Go · 7619 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 "regexp"
10 "strings"
11
12 "github.com/jackc/pgx/v5"
13 "github.com/jackc/pgx/v5/pgconn"
14 "github.com/jackc/pgx/v5/pgtype"
15
16 "github.com/tenseleyFlow/shithub/internal/entitlements"
17 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18 )
19
20 // Errors surfaced by the team orchestrator.
21 var (
22 ErrTeamNotFound = errors.New("orgs: team not found")
23 ErrTeamSlugInvalid = errors.New("orgs: team slug must be lowercase letters, digits, and hyphens (1-50)")
24 ErrTeamSlugTaken = errors.New("orgs: team slug already in use in this org")
25 ErrTeamNestingTooDeep = errors.New("orgs: team nesting limited to one level (parent already has a parent)")
26 ErrTeamSelfParent = errors.New("orgs: a team cannot be its own parent")
27 ErrTeamCrossOrgParent = errors.New("orgs: parent team must belong to the same org")
28 ErrTeamMissingActor = errors.New("orgs: actor required for team mutation")
29 ErrTeamRoleInvalid = errors.New("orgs: invalid team role")
30 ErrTeamRepoRoleInvalid = errors.New("orgs: invalid team repo-access role")
31 )
32
33 // teamSlugRE matches the team slug shape (lowercase letters, digits,
34 // hyphens, dots; can't start/end with hyphen). 50 chars matches the
35 // migration's CHECK.
36 var teamSlugRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,48}[a-z0-9])?$`)
37
38 // CreateTeamParams describes a new-team request.
39 type CreateTeamParams struct {
40 OrgID int64
41 Slug string
42 DisplayName string
43 Description string
44 ParentTeamID int64 // 0 → top-level
45 Privacy string // "visible" | "secret"
46 CreatedByUserID int64
47 }
48
49 // CreateTeam inserts a team row. Validates slug format, checks
50 // parent-team org match, and translates the trigger's nesting-violation
51 // SQLSTATE into a friendly error.
52 func CreateTeam(ctx context.Context, deps Deps, p CreateTeamParams) (orgsdb.Team, error) {
53 slug := strings.ToLower(strings.TrimSpace(p.Slug))
54 if !teamSlugRE.MatchString(slug) {
55 return orgsdb.Team{}, ErrTeamSlugInvalid
56 }
57 if p.CreatedByUserID == 0 {
58 return orgsdb.Team{}, ErrTeamMissingActor
59 }
60 priv, err := parsePrivacy(p.Privacy)
61 if err != nil {
62 return orgsdb.Team{}, err
63 }
64 q := orgsdb.New()
65 if p.ParentTeamID != 0 {
66 parent, perr := q.GetTeamByID(ctx, deps.Pool, p.ParentTeamID)
67 if perr != nil {
68 if errors.Is(perr, pgx.ErrNoRows) {
69 return orgsdb.Team{}, ErrTeamNotFound
70 }
71 return orgsdb.Team{}, perr
72 }
73 if parent.OrgID != p.OrgID {
74 return orgsdb.Team{}, ErrTeamCrossOrgParent
75 }
76 }
77 row, err := q.CreateTeam(ctx, deps.Pool, orgsdb.CreateTeamParams{
78 OrgID: p.OrgID,
79 Slug: slug,
80 DisplayName: strings.TrimSpace(p.DisplayName),
81 Description: strings.TrimSpace(p.Description),
82 ParentTeamID: pgtype.Int8{Int64: p.ParentTeamID, Valid: p.ParentTeamID != 0},
83 Privacy: priv,
84 CreatedByUserID: pgtype.Int8{Int64: p.CreatedByUserID, Valid: true},
85 })
86 if err != nil {
87 return orgsdb.Team{}, translateTeamError(err)
88 }
89 return row, nil
90 }
91
92 // SetParent changes a team's parent. Both the no-self-parent CHECK
93 // and the one-level-nesting trigger fire here; we translate to the
94 // orchestrator-level errors.
95 func SetTeamParent(ctx context.Context, deps Deps, teamID, parentTeamID int64) error {
96 if parentTeamID == teamID {
97 return ErrTeamSelfParent
98 }
99 err := orgsdb.New().SetTeamParent(ctx, deps.Pool, orgsdb.SetTeamParentParams{
100 ID: teamID,
101 ParentTeamID: pgtype.Int8{Int64: parentTeamID, Valid: parentTeamID != 0},
102 })
103 return translateTeamError(err)
104 }
105
106 // AddTeamMember inserts (team, user) at the given role. Idempotent on
107 // the pair (the sqlc query uses ON CONFLICT DO NOTHING). Caller
108 // resolves the policy gate; this orchestrator just shapes the row.
109 func AddTeamMember(ctx context.Context, deps Deps, teamID, userID, addedByUserID int64, role string) error {
110 r, err := parseTeamRole(role)
111 if err != nil {
112 return err
113 }
114 check, err := entitlements.CheckTeamMemberPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, teamID, userID)
115 if err != nil {
116 return err
117 }
118 if err := check.Err(); err != nil {
119 return err
120 }
121 return orgsdb.New().AddTeamMember(ctx, deps.Pool, orgsdb.AddTeamMemberParams{
122 TeamID: teamID,
123 UserID: userID,
124 Role: r,
125 AddedByUserID: pgtype.Int8{Int64: addedByUserID, Valid: addedByUserID != 0},
126 })
127 }
128
129 // RemoveTeamMember drops the (team, user) pair. No last-maintainer
130 // protection here — teams without maintainers are fine; org owners
131 // can always still mutate the team via the org-owner policy bypass.
132 func RemoveTeamMember(ctx context.Context, deps Deps, teamID, userID int64) error {
133 return orgsdb.New().RemoveTeamMember(ctx, deps.Pool, orgsdb.RemoveTeamMemberParams{
134 TeamID: teamID, UserID: userID,
135 })
136 }
137
138 // GrantRepoAccess upserts the team's role on a repo. ON CONFLICT
139 // DO UPDATE so re-granting at a new role is one call.
140 func GrantTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID, addedByUserID int64, role string) error {
141 r, err := parseTeamRepoRole(role)
142 if err != nil {
143 return err
144 }
145 check, err := entitlements.CheckTeamPrivateRepoGrant(ctx, entitlements.Deps{Pool: deps.Pool}, teamID, repoID)
146 if err != nil {
147 return err
148 }
149 if err := check.Err(); err != nil {
150 return err
151 }
152 return orgsdb.New().GrantTeamRepoAccess(ctx, deps.Pool, orgsdb.GrantTeamRepoAccessParams{
153 TeamID: teamID,
154 RepoID: repoID,
155 Role: r,
156 AddedByUserID: pgtype.Int8{Int64: addedByUserID, Valid: addedByUserID != 0},
157 })
158 }
159
160 // RevokeTeamRepoAccess drops the team's grant. Effective immediately
161 // (next request denies; per-request policy cache means in-flight
162 // requests can still pass — same staleness as collaborator changes).
163 func RevokeTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID int64) error {
164 return orgsdb.New().RevokeTeamRepoAccess(ctx, deps.Pool, orgsdb.RevokeTeamRepoAccessParams{
165 TeamID: teamID, RepoID: repoID,
166 })
167 }
168
169 // ─── helpers ───────────────────────────────────────────────────────
170
171 func parsePrivacy(s string) (orgsdb.TeamPrivacy, error) {
172 switch s {
173 case "", "visible":
174 return orgsdb.TeamPrivacyVisible, nil
175 case "secret":
176 return orgsdb.TeamPrivacySecret, nil
177 }
178 return "", fmt.Errorf("orgs: invalid team privacy %q", s)
179 }
180
181 func parseTeamRole(s string) (orgsdb.TeamRole, error) {
182 switch s {
183 case "", "member":
184 return orgsdb.TeamRoleMember, nil
185 case "maintainer":
186 return orgsdb.TeamRoleMaintainer, nil
187 }
188 return "", ErrTeamRoleInvalid
189 }
190
191 func parseTeamRepoRole(s string) (orgsdb.TeamRepoRole, error) {
192 switch s {
193 case "read":
194 return orgsdb.TeamRepoRoleRead, nil
195 case "triage":
196 return orgsdb.TeamRepoRoleTriage, nil
197 case "write":
198 return orgsdb.TeamRepoRoleWrite, nil
199 case "maintain":
200 return orgsdb.TeamRepoRoleMaintain, nil
201 case "admin":
202 return orgsdb.TeamRepoRoleAdmin, nil
203 }
204 return "", ErrTeamRepoRoleInvalid
205 }
206
207 // translateTeamError maps Postgres errors back to the orchestrator's
208 // typed errors. The migration's nesting trigger raises CHECK
209 // (SQLSTATE 23514); the unique index on (org_id, slug) raises 23505.
210 func translateTeamError(err error) error {
211 if err == nil {
212 return nil
213 }
214 var pgErr *pgconn.PgError
215 if errors.As(err, &pgErr) {
216 switch pgErr.Code {
217 case "23505":
218 return ErrTeamSlugTaken
219 case "23514":
220 if strings.Contains(pgErr.Message, "no_self_parent") {
221 return ErrTeamSelfParent
222 }
223 return ErrTeamNestingTooDeep
224 }
225 }
226 return err
227 }
228