Go · 5266 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package orgs
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9
10 "github.com/jackc/pgx/v5"
11 "github.com/jackc/pgx/v5/pgtype"
12
13 "github.com/tenseleyFlow/shithub/internal/entitlements"
14 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
15 )
16
17 // AddMember inserts an (org, user) pair with the supplied role.
18 // Idempotent on the pair: a re-add for an existing member is a no-op
19 // at the DB layer (matches the sqlc query's ON CONFLICT DO NOTHING).
20 // Caller is responsible for the policy check that the actor is allowed
21 // to manage members.
22 func AddMember(ctx context.Context, deps Deps, orgID, userID, invitedByUserID int64, role string) error {
23 if role == "" {
24 role = "member"
25 }
26 r, err := parseRole(role)
27 if err != nil {
28 return err
29 }
30 if r == orgsdb.OrgRoleOwner {
31 check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, orgID, userID)
32 if err != nil {
33 return err
34 }
35 if err := check.Err(); err != nil {
36 return err
37 }
38 }
39 tx, err := deps.Pool.Begin(ctx)
40 if err != nil {
41 return err
42 }
43 committed := false
44 defer func() {
45 if !committed {
46 _ = tx.Rollback(ctx)
47 }
48 }()
49 q := orgsdb.New()
50 if err := q.AddOrgMember(ctx, tx, orgsdb.AddOrgMemberParams{
51 OrgID: orgID,
52 UserID: userID,
53 Role: r,
54 InvitedByUserID: pgtype.Int8{Int64: invitedByUserID, Valid: invitedByUserID != 0},
55 }); err != nil {
56 return err
57 }
58 if err := enqueueBillingSeatSync(ctx, tx, deps, orgID); err != nil {
59 return fmt.Errorf("enqueue billing seat sync: %w", err)
60 }
61 if err := tx.Commit(ctx); err != nil {
62 return err
63 }
64 committed = true
65 return nil
66 }
67
68 // ChangeRole updates a member's role with last-owner protection: the
69 // only owner cannot be demoted (refuse with ErrLastOwner). Caller has
70 // already verified the actor's policy.
71 func ChangeRole(ctx context.Context, deps Deps, orgID, userID int64, role string) error {
72 r, err := parseRole(role)
73 if err != nil {
74 return err
75 }
76 q := orgsdb.New()
77 current, err := q.GetOrgMember(ctx, deps.Pool, orgsdb.GetOrgMemberParams{
78 OrgID: orgID, UserID: userID,
79 })
80 if err != nil {
81 if errors.Is(err, pgx.ErrNoRows) {
82 return ErrNotAMember
83 }
84 return err
85 }
86 if current.Role == orgsdb.OrgRoleOwner && r != orgsdb.OrgRoleOwner {
87 // Demoting an owner: refuse if they're the last one.
88 count, err := q.CountOrgOwners(ctx, deps.Pool, orgID)
89 if err != nil {
90 return err
91 }
92 if count <= 1 {
93 return ErrLastOwner
94 }
95 }
96 if current.Role != orgsdb.OrgRoleOwner && r == orgsdb.OrgRoleOwner {
97 check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, orgID, userID)
98 if err != nil {
99 return err
100 }
101 if err := check.Err(); err != nil {
102 return err
103 }
104 }
105 return q.ChangeOrgMemberRole(ctx, deps.Pool, orgsdb.ChangeOrgMemberRoleParams{
106 OrgID: orgID, UserID: userID, Role: r,
107 })
108 }
109
110 // RemoveMember deletes the (org, user) row. Last-owner protection
111 // applies — we refuse to drop the only owner. Removing oneself is
112 // fine when there are ≥2 owners.
113 func RemoveMember(ctx context.Context, deps Deps, orgID, userID int64) error {
114 tx, err := deps.Pool.Begin(ctx)
115 if err != nil {
116 return err
117 }
118 committed := false
119 defer func() {
120 if !committed {
121 _ = tx.Rollback(ctx)
122 }
123 }()
124 q := orgsdb.New()
125 current, err := q.GetOrgMember(ctx, tx, orgsdb.GetOrgMemberParams{
126 OrgID: orgID, UserID: userID,
127 })
128 if err != nil {
129 if errors.Is(err, pgx.ErrNoRows) {
130 return ErrNotAMember
131 }
132 return err
133 }
134 if current.Role == orgsdb.OrgRoleOwner {
135 count, err := q.CountOrgOwners(ctx, tx, orgID)
136 if err != nil {
137 return err
138 }
139 if count <= 1 {
140 return ErrLastOwner
141 }
142 }
143 if err := q.RemoveOrgMember(ctx, tx, orgsdb.RemoveOrgMemberParams{
144 OrgID: orgID, UserID: userID,
145 }); err != nil {
146 return err
147 }
148 if err := enqueueBillingSeatSync(ctx, tx, deps, orgID); err != nil {
149 return fmt.Errorf("enqueue billing seat sync: %w", err)
150 }
151 if err := tx.Commit(ctx); err != nil {
152 return err
153 }
154 committed = true
155 return nil
156 }
157
158 // IsMember reports whether the user is a member of the org. Used by
159 // policy + the org-owner repo-create gate.
160 func IsMember(ctx context.Context, deps Deps, orgID, userID int64) (bool, error) {
161 _, err := orgsdb.New().GetOrgMember(ctx, deps.Pool, orgsdb.GetOrgMemberParams{
162 OrgID: orgID, UserID: userID,
163 })
164 if err != nil {
165 if errors.Is(err, pgx.ErrNoRows) {
166 return false, nil
167 }
168 return false, err
169 }
170 return true, nil
171 }
172
173 // IsOwner reports whether the user is an owner of the org.
174 func IsOwner(ctx context.Context, deps Deps, orgID, userID int64) (bool, error) {
175 row, err := orgsdb.New().GetOrgMember(ctx, deps.Pool, orgsdb.GetOrgMemberParams{
176 OrgID: orgID, UserID: userID,
177 })
178 if err != nil {
179 if errors.Is(err, pgx.ErrNoRows) {
180 return false, nil
181 }
182 return false, err
183 }
184 return row.Role == orgsdb.OrgRoleOwner, nil
185 }
186
187 // parseRole returns the typed enum value, or an error for unknown
188 // strings (defends against a hand-crafted POST body).
189 func parseRole(s string) (orgsdb.OrgRole, error) {
190 switch s {
191 case "owner":
192 return orgsdb.OrgRoleOwner, nil
193 case "member":
194 return orgsdb.OrgRoleMember, nil
195 default:
196 return "", fmt.Errorf("orgs: invalid role %q", s)
197 }
198 }
199