| 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 |