tenseleyflow/shithub / 41454b8

Browse files

S31: teams + policy max-of-sources (org owner | direct collab | team grant + parent)

Authored by espadonne
SHA
41454b8733f9bca9f0dd1838a3603fde1ff9921c
Parents
3c50abc
Tree
e558583

9 changed files

StatusFile+-
M internal/auth/policy/org_owner_test.go 123 0
M internal/auth/policy/policy.go 106 5
A internal/migrationsfs/migrations/0035_teams.sql 103 0
A internal/orgs/queries/teams.sql 109 0
M internal/orgs/sqlc/models.go 158 0
M internal/orgs/sqlc/querier.go 36 0
A internal/orgs/sqlc/teams.sql.go 593 0
A internal/orgs/teams.go 212 0
A internal/orgs/teams_test.go 112 0
internal/auth/policy/org_owner_test.gomodified
@@ -7,6 +7,7 @@ import (
7
 	"testing"
7
 	"testing"
8
 
8
 
9
 	"github.com/jackc/pgx/v5/pgtype"
9
 	"github.com/jackc/pgx/v5/pgtype"
10
+	"github.com/jackc/pgx/v5/pgxpool"
10
 
11
 
11
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
12
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
12
 	"github.com/tenseleyFlow/shithub/internal/orgs"
13
 	"github.com/tenseleyFlow/shithub/internal/orgs"
@@ -91,6 +92,128 @@ func TestOrgOwner_ImplicitAdmin(t *testing.T) {
91
 	}
92
 	}
92
 }
93
 }
93
 
94
 
95
+// TestTeamGrant_GivesWriteAccess pins the S31 contract: a team grant
96
+// at `write` on an org repo lets that team's members push, even
97
+// though they're plain org members (not owners) and have no direct
98
+// collaborator row.
99
+func TestTeamGrant_GivesWriteAccess(t *testing.T) {
100
+	pool := dbtest.NewTestDB(t)
101
+	ctx := context.Background()
102
+
103
+	creator, _ := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
104
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
105
+	})
106
+	deps := orgs.Deps{Pool: pool}
107
+	org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: creator.ID})
108
+	repo, _ := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{
109
+		OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
110
+		Name:       "demo", DefaultBranch: "trunk", Visibility: reposdb.RepoVisibilityPublic,
111
+	})
112
+	bob, _ := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
113
+		Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
114
+	})
115
+	if err := orgs.AddMember(ctx, deps, org.ID, bob.ID, creator.ID, "member"); err != nil {
116
+		t.Fatalf("add org member: %v", err)
117
+	}
118
+	team, _ := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
119
+		OrgID: org.ID, Slug: "eng", CreatedByUserID: creator.ID,
120
+	})
121
+	if err := orgs.AddTeamMember(ctx, deps, team.ID, bob.ID, creator.ID, "member"); err != nil {
122
+		t.Fatalf("add team member: %v", err)
123
+	}
124
+	if err := orgs.GrantTeamRepoAccess(ctx, deps, team.ID, repo.ID, creator.ID, "write"); err != nil {
125
+		t.Fatalf("grant: %v", err)
126
+	}
127
+
128
+	ref := policy.NewRepoRefFromRepo(repo)
129
+	bobActor := policy.UserActor(bob.ID, "bob", false, false)
130
+	pdeps := policy.Deps{Pool: pool}
131
+
132
+	if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); !got.Allow {
133
+		t.Fatalf("team-granted write should allow: %+v", got)
134
+	}
135
+
136
+	// Demote to read → write must now deny.
137
+	if err := orgs.GrantTeamRepoAccess(ctx, deps, team.ID, repo.ID, creator.ID, "read"); err != nil {
138
+		t.Fatalf("demote: %v", err)
139
+	}
140
+	if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); got.Allow {
141
+		t.Fatal("after demote to read, write should deny")
142
+	}
143
+	// Reads still allow under read role.
144
+	if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoRead, ref); !got.Allow {
145
+		t.Fatalf("read should still allow at read role: %+v", got)
146
+	}
147
+
148
+	// Revoke entirely → reads on a public repo still allow (visibility),
149
+	// but for the test of "team membership lost access" we use a
150
+	// private repo branch:
151
+	if _, err := pool.Exec(ctx, `UPDATE repos SET visibility='private' WHERE id=$1`, repo.ID); err != nil {
152
+		t.Fatalf("flip private: %v", err)
153
+	}
154
+	// Cache from the previous Can() call may still be hot — start a
155
+	// fresh request scope by using a new context.
156
+	if err := orgs.RevokeTeamRepoAccess(ctx, deps, team.ID, repo.ID); err != nil {
157
+		t.Fatalf("revoke: %v", err)
158
+	}
159
+	freshCtx := context.Background()
160
+	if got := policy.Can(freshCtx, pdeps, bobActor, policy.ActionRepoRead,
161
+		policy.NewRepoRefFromRepo(reposdbReload(t, pool, repo.ID))); got.Allow {
162
+		t.Fatalf("after revoke, private read should deny: %+v", got)
163
+	}
164
+}
165
+
166
+// TestTeamParent_Inheritance pins the one-level parent inheritance
167
+// rule: a child-team member inherits the parent team's repo grants.
168
+func TestTeamParent_Inheritance(t *testing.T) {
169
+	pool := dbtest.NewTestDB(t)
170
+	ctx := context.Background()
171
+	creator, _ := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
172
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
173
+	})
174
+	deps := orgs.Deps{Pool: pool}
175
+	org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: creator.ID})
176
+	repo, _ := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{
177
+		OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
178
+		Name:       "demo", DefaultBranch: "trunk", Visibility: reposdb.RepoVisibilityPublic,
179
+	})
180
+	bob, _ := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
181
+		Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
182
+	})
183
+	_ = orgs.AddMember(ctx, deps, org.ID, bob.ID, creator.ID, "member")
184
+
185
+	parent, _ := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
186
+		OrgID: org.ID, Slug: "engineering", CreatedByUserID: creator.ID,
187
+	})
188
+	child, _ := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
189
+		OrgID: org.ID, Slug: "eng-mobile", ParentTeamID: parent.ID,
190
+		CreatedByUserID: creator.ID,
191
+	})
192
+	// Bob is in the CHILD team only. Grant write on the PARENT team.
193
+	_ = orgs.AddTeamMember(ctx, deps, child.ID, bob.ID, creator.ID, "member")
194
+	if err := orgs.GrantTeamRepoAccess(ctx, deps, parent.ID, repo.ID, creator.ID, "write"); err != nil {
195
+		t.Fatalf("parent grant: %v", err)
196
+	}
197
+
198
+	ref := policy.NewRepoRefFromRepo(repo)
199
+	bobActor := policy.UserActor(bob.ID, "bob", false, false)
200
+	pdeps := policy.Deps{Pool: pool}
201
+	if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); !got.Allow {
202
+		t.Fatalf("child team should inherit parent's write grant: %+v", got)
203
+	}
204
+}
205
+
206
+// reposdbReload re-fetches a repo row so a follow-up policy.Can sees
207
+// fresh visibility after a raw UPDATE.
208
+func reposdbReload(t *testing.T, pool *pgxpool.Pool, id int64) reposdb.Repo {
209
+	t.Helper()
210
+	row, err := reposdb.New().GetRepoByID(context.Background(), pool, id)
211
+	if err != nil {
212
+		t.Fatalf("reload repo: %v", err)
213
+	}
214
+	return row
215
+}
216
+
94
 // TestOrgSuspended_BlocksWrites pins the S30 contract: when an org
217
 // TestOrgSuspended_BlocksWrites pins the S30 contract: when an org
95
 // is suspended, every write action against an org-owned repo is
218
 // is suspended, every write action against an org-owned repo is
96
 // denied — even for the org owner. Reads still allow.
219
 // denied — even for the org owner. Reads still allow.
internal/auth/policy/policy.gomodified
@@ -8,6 +8,7 @@ import (
8
 	"net/http"
8
 	"net/http"
9
 
9
 
10
 	"github.com/jackc/pgx/v5"
10
 	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgtype"
11
 	"github.com/jackc/pgx/v5/pgxpool"
12
 	"github.com/jackc/pgx/v5/pgxpool"
12
 
13
 
13
 	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
14
 	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
@@ -253,23 +254,123 @@ func effectiveRole(ctx context.Context, d Deps, actor Actor, repo RepoRef) (Role
253
 		// lookup below.
254
 		// lookup below.
254
 	}
255
 	}
255
 
256
 
257
+	// Effective role = MAX(direct collab, team grants). Team path
258
+	// runs only for org-owned repos (user-owned repos have no
259
+	// teams). One hop on parent_team_id captures the inherited
260
+	// grants per S31's one-level-deep rule.
261
+	best := RoleNone
256
 	q := policydb.New()
262
 	q := policydb.New()
257
 	dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{
263
 	dbRole, err := q.GetCollabRole(ctx, d.Pool, policydb.GetCollabRoleParams{
258
 		RepoID: repo.ID,
264
 		RepoID: repo.ID,
259
 		UserID: actor.UserID,
265
 		UserID: actor.UserID,
260
 	})
266
 	})
261
-	if errors.Is(err, pgx.ErrNoRows) {
267
+	switch {
262
-		cachePut(cache, key, RoleNone)
268
+	case err == nil:
269
+		best = roleFromDB(dbRole)
270
+	case errors.Is(err, pgx.ErrNoRows):
271
+		// no direct collab row — best stays RoleNone
272
+	default:
273
+		return RoleNone, err
274
+	}
275
+	if repo.OwnerOrgID != 0 {
276
+		teamRole, terr := teamGrantedRole(ctx, d, actor.UserID, repo.OwnerOrgID, repo.ID)
277
+		if terr != nil {
278
+			return RoleNone, terr
279
+		}
280
+		// roleStronger picks the higher-rank role. Don't use
281
+		// RoleAtLeast here — its `want > 0` guard treats RoleNone
282
+		// as un-comparable and blocks the legitimate "any role
283
+		// beats no role" branch.
284
+		if roleStronger(teamRole, best) {
285
+			best = teamRole
286
+		}
287
+	}
288
+	cachePut(cache, key, best)
289
+	return best, nil
290
+}
291
+
292
+// roleStronger reports whether `a` ranks strictly higher than `b`,
293
+// where RoleNone is the bottom (rank 0). Used to compose role
294
+// sources (direct collab, team grant, parent-team grant) into a
295
+// single max — the spec's "effective role = max of all sources"
296
+// rule.
297
+func roleStronger(a, b Role) bool {
298
+	return roleRank(a) > roleRank(b)
299
+}
300
+
301
+// teamGrantedRole computes the highest role the actor inherits from
302
+// any team in the org that has a grant on the repo. Walks parent
303
+// teams one hop (per the one-level-nesting cap from migration 0035).
304
+func teamGrantedRole(ctx context.Context, d Deps, userID, orgID, repoID int64) (Role, error) {
305
+	rows, err := d.Pool.Query(ctx,
306
+		`SELECT t.id, t.parent_team_id
307
+		   FROM team_members m
308
+		   JOIN teams t ON t.id = m.team_id
309
+		  WHERE t.org_id = $1 AND m.user_id = $2`,
310
+		orgID, userID)
311
+	if err != nil {
312
+		return RoleNone, err
313
+	}
314
+	defer rows.Close()
315
+	teamIDs := []int64{}
316
+	for rows.Next() {
317
+		var id int64
318
+		var parent pgtype.Int8
319
+		if err := rows.Scan(&id, &parent); err != nil {
320
+			return RoleNone, err
321
+		}
322
+		teamIDs = append(teamIDs, id)
323
+		if parent.Valid {
324
+			teamIDs = append(teamIDs, parent.Int64)
325
+		}
326
+	}
327
+	if err := rows.Err(); err != nil {
328
+		return RoleNone, err
329
+	}
330
+	if len(teamIDs) == 0 {
263
 		return RoleNone, nil
331
 		return RoleNone, nil
264
 	}
332
 	}
333
+	grantRows, err := d.Pool.Query(ctx,
334
+		`SELECT role::text FROM team_repo_access
335
+		  WHERE repo_id = $1 AND team_id = ANY($2::bigint[])`,
336
+		repoID, teamIDs)
265
 	if err != nil {
337
 	if err != nil {
266
 		return RoleNone, err
338
 		return RoleNone, err
267
 	}
339
 	}
268
-	r := roleFromDB(dbRole)
340
+	defer grantRows.Close()
269
-	cachePut(cache, key, r)
341
+	best := RoleNone
270
-	return r, nil
342
+	for grantRows.Next() {
343
+		var role string
344
+		if err := grantRows.Scan(&role); err != nil {
345
+			return RoleNone, err
346
+		}
347
+		if r := teamRepoRoleToPolicyRole(role); roleStronger(r, best) {
348
+			best = r
349
+		}
350
+	}
351
+	return best, grantRows.Err()
271
 }
352
 }
272
 
353
 
354
+// teamRepoRoleToPolicyRole maps the team_repo_role enum string to
355
+// the policy.Role string. Names align but the typed enums are in
356
+// different packages so the conversion is explicit.
357
+func teamRepoRoleToPolicyRole(s string) Role {
358
+	switch s {
359
+	case "read":
360
+		return RoleRead
361
+	case "triage":
362
+		return RoleTriage
363
+	case "write":
364
+		return RoleWrite
365
+	case "maintain":
366
+		return RoleMaintain
367
+	case "admin":
368
+		return RoleAdmin
369
+	}
370
+	return RoleNone
371
+}
372
+
373
+
273
 // minRoleFor returns the minimum collaborator role required for the
374
 // minRoleFor returns the minimum collaborator role required for the
274
 // action against an existing repo. Owner is implicit admin, so this
375
 // action against an existing repo. Owner is implicit admin, so this
275
 // table is the single source of truth for "what role grants what."
376
 // table is the single source of truth for "what role grants what."
internal/migrationsfs/migrations/0035_teams.sqladded
@@ -0,0 +1,103 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- S31 — Teams within organizations.
4
+--
5
+-- One level of nesting is intentional and structurally enforced via
6
+-- two CHECKs:
7
+--   - parent_team_id != id  (no self-parent)
8
+--   - if parent_team_id IS NOT NULL then the parent's parent_team_id
9
+--     is NULL (validated via a trigger; can't express in a row CHECK)
10
+--
11
+-- team_repo_access is the per-team repo grant; the per-user grant
12
+-- continues to live in `repo_collaborators` (S15). Both contribute to
13
+-- the policy aggregator's max-of-sources rule.
14
+
15
+-- +goose Up
16
+
17
+CREATE TYPE team_privacy   AS ENUM ('visible', 'secret');
18
+CREATE TYPE team_role      AS ENUM ('member', 'maintainer');
19
+CREATE TYPE team_repo_role AS ENUM ('read', 'triage', 'write', 'maintain', 'admin');
20
+
21
+CREATE TABLE teams (
22
+    id                 bigserial    PRIMARY KEY,
23
+    org_id             bigint       NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
24
+    slug               citext       NOT NULL,
25
+    display_name       text         NOT NULL DEFAULT '',
26
+    description        text         NOT NULL DEFAULT '',
27
+    parent_team_id     bigint       REFERENCES teams(id) ON DELETE SET NULL,
28
+    privacy            team_privacy NOT NULL DEFAULT 'visible',
29
+    created_by_user_id bigint       REFERENCES users(id) ON DELETE SET NULL,
30
+    created_at         timestamptz  NOT NULL DEFAULT now(),
31
+    updated_at         timestamptz  NOT NULL DEFAULT now(),
32
+
33
+    CONSTRAINT teams_slug_length CHECK (char_length(slug::text) BETWEEN 1 AND 50),
34
+    CONSTRAINT teams_no_self_parent CHECK (parent_team_id IS NULL OR parent_team_id <> id),
35
+    CONSTRAINT teams_org_slug_unique UNIQUE (org_id, slug)
36
+);
37
+
38
+CREATE INDEX teams_org_idx ON teams (org_id);
39
+CREATE INDEX teams_parent_idx ON teams (parent_team_id) WHERE parent_team_id IS NOT NULL;
40
+
41
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON teams
42
+    FOR EACH ROW EXECUTE FUNCTION tg_set_updated_at();
43
+
44
+-- One-level-deep nesting enforcement: a team's parent must itself
45
+-- have a NULL parent. Implemented as a trigger because the rule
46
+-- spans rows.
47
+-- +goose StatementBegin
48
+CREATE OR REPLACE FUNCTION tg_teams_one_level_nesting() RETURNS trigger AS $$
49
+DECLARE
50
+    grandparent_id bigint;
51
+BEGIN
52
+    IF NEW.parent_team_id IS NULL THEN
53
+        RETURN NEW;
54
+    END IF;
55
+    SELECT parent_team_id INTO grandparent_id FROM teams WHERE id = NEW.parent_team_id;
56
+    IF grandparent_id IS NOT NULL THEN
57
+        RAISE EXCEPTION 'teams: nesting limited to one level (parent already has a parent)'
58
+            USING ERRCODE = '23514';
59
+    END IF;
60
+    RETURN NEW;
61
+END;
62
+$$ LANGUAGE plpgsql;
63
+-- +goose StatementEnd
64
+
65
+CREATE TRIGGER tg_teams_one_level_nesting_iu
66
+    BEFORE INSERT OR UPDATE ON teams
67
+    FOR EACH ROW EXECUTE FUNCTION tg_teams_one_level_nesting();
68
+
69
+-- ─── team_members ─────────────────────────────────────────────────
70
+CREATE TABLE team_members (
71
+    team_id          bigint      NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
72
+    user_id          bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
73
+    role             team_role   NOT NULL DEFAULT 'member',
74
+    added_by_user_id bigint      REFERENCES users(id) ON DELETE SET NULL,
75
+    added_at         timestamptz NOT NULL DEFAULT now(),
76
+
77
+    PRIMARY KEY (team_id, user_id)
78
+);
79
+
80
+CREATE INDEX team_members_user_idx ON team_members (user_id);
81
+
82
+-- ─── team_repo_access ─────────────────────────────────────────────
83
+CREATE TABLE team_repo_access (
84
+    team_id          bigint         NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
85
+    repo_id          bigint         NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
86
+    role             team_repo_role NOT NULL DEFAULT 'read',
87
+    added_by_user_id bigint         REFERENCES users(id) ON DELETE SET NULL,
88
+    added_at         timestamptz    NOT NULL DEFAULT now(),
89
+
90
+    PRIMARY KEY (team_id, repo_id)
91
+);
92
+
93
+CREATE INDEX team_repo_access_repo_idx ON team_repo_access (repo_id);
94
+
95
+-- +goose Down
96
+DROP TABLE IF EXISTS team_repo_access;
97
+DROP TABLE IF EXISTS team_members;
98
+DROP TRIGGER IF EXISTS tg_teams_one_level_nesting_iu ON teams;
99
+DROP FUNCTION IF EXISTS tg_teams_one_level_nesting();
100
+DROP TABLE IF EXISTS teams;
101
+DROP TYPE IF EXISTS team_repo_role;
102
+DROP TYPE IF EXISTS team_role;
103
+DROP TYPE IF EXISTS team_privacy;
internal/orgs/queries/teams.sqladded
@@ -0,0 +1,109 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- ─── teams ─────────────────────────────────────────────────────────
4
+
5
+-- name: CreateTeam :one
6
+INSERT INTO teams (org_id, slug, display_name, description, parent_team_id, privacy, created_by_user_id)
7
+VALUES ($1, $2, $3, $4, sqlc.narg(parent_team_id)::bigint, $5, sqlc.narg(created_by_user_id)::bigint)
8
+RETURNING *;
9
+
10
+-- name: GetTeamByID :one
11
+SELECT * FROM teams WHERE id = $1;
12
+
13
+-- name: GetTeamByOrgAndSlug :one
14
+SELECT * FROM teams WHERE org_id = $1 AND slug = $2;
15
+
16
+-- name: ListTeamsForOrg :many
17
+SELECT * FROM teams WHERE org_id = $1 ORDER BY slug ASC;
18
+
19
+-- name: ListChildTeams :many
20
+SELECT * FROM teams WHERE parent_team_id = $1 ORDER BY slug ASC;
21
+
22
+-- name: UpdateTeamProfile :exec
23
+UPDATE teams SET display_name = $2, description = $3, privacy = $4, updated_at = now()
24
+WHERE id = $1;
25
+
26
+-- name: SetTeamParent :exec
27
+-- The one-level-nesting BEFORE trigger blocks invalid moves; the
28
+-- caller surfaces the SQLSTATE 23514 as a friendly error.
29
+UPDATE teams SET parent_team_id = sqlc.narg(parent_team_id)::bigint, updated_at = now()
30
+WHERE id = $1;
31
+
32
+-- name: DeleteTeam :exec
33
+-- Children's parent_team_id flips to NULL via the FK ON DELETE SET NULL,
34
+-- promoting them to top-level — matches the "deleting parent doesn't
35
+-- delete children" intent in the spec's pitfalls.
36
+DELETE FROM teams WHERE id = $1;
37
+
38
+-- ─── team_members ─────────────────────────────────────────────────
39
+
40
+-- name: AddTeamMember :exec
41
+INSERT INTO team_members (team_id, user_id, role, added_by_user_id)
42
+VALUES ($1, $2, $3, sqlc.narg(added_by_user_id)::bigint)
43
+ON CONFLICT (team_id, user_id) DO NOTHING;
44
+
45
+-- name: SetTeamMemberRole :exec
46
+UPDATE team_members SET role = $3 WHERE team_id = $1 AND user_id = $2;
47
+
48
+-- name: RemoveTeamMember :exec
49
+DELETE FROM team_members WHERE team_id = $1 AND user_id = $2;
50
+
51
+-- name: ListTeamMembers :many
52
+SELECT m.team_id, m.user_id, m.role, m.added_by_user_id, m.added_at,
53
+       u.username, u.display_name
54
+FROM team_members m
55
+JOIN users u ON u.id = m.user_id
56
+WHERE m.team_id = $1 AND u.deleted_at IS NULL
57
+ORDER BY m.role ASC, u.username ASC;
58
+
59
+-- name: ListTeamsForUserInOrg :many
60
+-- Returns the teams a user directly belongs to within an org. The
61
+-- policy aggregator unions this with each row's parent_team_id to
62
+-- get the inherited set.
63
+SELECT t.id, t.org_id, t.slug, t.display_name, t.description,
64
+       t.parent_team_id, t.privacy, t.created_by_user_id,
65
+       t.created_at, t.updated_at,
66
+       m.role AS member_role
67
+FROM team_members m
68
+JOIN teams t ON t.id = m.team_id
69
+WHERE t.org_id = $1 AND m.user_id = $2;
70
+
71
+-- ─── team_repo_access ─────────────────────────────────────────────
72
+
73
+-- name: GrantTeamRepoAccess :exec
74
+INSERT INTO team_repo_access (team_id, repo_id, role, added_by_user_id)
75
+VALUES ($1, $2, $3, sqlc.narg(added_by_user_id)::bigint)
76
+ON CONFLICT (team_id, repo_id) DO UPDATE SET role = EXCLUDED.role;
77
+
78
+-- name: RevokeTeamRepoAccess :exec
79
+DELETE FROM team_repo_access WHERE team_id = $1 AND repo_id = $2;
80
+
81
+-- name: GetTeamRepoAccess :one
82
+SELECT * FROM team_repo_access WHERE team_id = $1 AND repo_id = $2;
83
+
84
+-- name: ListTeamRepoAccess :many
85
+-- All repos the team has any grant on; for the team-view page.
86
+SELECT a.team_id, a.repo_id, a.role, a.added_by_user_id, a.added_at,
87
+       r.name AS repo_name, r.visibility::text AS visibility
88
+FROM team_repo_access a
89
+JOIN repos r ON r.id = a.repo_id
90
+WHERE a.team_id = $1 AND r.deleted_at IS NULL
91
+ORDER BY r.name ASC;
92
+
93
+-- name: ListRepoTeamGrants :many
94
+-- All teams with grants on a single repo; for repo settings/access page.
95
+SELECT a.team_id, a.repo_id, a.role, a.added_by_user_id, a.added_at,
96
+       t.slug AS team_slug, t.display_name AS team_display_name,
97
+       t.privacy::text AS team_privacy
98
+FROM team_repo_access a
99
+JOIN teams t ON t.id = a.team_id
100
+WHERE a.repo_id = $1
101
+ORDER BY t.slug ASC;
102
+
103
+-- name: ListTeamAccessForRepoAndTeams :many
104
+-- Policy hot-path: given a repo and a set of team_ids the actor
105
+-- belongs to (incl. inherited), return the access rows. Caller picks
106
+-- the highest role.
107
+SELECT team_id, repo_id, role
108
+FROM team_repo_access
109
+WHERE repo_id = $1 AND team_id = ANY($2::bigint[]);
internal/orgs/sqlc/models.gomodified
@@ -834,6 +834,135 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
834
 	return string(ns.RepoVisibility), nil
834
 	return string(ns.RepoVisibility), nil
835
 }
835
 }
836
 
836
 
837
+type TeamPrivacy string
838
+
839
+const (
840
+	TeamPrivacyVisible TeamPrivacy = "visible"
841
+	TeamPrivacySecret  TeamPrivacy = "secret"
842
+)
843
+
844
+func (e *TeamPrivacy) Scan(src interface{}) error {
845
+	switch s := src.(type) {
846
+	case []byte:
847
+		*e = TeamPrivacy(s)
848
+	case string:
849
+		*e = TeamPrivacy(s)
850
+	default:
851
+		return fmt.Errorf("unsupported scan type for TeamPrivacy: %T", src)
852
+	}
853
+	return nil
854
+}
855
+
856
+type NullTeamPrivacy struct {
857
+	TeamPrivacy TeamPrivacy
858
+	Valid       bool // Valid is true if TeamPrivacy is not NULL
859
+}
860
+
861
+// Scan implements the Scanner interface.
862
+func (ns *NullTeamPrivacy) Scan(value interface{}) error {
863
+	if value == nil {
864
+		ns.TeamPrivacy, ns.Valid = "", false
865
+		return nil
866
+	}
867
+	ns.Valid = true
868
+	return ns.TeamPrivacy.Scan(value)
869
+}
870
+
871
+// Value implements the driver Valuer interface.
872
+func (ns NullTeamPrivacy) Value() (driver.Value, error) {
873
+	if !ns.Valid {
874
+		return nil, nil
875
+	}
876
+	return string(ns.TeamPrivacy), nil
877
+}
878
+
879
+type TeamRepoRole string
880
+
881
+const (
882
+	TeamRepoRoleRead     TeamRepoRole = "read"
883
+	TeamRepoRoleTriage   TeamRepoRole = "triage"
884
+	TeamRepoRoleWrite    TeamRepoRole = "write"
885
+	TeamRepoRoleMaintain TeamRepoRole = "maintain"
886
+	TeamRepoRoleAdmin    TeamRepoRole = "admin"
887
+)
888
+
889
+func (e *TeamRepoRole) Scan(src interface{}) error {
890
+	switch s := src.(type) {
891
+	case []byte:
892
+		*e = TeamRepoRole(s)
893
+	case string:
894
+		*e = TeamRepoRole(s)
895
+	default:
896
+		return fmt.Errorf("unsupported scan type for TeamRepoRole: %T", src)
897
+	}
898
+	return nil
899
+}
900
+
901
+type NullTeamRepoRole struct {
902
+	TeamRepoRole TeamRepoRole
903
+	Valid        bool // Valid is true if TeamRepoRole is not NULL
904
+}
905
+
906
+// Scan implements the Scanner interface.
907
+func (ns *NullTeamRepoRole) Scan(value interface{}) error {
908
+	if value == nil {
909
+		ns.TeamRepoRole, ns.Valid = "", false
910
+		return nil
911
+	}
912
+	ns.Valid = true
913
+	return ns.TeamRepoRole.Scan(value)
914
+}
915
+
916
+// Value implements the driver Valuer interface.
917
+func (ns NullTeamRepoRole) Value() (driver.Value, error) {
918
+	if !ns.Valid {
919
+		return nil, nil
920
+	}
921
+	return string(ns.TeamRepoRole), nil
922
+}
923
+
924
+type TeamRole string
925
+
926
+const (
927
+	TeamRoleMember     TeamRole = "member"
928
+	TeamRoleMaintainer TeamRole = "maintainer"
929
+)
930
+
931
+func (e *TeamRole) Scan(src interface{}) error {
932
+	switch s := src.(type) {
933
+	case []byte:
934
+		*e = TeamRole(s)
935
+	case string:
936
+		*e = TeamRole(s)
937
+	default:
938
+		return fmt.Errorf("unsupported scan type for TeamRole: %T", src)
939
+	}
940
+	return nil
941
+}
942
+
943
+type NullTeamRole struct {
944
+	TeamRole TeamRole
945
+	Valid    bool // Valid is true if TeamRole is not NULL
946
+}
947
+
948
+// Scan implements the Scanner interface.
949
+func (ns *NullTeamRole) Scan(value interface{}) error {
950
+	if value == nil {
951
+		ns.TeamRole, ns.Valid = "", false
952
+		return nil
953
+	}
954
+	ns.Valid = true
955
+	return ns.TeamRole.Scan(value)
956
+}
957
+
958
+// Value implements the driver Valuer interface.
959
+func (ns NullTeamRole) Value() (driver.Value, error) {
960
+	if !ns.Valid {
961
+		return nil, nil
962
+	}
963
+	return string(ns.TeamRole), nil
964
+}
965
+
837
 type TransferPrincipalKind string
966
 type TransferPrincipalKind string
838
 
967
 
839
 const (
968
 const (
@@ -1461,6 +1590,35 @@ type Star struct {
1461
 	StarredAt pgtype.Timestamptz
1590
 	StarredAt pgtype.Timestamptz
1462
 }
1591
 }
1463
 
1592
 
1593
+type Team struct {
1594
+	ID              int64
1595
+	OrgID           int64
1596
+	Slug            string
1597
+	DisplayName     string
1598
+	Description     string
1599
+	ParentTeamID    pgtype.Int8
1600
+	Privacy         TeamPrivacy
1601
+	CreatedByUserID pgtype.Int8
1602
+	CreatedAt       pgtype.Timestamptz
1603
+	UpdatedAt       pgtype.Timestamptz
1604
+}
1605
+
1606
+type TeamMember struct {
1607
+	TeamID        int64
1608
+	UserID        int64
1609
+	Role          TeamRole
1610
+	AddedByUserID pgtype.Int8
1611
+	AddedAt       pgtype.Timestamptz
1612
+}
1613
+
1614
+type TeamRepoAccess struct {
1615
+	TeamID        int64
1616
+	RepoID        int64
1617
+	Role          TeamRepoRole
1618
+	AddedByUserID pgtype.Int8
1619
+	AddedAt       pgtype.Timestamptz
1620
+}
1621
+
1464
 type User struct {
1622
 type User struct {
1465
 	ID                int64
1623
 	ID                int64
1466
 	Username          string
1624
 	Username          string
internal/orgs/sqlc/querier.gomodified
@@ -19,6 +19,8 @@ type Querier interface {
19
 	// the member is new; existing rows keep their current role (use
19
 	// the member is new; existing rows keep their current role (use
20
 	// ChangeOrgMemberRole to update).
20
 	// ChangeOrgMemberRole to update).
21
 	AddOrgMember(ctx context.Context, db DBTX, arg AddOrgMemberParams) error
21
 	AddOrgMember(ctx context.Context, db DBTX, arg AddOrgMemberParams) error
22
+	// ─── team_members ─────────────────────────────────────────────────
23
+	AddTeamMember(ctx context.Context, db DBTX, arg AddTeamMemberParams) error
22
 	CancelOrgInvitation(ctx context.Context, db DBTX, id int64) error
24
 	CancelOrgInvitation(ctx context.Context, db DBTX, id int64) error
23
 	ChangeOrgMemberRole(ctx context.Context, db DBTX, arg ChangeOrgMemberRoleParams) error
25
 	ChangeOrgMemberRole(ctx context.Context, db DBTX, arg ChangeOrgMemberRoleParams) error
24
 	// Used by the last-owner protection: refuses to remove or demote the
26
 	// Used by the last-owner protection: refuses to remove or demote the
@@ -33,7 +35,14 @@ type Querier interface {
33
 	// SPDX-License-Identifier: AGPL-3.0-or-later
35
 	// SPDX-License-Identifier: AGPL-3.0-or-later
34
 	// ─── org_invitations ───────────────────────────────────────────────
36
 	// ─── org_invitations ───────────────────────────────────────────────
35
 	CreateOrgInvitation(ctx context.Context, db DBTX, arg CreateOrgInvitationParams) (OrgInvitation, error)
37
 	CreateOrgInvitation(ctx context.Context, db DBTX, arg CreateOrgInvitationParams) (OrgInvitation, error)
38
+	// SPDX-License-Identifier: AGPL-3.0-or-later
39
+	// ─── teams ─────────────────────────────────────────────────────────
40
+	CreateTeam(ctx context.Context, db DBTX, arg CreateTeamParams) (Team, error)
36
 	DeclineOrgInvitation(ctx context.Context, db DBTX, id int64) error
41
 	DeclineOrgInvitation(ctx context.Context, db DBTX, id int64) error
42
+	// Children's parent_team_id flips to NULL via the FK ON DELETE SET NULL,
43
+	// promoting them to top-level — matches the "deleting parent doesn't
44
+	// delete children" intent in the spec's pitfalls.
45
+	DeleteTeam(ctx context.Context, db DBTX, id int64) error
37
 	// Idempotency check before creating a new invite — so a re-issued
46
 	// Idempotency check before creating a new invite — so a re-issued
38
 	// invite to the same target doesn't accumulate stale rows.
47
 	// invite to the same target doesn't accumulate stale rows.
39
 	GetExistingPendingInvitation(ctx context.Context, db DBTX, arg GetExistingPendingInvitationParams) (OrgInvitation, error)
48
 	GetExistingPendingInvitation(ctx context.Context, db DBTX, arg GetExistingPendingInvitationParams) (OrgInvitation, error)
@@ -47,9 +56,15 @@ type Querier interface {
47
 	GetOrgInvitationByID(ctx context.Context, db DBTX, id int64) (OrgInvitation, error)
56
 	GetOrgInvitationByID(ctx context.Context, db DBTX, id int64) (OrgInvitation, error)
48
 	GetOrgInvitationByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (OrgInvitation, error)
57
 	GetOrgInvitationByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (OrgInvitation, error)
49
 	GetOrgMember(ctx context.Context, db DBTX, arg GetOrgMemberParams) (OrgMember, error)
58
 	GetOrgMember(ctx context.Context, db DBTX, arg GetOrgMemberParams) (OrgMember, error)
59
+	GetTeamByID(ctx context.Context, db DBTX, id int64) (Team, error)
60
+	GetTeamByOrgAndSlug(ctx context.Context, db DBTX, arg GetTeamByOrgAndSlugParams) (Team, error)
61
+	GetTeamRepoAccess(ctx context.Context, db DBTX, arg GetTeamRepoAccessParams) (TeamRepoAccess, error)
62
+	// ─── team_repo_access ─────────────────────────────────────────────
63
+	GrantTeamRepoAccess(ctx context.Context, db DBTX, arg GrantTeamRepoAccessParams) error
50
 	// Final row removal after the cascade finished. The principals
64
 	// Final row removal after the cascade finished. The principals
51
 	// trigger drops the matching principals row in the same tx.
65
 	// trigger drops the matching principals row in the same tx.
52
 	HardDeleteOrgRow(ctx context.Context, db DBTX, id int64) error
66
 	HardDeleteOrgRow(ctx context.Context, db DBTX, id int64) error
67
+	ListChildTeams(ctx context.Context, db DBTX, parentTeamID pgtype.Int8) ([]Team, error)
53
 	// Sweep input for the lifecycle worker: every soft-deleted org whose
68
 	// Sweep input for the lifecycle worker: every soft-deleted org whose
54
 	// 14-day grace window has elapsed. The interval is intentionally a
69
 	// 14-day grace window has elapsed. The interval is intentionally a
55
 	// DB literal (not a parameter) so the policy lives next to the data.
70
 	// DB literal (not a parameter) so the policy lives next to the data.
@@ -67,7 +82,22 @@ type Querier interface {
67
 	// (claim-on-signup). Caller unions the two lookups when surfacing
82
 	// (claim-on-signup). Caller unions the two lookups when surfacing
68
 	// to the user.
83
 	// to the user.
69
 	ListPendingInvitationsForUser(ctx context.Context, db DBTX, targetUserID pgtype.Int8) ([]ListPendingInvitationsForUserRow, error)
84
 	ListPendingInvitationsForUser(ctx context.Context, db DBTX, targetUserID pgtype.Int8) ([]ListPendingInvitationsForUserRow, error)
85
+	// All teams with grants on a single repo; for repo settings/access page.
86
+	ListRepoTeamGrants(ctx context.Context, db DBTX, repoID int64) ([]ListRepoTeamGrantsRow, error)
87
+	// Policy hot-path: given a repo and a set of team_ids the actor
88
+	// belongs to (incl. inherited), return the access rows. Caller picks
89
+	// the highest role.
90
+	ListTeamAccessForRepoAndTeams(ctx context.Context, db DBTX, arg ListTeamAccessForRepoAndTeamsParams) ([]ListTeamAccessForRepoAndTeamsRow, error)
91
+	ListTeamMembers(ctx context.Context, db DBTX, teamID int64) ([]ListTeamMembersRow, error)
92
+	// All repos the team has any grant on; for the team-view page.
93
+	ListTeamRepoAccess(ctx context.Context, db DBTX, teamID int64) ([]ListTeamRepoAccessRow, error)
94
+	ListTeamsForOrg(ctx context.Context, db DBTX, orgID int64) ([]Team, error)
95
+	// Returns the teams a user directly belongs to within an org. The
96
+	// policy aggregator unions this with each row's parent_team_id to
97
+	// get the inherited set.
98
+	ListTeamsForUserInOrg(ctx context.Context, db DBTX, arg ListTeamsForUserInOrgParams) ([]ListTeamsForUserInOrgRow, error)
70
 	RemoveOrgMember(ctx context.Context, db DBTX, arg RemoveOrgMemberParams) error
99
 	RemoveOrgMember(ctx context.Context, db DBTX, arg RemoveOrgMemberParams) error
100
+	RemoveTeamMember(ctx context.Context, db DBTX, arg RemoveTeamMemberParams) error
71
 	// ─── principals (read-only from this domain) ───────────────────────
101
 	// ─── principals (read-only from this domain) ───────────────────────
72
 	// Single-query /{slug} resolver. Returns the (kind, id) tuple that
102
 	// Single-query /{slug} resolver. Returns the (kind, id) tuple that
73
 	// /{slug}/* routes use to dispatch to the user-profile or org-profile
103
 	// /{slug}/* routes use to dispatch to the user-profile or org-profile
@@ -75,11 +105,17 @@ type Querier interface {
75
 	// impossible at the DB layer.
105
 	// impossible at the DB layer.
76
 	ResolvePrincipal(ctx context.Context, db DBTX, slug string) (Principal, error)
106
 	ResolvePrincipal(ctx context.Context, db DBTX, slug string) (Principal, error)
77
 	RestoreOrg(ctx context.Context, db DBTX, id int64) error
107
 	RestoreOrg(ctx context.Context, db DBTX, id int64) error
108
+	RevokeTeamRepoAccess(ctx context.Context, db DBTX, arg RevokeTeamRepoAccessParams) error
78
 	SetOrgAllowMemberRepoCreate(ctx context.Context, db DBTX, arg SetOrgAllowMemberRepoCreateParams) error
109
 	SetOrgAllowMemberRepoCreate(ctx context.Context, db DBTX, arg SetOrgAllowMemberRepoCreateParams) error
79
 	SetOrgAvatarKey(ctx context.Context, db DBTX, arg SetOrgAvatarKeyParams) error
110
 	SetOrgAvatarKey(ctx context.Context, db DBTX, arg SetOrgAvatarKeyParams) error
80
 	SetOrgSuspended(ctx context.Context, db DBTX, arg SetOrgSuspendedParams) error
111
 	SetOrgSuspended(ctx context.Context, db DBTX, arg SetOrgSuspendedParams) error
112
+	SetTeamMemberRole(ctx context.Context, db DBTX, arg SetTeamMemberRoleParams) error
113
+	// The one-level-nesting BEFORE trigger blocks invalid moves; the
114
+	// caller surfaces the SQLSTATE 23514 as a friendly error.
115
+	SetTeamParent(ctx context.Context, db DBTX, arg SetTeamParentParams) error
81
 	SoftDeleteOrg(ctx context.Context, db DBTX, id int64) error
116
 	SoftDeleteOrg(ctx context.Context, db DBTX, id int64) error
82
 	UpdateOrgProfile(ctx context.Context, db DBTX, arg UpdateOrgProfileParams) error
117
 	UpdateOrgProfile(ctx context.Context, db DBTX, arg UpdateOrgProfileParams) error
118
+	UpdateTeamProfile(ctx context.Context, db DBTX, arg UpdateTeamProfileParams) error
83
 }
119
 }
84
 
120
 
85
 var _ Querier = (*Queries)(nil)
121
 var _ Querier = (*Queries)(nil)
internal/orgs/sqlc/teams.sql.goadded
@@ -0,0 +1,593 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: teams.sql
5
+
6
+package orgsdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const addTeamMember = `-- name: AddTeamMember :exec
15
+
16
+INSERT INTO team_members (team_id, user_id, role, added_by_user_id)
17
+VALUES ($1, $2, $3, $4::bigint)
18
+ON CONFLICT (team_id, user_id) DO NOTHING
19
+`
20
+
21
+type AddTeamMemberParams struct {
22
+	TeamID        int64
23
+	UserID        int64
24
+	Role          TeamRole
25
+	AddedByUserID pgtype.Int8
26
+}
27
+
28
+// ─── team_members ─────────────────────────────────────────────────
29
+func (q *Queries) AddTeamMember(ctx context.Context, db DBTX, arg AddTeamMemberParams) error {
30
+	_, err := db.Exec(ctx, addTeamMember,
31
+		arg.TeamID,
32
+		arg.UserID,
33
+		arg.Role,
34
+		arg.AddedByUserID,
35
+	)
36
+	return err
37
+}
38
+
39
+const createTeam = `-- name: CreateTeam :one
40
+
41
+
42
+INSERT INTO teams (org_id, slug, display_name, description, parent_team_id, privacy, created_by_user_id)
43
+VALUES ($1, $2, $3, $4, $6::bigint, $5, $7::bigint)
44
+RETURNING id, org_id, slug, display_name, description, parent_team_id, privacy, created_by_user_id, created_at, updated_at
45
+`
46
+
47
+type CreateTeamParams struct {
48
+	OrgID           int64
49
+	Slug            string
50
+	DisplayName     string
51
+	Description     string
52
+	Privacy         TeamPrivacy
53
+	ParentTeamID    pgtype.Int8
54
+	CreatedByUserID pgtype.Int8
55
+}
56
+
57
+// SPDX-License-Identifier: AGPL-3.0-or-later
58
+// ─── teams ─────────────────────────────────────────────────────────
59
+func (q *Queries) CreateTeam(ctx context.Context, db DBTX, arg CreateTeamParams) (Team, error) {
60
+	row := db.QueryRow(ctx, createTeam,
61
+		arg.OrgID,
62
+		arg.Slug,
63
+		arg.DisplayName,
64
+		arg.Description,
65
+		arg.Privacy,
66
+		arg.ParentTeamID,
67
+		arg.CreatedByUserID,
68
+	)
69
+	var i Team
70
+	err := row.Scan(
71
+		&i.ID,
72
+		&i.OrgID,
73
+		&i.Slug,
74
+		&i.DisplayName,
75
+		&i.Description,
76
+		&i.ParentTeamID,
77
+		&i.Privacy,
78
+		&i.CreatedByUserID,
79
+		&i.CreatedAt,
80
+		&i.UpdatedAt,
81
+	)
82
+	return i, err
83
+}
84
+
85
+const deleteTeam = `-- name: DeleteTeam :exec
86
+DELETE FROM teams WHERE id = $1
87
+`
88
+
89
+// Children's parent_team_id flips to NULL via the FK ON DELETE SET NULL,
90
+// promoting them to top-level — matches the "deleting parent doesn't
91
+// delete children" intent in the spec's pitfalls.
92
+func (q *Queries) DeleteTeam(ctx context.Context, db DBTX, id int64) error {
93
+	_, err := db.Exec(ctx, deleteTeam, id)
94
+	return err
95
+}
96
+
97
+const getTeamByID = `-- name: GetTeamByID :one
98
+SELECT id, org_id, slug, display_name, description, parent_team_id, privacy, created_by_user_id, created_at, updated_at FROM teams WHERE id = $1
99
+`
100
+
101
+func (q *Queries) GetTeamByID(ctx context.Context, db DBTX, id int64) (Team, error) {
102
+	row := db.QueryRow(ctx, getTeamByID, id)
103
+	var i Team
104
+	err := row.Scan(
105
+		&i.ID,
106
+		&i.OrgID,
107
+		&i.Slug,
108
+		&i.DisplayName,
109
+		&i.Description,
110
+		&i.ParentTeamID,
111
+		&i.Privacy,
112
+		&i.CreatedByUserID,
113
+		&i.CreatedAt,
114
+		&i.UpdatedAt,
115
+	)
116
+	return i, err
117
+}
118
+
119
+const getTeamByOrgAndSlug = `-- name: GetTeamByOrgAndSlug :one
120
+SELECT id, org_id, slug, display_name, description, parent_team_id, privacy, created_by_user_id, created_at, updated_at FROM teams WHERE org_id = $1 AND slug = $2
121
+`
122
+
123
+type GetTeamByOrgAndSlugParams struct {
124
+	OrgID int64
125
+	Slug  string
126
+}
127
+
128
+func (q *Queries) GetTeamByOrgAndSlug(ctx context.Context, db DBTX, arg GetTeamByOrgAndSlugParams) (Team, error) {
129
+	row := db.QueryRow(ctx, getTeamByOrgAndSlug, arg.OrgID, arg.Slug)
130
+	var i Team
131
+	err := row.Scan(
132
+		&i.ID,
133
+		&i.OrgID,
134
+		&i.Slug,
135
+		&i.DisplayName,
136
+		&i.Description,
137
+		&i.ParentTeamID,
138
+		&i.Privacy,
139
+		&i.CreatedByUserID,
140
+		&i.CreatedAt,
141
+		&i.UpdatedAt,
142
+	)
143
+	return i, err
144
+}
145
+
146
+const getTeamRepoAccess = `-- name: GetTeamRepoAccess :one
147
+SELECT team_id, repo_id, role, added_by_user_id, added_at FROM team_repo_access WHERE team_id = $1 AND repo_id = $2
148
+`
149
+
150
+type GetTeamRepoAccessParams struct {
151
+	TeamID int64
152
+	RepoID int64
153
+}
154
+
155
+func (q *Queries) GetTeamRepoAccess(ctx context.Context, db DBTX, arg GetTeamRepoAccessParams) (TeamRepoAccess, error) {
156
+	row := db.QueryRow(ctx, getTeamRepoAccess, arg.TeamID, arg.RepoID)
157
+	var i TeamRepoAccess
158
+	err := row.Scan(
159
+		&i.TeamID,
160
+		&i.RepoID,
161
+		&i.Role,
162
+		&i.AddedByUserID,
163
+		&i.AddedAt,
164
+	)
165
+	return i, err
166
+}
167
+
168
+const grantTeamRepoAccess = `-- name: GrantTeamRepoAccess :exec
169
+
170
+INSERT INTO team_repo_access (team_id, repo_id, role, added_by_user_id)
171
+VALUES ($1, $2, $3, $4::bigint)
172
+ON CONFLICT (team_id, repo_id) DO UPDATE SET role = EXCLUDED.role
173
+`
174
+
175
+type GrantTeamRepoAccessParams struct {
176
+	TeamID        int64
177
+	RepoID        int64
178
+	Role          TeamRepoRole
179
+	AddedByUserID pgtype.Int8
180
+}
181
+
182
+// ─── team_repo_access ─────────────────────────────────────────────
183
+func (q *Queries) GrantTeamRepoAccess(ctx context.Context, db DBTX, arg GrantTeamRepoAccessParams) error {
184
+	_, err := db.Exec(ctx, grantTeamRepoAccess,
185
+		arg.TeamID,
186
+		arg.RepoID,
187
+		arg.Role,
188
+		arg.AddedByUserID,
189
+	)
190
+	return err
191
+}
192
+
193
+const listChildTeams = `-- name: ListChildTeams :many
194
+SELECT id, org_id, slug, display_name, description, parent_team_id, privacy, created_by_user_id, created_at, updated_at FROM teams WHERE parent_team_id = $1 ORDER BY slug ASC
195
+`
196
+
197
+func (q *Queries) ListChildTeams(ctx context.Context, db DBTX, parentTeamID pgtype.Int8) ([]Team, error) {
198
+	rows, err := db.Query(ctx, listChildTeams, parentTeamID)
199
+	if err != nil {
200
+		return nil, err
201
+	}
202
+	defer rows.Close()
203
+	items := []Team{}
204
+	for rows.Next() {
205
+		var i Team
206
+		if err := rows.Scan(
207
+			&i.ID,
208
+			&i.OrgID,
209
+			&i.Slug,
210
+			&i.DisplayName,
211
+			&i.Description,
212
+			&i.ParentTeamID,
213
+			&i.Privacy,
214
+			&i.CreatedByUserID,
215
+			&i.CreatedAt,
216
+			&i.UpdatedAt,
217
+		); err != nil {
218
+			return nil, err
219
+		}
220
+		items = append(items, i)
221
+	}
222
+	if err := rows.Err(); err != nil {
223
+		return nil, err
224
+	}
225
+	return items, nil
226
+}
227
+
228
+const listRepoTeamGrants = `-- name: ListRepoTeamGrants :many
229
+SELECT a.team_id, a.repo_id, a.role, a.added_by_user_id, a.added_at,
230
+       t.slug AS team_slug, t.display_name AS team_display_name,
231
+       t.privacy::text AS team_privacy
232
+FROM team_repo_access a
233
+JOIN teams t ON t.id = a.team_id
234
+WHERE a.repo_id = $1
235
+ORDER BY t.slug ASC
236
+`
237
+
238
+type ListRepoTeamGrantsRow struct {
239
+	TeamID          int64
240
+	RepoID          int64
241
+	Role            TeamRepoRole
242
+	AddedByUserID   pgtype.Int8
243
+	AddedAt         pgtype.Timestamptz
244
+	TeamSlug        string
245
+	TeamDisplayName string
246
+	TeamPrivacy     string
247
+}
248
+
249
+// All teams with grants on a single repo; for repo settings/access page.
250
+func (q *Queries) ListRepoTeamGrants(ctx context.Context, db DBTX, repoID int64) ([]ListRepoTeamGrantsRow, error) {
251
+	rows, err := db.Query(ctx, listRepoTeamGrants, repoID)
252
+	if err != nil {
253
+		return nil, err
254
+	}
255
+	defer rows.Close()
256
+	items := []ListRepoTeamGrantsRow{}
257
+	for rows.Next() {
258
+		var i ListRepoTeamGrantsRow
259
+		if err := rows.Scan(
260
+			&i.TeamID,
261
+			&i.RepoID,
262
+			&i.Role,
263
+			&i.AddedByUserID,
264
+			&i.AddedAt,
265
+			&i.TeamSlug,
266
+			&i.TeamDisplayName,
267
+			&i.TeamPrivacy,
268
+		); err != nil {
269
+			return nil, err
270
+		}
271
+		items = append(items, i)
272
+	}
273
+	if err := rows.Err(); err != nil {
274
+		return nil, err
275
+	}
276
+	return items, nil
277
+}
278
+
279
+const listTeamAccessForRepoAndTeams = `-- name: ListTeamAccessForRepoAndTeams :many
280
+SELECT team_id, repo_id, role
281
+FROM team_repo_access
282
+WHERE repo_id = $1 AND team_id = ANY($2::bigint[])
283
+`
284
+
285
+type ListTeamAccessForRepoAndTeamsParams struct {
286
+	RepoID  int64
287
+	Column2 []int64
288
+}
289
+
290
+type ListTeamAccessForRepoAndTeamsRow struct {
291
+	TeamID int64
292
+	RepoID int64
293
+	Role   TeamRepoRole
294
+}
295
+
296
+// Policy hot-path: given a repo and a set of team_ids the actor
297
+// belongs to (incl. inherited), return the access rows. Caller picks
298
+// the highest role.
299
+func (q *Queries) ListTeamAccessForRepoAndTeams(ctx context.Context, db DBTX, arg ListTeamAccessForRepoAndTeamsParams) ([]ListTeamAccessForRepoAndTeamsRow, error) {
300
+	rows, err := db.Query(ctx, listTeamAccessForRepoAndTeams, arg.RepoID, arg.Column2)
301
+	if err != nil {
302
+		return nil, err
303
+	}
304
+	defer rows.Close()
305
+	items := []ListTeamAccessForRepoAndTeamsRow{}
306
+	for rows.Next() {
307
+		var i ListTeamAccessForRepoAndTeamsRow
308
+		if err := rows.Scan(&i.TeamID, &i.RepoID, &i.Role); err != nil {
309
+			return nil, err
310
+		}
311
+		items = append(items, i)
312
+	}
313
+	if err := rows.Err(); err != nil {
314
+		return nil, err
315
+	}
316
+	return items, nil
317
+}
318
+
319
+const listTeamMembers = `-- name: ListTeamMembers :many
320
+SELECT m.team_id, m.user_id, m.role, m.added_by_user_id, m.added_at,
321
+       u.username, u.display_name
322
+FROM team_members m
323
+JOIN users u ON u.id = m.user_id
324
+WHERE m.team_id = $1 AND u.deleted_at IS NULL
325
+ORDER BY m.role ASC, u.username ASC
326
+`
327
+
328
+type ListTeamMembersRow struct {
329
+	TeamID        int64
330
+	UserID        int64
331
+	Role          TeamRole
332
+	AddedByUserID pgtype.Int8
333
+	AddedAt       pgtype.Timestamptz
334
+	Username      string
335
+	DisplayName   string
336
+}
337
+
338
+func (q *Queries) ListTeamMembers(ctx context.Context, db DBTX, teamID int64) ([]ListTeamMembersRow, error) {
339
+	rows, err := db.Query(ctx, listTeamMembers, teamID)
340
+	if err != nil {
341
+		return nil, err
342
+	}
343
+	defer rows.Close()
344
+	items := []ListTeamMembersRow{}
345
+	for rows.Next() {
346
+		var i ListTeamMembersRow
347
+		if err := rows.Scan(
348
+			&i.TeamID,
349
+			&i.UserID,
350
+			&i.Role,
351
+			&i.AddedByUserID,
352
+			&i.AddedAt,
353
+			&i.Username,
354
+			&i.DisplayName,
355
+		); err != nil {
356
+			return nil, err
357
+		}
358
+		items = append(items, i)
359
+	}
360
+	if err := rows.Err(); err != nil {
361
+		return nil, err
362
+	}
363
+	return items, nil
364
+}
365
+
366
+const listTeamRepoAccess = `-- name: ListTeamRepoAccess :many
367
+SELECT a.team_id, a.repo_id, a.role, a.added_by_user_id, a.added_at,
368
+       r.name AS repo_name, r.visibility::text AS visibility
369
+FROM team_repo_access a
370
+JOIN repos r ON r.id = a.repo_id
371
+WHERE a.team_id = $1 AND r.deleted_at IS NULL
372
+ORDER BY r.name ASC
373
+`
374
+
375
+type ListTeamRepoAccessRow struct {
376
+	TeamID        int64
377
+	RepoID        int64
378
+	Role          TeamRepoRole
379
+	AddedByUserID pgtype.Int8
380
+	AddedAt       pgtype.Timestamptz
381
+	RepoName      string
382
+	Visibility    string
383
+}
384
+
385
+// All repos the team has any grant on; for the team-view page.
386
+func (q *Queries) ListTeamRepoAccess(ctx context.Context, db DBTX, teamID int64) ([]ListTeamRepoAccessRow, error) {
387
+	rows, err := db.Query(ctx, listTeamRepoAccess, teamID)
388
+	if err != nil {
389
+		return nil, err
390
+	}
391
+	defer rows.Close()
392
+	items := []ListTeamRepoAccessRow{}
393
+	for rows.Next() {
394
+		var i ListTeamRepoAccessRow
395
+		if err := rows.Scan(
396
+			&i.TeamID,
397
+			&i.RepoID,
398
+			&i.Role,
399
+			&i.AddedByUserID,
400
+			&i.AddedAt,
401
+			&i.RepoName,
402
+			&i.Visibility,
403
+		); err != nil {
404
+			return nil, err
405
+		}
406
+		items = append(items, i)
407
+	}
408
+	if err := rows.Err(); err != nil {
409
+		return nil, err
410
+	}
411
+	return items, nil
412
+}
413
+
414
+const listTeamsForOrg = `-- name: ListTeamsForOrg :many
415
+SELECT id, org_id, slug, display_name, description, parent_team_id, privacy, created_by_user_id, created_at, updated_at FROM teams WHERE org_id = $1 ORDER BY slug ASC
416
+`
417
+
418
+func (q *Queries) ListTeamsForOrg(ctx context.Context, db DBTX, orgID int64) ([]Team, error) {
419
+	rows, err := db.Query(ctx, listTeamsForOrg, orgID)
420
+	if err != nil {
421
+		return nil, err
422
+	}
423
+	defer rows.Close()
424
+	items := []Team{}
425
+	for rows.Next() {
426
+		var i Team
427
+		if err := rows.Scan(
428
+			&i.ID,
429
+			&i.OrgID,
430
+			&i.Slug,
431
+			&i.DisplayName,
432
+			&i.Description,
433
+			&i.ParentTeamID,
434
+			&i.Privacy,
435
+			&i.CreatedByUserID,
436
+			&i.CreatedAt,
437
+			&i.UpdatedAt,
438
+		); err != nil {
439
+			return nil, err
440
+		}
441
+		items = append(items, i)
442
+	}
443
+	if err := rows.Err(); err != nil {
444
+		return nil, err
445
+	}
446
+	return items, nil
447
+}
448
+
449
+const listTeamsForUserInOrg = `-- name: ListTeamsForUserInOrg :many
450
+SELECT t.id, t.org_id, t.slug, t.display_name, t.description,
451
+       t.parent_team_id, t.privacy, t.created_by_user_id,
452
+       t.created_at, t.updated_at,
453
+       m.role AS member_role
454
+FROM team_members m
455
+JOIN teams t ON t.id = m.team_id
456
+WHERE t.org_id = $1 AND m.user_id = $2
457
+`
458
+
459
+type ListTeamsForUserInOrgParams struct {
460
+	OrgID  int64
461
+	UserID int64
462
+}
463
+
464
+type ListTeamsForUserInOrgRow struct {
465
+	ID              int64
466
+	OrgID           int64
467
+	Slug            string
468
+	DisplayName     string
469
+	Description     string
470
+	ParentTeamID    pgtype.Int8
471
+	Privacy         TeamPrivacy
472
+	CreatedByUserID pgtype.Int8
473
+	CreatedAt       pgtype.Timestamptz
474
+	UpdatedAt       pgtype.Timestamptz
475
+	MemberRole      TeamRole
476
+}
477
+
478
+// Returns the teams a user directly belongs to within an org. The
479
+// policy aggregator unions this with each row's parent_team_id to
480
+// get the inherited set.
481
+func (q *Queries) ListTeamsForUserInOrg(ctx context.Context, db DBTX, arg ListTeamsForUserInOrgParams) ([]ListTeamsForUserInOrgRow, error) {
482
+	rows, err := db.Query(ctx, listTeamsForUserInOrg, arg.OrgID, arg.UserID)
483
+	if err != nil {
484
+		return nil, err
485
+	}
486
+	defer rows.Close()
487
+	items := []ListTeamsForUserInOrgRow{}
488
+	for rows.Next() {
489
+		var i ListTeamsForUserInOrgRow
490
+		if err := rows.Scan(
491
+			&i.ID,
492
+			&i.OrgID,
493
+			&i.Slug,
494
+			&i.DisplayName,
495
+			&i.Description,
496
+			&i.ParentTeamID,
497
+			&i.Privacy,
498
+			&i.CreatedByUserID,
499
+			&i.CreatedAt,
500
+			&i.UpdatedAt,
501
+			&i.MemberRole,
502
+		); err != nil {
503
+			return nil, err
504
+		}
505
+		items = append(items, i)
506
+	}
507
+	if err := rows.Err(); err != nil {
508
+		return nil, err
509
+	}
510
+	return items, nil
511
+}
512
+
513
+const removeTeamMember = `-- name: RemoveTeamMember :exec
514
+DELETE FROM team_members WHERE team_id = $1 AND user_id = $2
515
+`
516
+
517
+type RemoveTeamMemberParams struct {
518
+	TeamID int64
519
+	UserID int64
520
+}
521
+
522
+func (q *Queries) RemoveTeamMember(ctx context.Context, db DBTX, arg RemoveTeamMemberParams) error {
523
+	_, err := db.Exec(ctx, removeTeamMember, arg.TeamID, arg.UserID)
524
+	return err
525
+}
526
+
527
+const revokeTeamRepoAccess = `-- name: RevokeTeamRepoAccess :exec
528
+DELETE FROM team_repo_access WHERE team_id = $1 AND repo_id = $2
529
+`
530
+
531
+type RevokeTeamRepoAccessParams struct {
532
+	TeamID int64
533
+	RepoID int64
534
+}
535
+
536
+func (q *Queries) RevokeTeamRepoAccess(ctx context.Context, db DBTX, arg RevokeTeamRepoAccessParams) error {
537
+	_, err := db.Exec(ctx, revokeTeamRepoAccess, arg.TeamID, arg.RepoID)
538
+	return err
539
+}
540
+
541
+const setTeamMemberRole = `-- name: SetTeamMemberRole :exec
542
+UPDATE team_members SET role = $3 WHERE team_id = $1 AND user_id = $2
543
+`
544
+
545
+type SetTeamMemberRoleParams struct {
546
+	TeamID int64
547
+	UserID int64
548
+	Role   TeamRole
549
+}
550
+
551
+func (q *Queries) SetTeamMemberRole(ctx context.Context, db DBTX, arg SetTeamMemberRoleParams) error {
552
+	_, err := db.Exec(ctx, setTeamMemberRole, arg.TeamID, arg.UserID, arg.Role)
553
+	return err
554
+}
555
+
556
+const setTeamParent = `-- name: SetTeamParent :exec
557
+UPDATE teams SET parent_team_id = $2::bigint, updated_at = now()
558
+WHERE id = $1
559
+`
560
+
561
+type SetTeamParentParams struct {
562
+	ID           int64
563
+	ParentTeamID pgtype.Int8
564
+}
565
+
566
+// The one-level-nesting BEFORE trigger blocks invalid moves; the
567
+// caller surfaces the SQLSTATE 23514 as a friendly error.
568
+func (q *Queries) SetTeamParent(ctx context.Context, db DBTX, arg SetTeamParentParams) error {
569
+	_, err := db.Exec(ctx, setTeamParent, arg.ID, arg.ParentTeamID)
570
+	return err
571
+}
572
+
573
+const updateTeamProfile = `-- name: UpdateTeamProfile :exec
574
+UPDATE teams SET display_name = $2, description = $3, privacy = $4, updated_at = now()
575
+WHERE id = $1
576
+`
577
+
578
+type UpdateTeamProfileParams struct {
579
+	ID          int64
580
+	DisplayName string
581
+	Description string
582
+	Privacy     TeamPrivacy
583
+}
584
+
585
+func (q *Queries) UpdateTeamProfile(ctx context.Context, db DBTX, arg UpdateTeamProfileParams) error {
586
+	_, err := db.Exec(ctx, updateTeamProfile,
587
+		arg.ID,
588
+		arg.DisplayName,
589
+		arg.Description,
590
+		arg.Privacy,
591
+	)
592
+	return err
593
+}
internal/orgs/teams.goadded
@@ -0,0 +1,212 @@
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
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
17
+)
18
+
19
+// Errors surfaced by the team orchestrator.
20
+var (
21
+	ErrTeamNotFound        = errors.New("orgs: team not found")
22
+	ErrTeamSlugInvalid     = errors.New("orgs: team slug must be lowercase letters, digits, and hyphens (1-50)")
23
+	ErrTeamSlugTaken       = errors.New("orgs: team slug already in use in this org")
24
+	ErrTeamNestingTooDeep  = errors.New("orgs: team nesting limited to one level (parent already has a parent)")
25
+	ErrTeamSelfParent      = errors.New("orgs: a team cannot be its own parent")
26
+	ErrTeamCrossOrgParent  = errors.New("orgs: parent team must belong to the same org")
27
+	ErrTeamMissingActor    = errors.New("orgs: actor required for team mutation")
28
+	ErrTeamRoleInvalid     = errors.New("orgs: invalid team role")
29
+	ErrTeamRepoRoleInvalid = errors.New("orgs: invalid team repo-access role")
30
+)
31
+
32
+// teamSlugRE matches the team slug shape (lowercase letters, digits,
33
+// hyphens, dots; can't start/end with hyphen). 50 chars matches the
34
+// migration's CHECK.
35
+var teamSlugRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,48}[a-z0-9])?$`)
36
+
37
+// CreateTeamParams describes a new-team request.
38
+type CreateTeamParams struct {
39
+	OrgID           int64
40
+	Slug            string
41
+	DisplayName     string
42
+	Description     string
43
+	ParentTeamID    int64  // 0 → top-level
44
+	Privacy         string // "visible" | "secret"
45
+	CreatedByUserID int64
46
+}
47
+
48
+// CreateTeam inserts a team row. Validates slug format, checks
49
+// parent-team org match, and translates the trigger's nesting-violation
50
+// SQLSTATE into a friendly error.
51
+func CreateTeam(ctx context.Context, deps Deps, p CreateTeamParams) (orgsdb.Team, error) {
52
+	slug := strings.ToLower(strings.TrimSpace(p.Slug))
53
+	if !teamSlugRE.MatchString(slug) {
54
+		return orgsdb.Team{}, ErrTeamSlugInvalid
55
+	}
56
+	if p.CreatedByUserID == 0 {
57
+		return orgsdb.Team{}, ErrTeamMissingActor
58
+	}
59
+	priv, err := parsePrivacy(p.Privacy)
60
+	if err != nil {
61
+		return orgsdb.Team{}, err
62
+	}
63
+	q := orgsdb.New()
64
+	if p.ParentTeamID != 0 {
65
+		parent, perr := q.GetTeamByID(ctx, deps.Pool, p.ParentTeamID)
66
+		if perr != nil {
67
+			if errors.Is(perr, pgx.ErrNoRows) {
68
+				return orgsdb.Team{}, ErrTeamNotFound
69
+			}
70
+			return orgsdb.Team{}, perr
71
+		}
72
+		if parent.OrgID != p.OrgID {
73
+			return orgsdb.Team{}, ErrTeamCrossOrgParent
74
+		}
75
+	}
76
+	row, err := q.CreateTeam(ctx, deps.Pool, orgsdb.CreateTeamParams{
77
+		OrgID:           p.OrgID,
78
+		Slug:            slug,
79
+		DisplayName:     strings.TrimSpace(p.DisplayName),
80
+		Description:     strings.TrimSpace(p.Description),
81
+		ParentTeamID:    pgtype.Int8{Int64: p.ParentTeamID, Valid: p.ParentTeamID != 0},
82
+		Privacy:         priv,
83
+		CreatedByUserID: pgtype.Int8{Int64: p.CreatedByUserID, Valid: true},
84
+	})
85
+	if err != nil {
86
+		return orgsdb.Team{}, translateTeamError(err)
87
+	}
88
+	return row, nil
89
+}
90
+
91
+// SetParent changes a team's parent. Both the no-self-parent CHECK
92
+// and the one-level-nesting trigger fire here; we translate to the
93
+// orchestrator-level errors.
94
+func SetTeamParent(ctx context.Context, deps Deps, teamID, parentTeamID int64) error {
95
+	if parentTeamID == teamID {
96
+		return ErrTeamSelfParent
97
+	}
98
+	err := orgsdb.New().SetTeamParent(ctx, deps.Pool, orgsdb.SetTeamParentParams{
99
+		ID:           teamID,
100
+		ParentTeamID: pgtype.Int8{Int64: parentTeamID, Valid: parentTeamID != 0},
101
+	})
102
+	return translateTeamError(err)
103
+}
104
+
105
+// AddTeamMember inserts (team, user) at the given role. Idempotent on
106
+// the pair (the sqlc query uses ON CONFLICT DO NOTHING). Caller
107
+// resolves the policy gate; this orchestrator just shapes the row.
108
+func AddTeamMember(ctx context.Context, deps Deps, teamID, userID, addedByUserID int64, role string) error {
109
+	r, err := parseTeamRole(role)
110
+	if err != nil {
111
+		return err
112
+	}
113
+	return orgsdb.New().AddTeamMember(ctx, deps.Pool, orgsdb.AddTeamMemberParams{
114
+		TeamID:        teamID,
115
+		UserID:        userID,
116
+		Role:          r,
117
+		AddedByUserID: pgtype.Int8{Int64: addedByUserID, Valid: addedByUserID != 0},
118
+	})
119
+}
120
+
121
+// RemoveTeamMember drops the (team, user) pair. No last-maintainer
122
+// protection here — teams without maintainers are fine; org owners
123
+// can always still mutate the team via the org-owner policy bypass.
124
+func RemoveTeamMember(ctx context.Context, deps Deps, teamID, userID int64) error {
125
+	return orgsdb.New().RemoveTeamMember(ctx, deps.Pool, orgsdb.RemoveTeamMemberParams{
126
+		TeamID: teamID, UserID: userID,
127
+	})
128
+}
129
+
130
+// GrantRepoAccess upserts the team's role on a repo. ON CONFLICT
131
+// DO UPDATE so re-granting at a new role is one call.
132
+func GrantTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID, addedByUserID int64, role string) error {
133
+	r, err := parseTeamRepoRole(role)
134
+	if err != nil {
135
+		return err
136
+	}
137
+	return orgsdb.New().GrantTeamRepoAccess(ctx, deps.Pool, orgsdb.GrantTeamRepoAccessParams{
138
+		TeamID:        teamID,
139
+		RepoID:        repoID,
140
+		Role:          r,
141
+		AddedByUserID: pgtype.Int8{Int64: addedByUserID, Valid: addedByUserID != 0},
142
+	})
143
+}
144
+
145
+// RevokeTeamRepoAccess drops the team's grant. Effective immediately
146
+// (next request denies; per-request policy cache means in-flight
147
+// requests can still pass — same staleness as collaborator changes).
148
+func RevokeTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID int64) error {
149
+	return orgsdb.New().RevokeTeamRepoAccess(ctx, deps.Pool, orgsdb.RevokeTeamRepoAccessParams{
150
+		TeamID: teamID, RepoID: repoID,
151
+	})
152
+}
153
+
154
+// ─── helpers ───────────────────────────────────────────────────────
155
+
156
+func parsePrivacy(s string) (orgsdb.TeamPrivacy, error) {
157
+	switch s {
158
+	case "", "visible":
159
+		return orgsdb.TeamPrivacyVisible, nil
160
+	case "secret":
161
+		return orgsdb.TeamPrivacySecret, nil
162
+	}
163
+	return "", fmt.Errorf("orgs: invalid team privacy %q", s)
164
+}
165
+
166
+func parseTeamRole(s string) (orgsdb.TeamRole, error) {
167
+	switch s {
168
+	case "", "member":
169
+		return orgsdb.TeamRoleMember, nil
170
+	case "maintainer":
171
+		return orgsdb.TeamRoleMaintainer, nil
172
+	}
173
+	return "", ErrTeamRoleInvalid
174
+}
175
+
176
+func parseTeamRepoRole(s string) (orgsdb.TeamRepoRole, error) {
177
+	switch s {
178
+	case "read":
179
+		return orgsdb.TeamRepoRoleRead, nil
180
+	case "triage":
181
+		return orgsdb.TeamRepoRoleTriage, nil
182
+	case "write":
183
+		return orgsdb.TeamRepoRoleWrite, nil
184
+	case "maintain":
185
+		return orgsdb.TeamRepoRoleMaintain, nil
186
+	case "admin":
187
+		return orgsdb.TeamRepoRoleAdmin, nil
188
+	}
189
+	return "", ErrTeamRepoRoleInvalid
190
+}
191
+
192
+// translateTeamError maps Postgres errors back to the orchestrator's
193
+// typed errors. The migration's nesting trigger raises CHECK
194
+// (SQLSTATE 23514); the unique index on (org_id, slug) raises 23505.
195
+func translateTeamError(err error) error {
196
+	if err == nil {
197
+		return nil
198
+	}
199
+	var pgErr *pgconn.PgError
200
+	if errors.As(err, &pgErr) {
201
+		switch pgErr.Code {
202
+		case "23505":
203
+			return ErrTeamSlugTaken
204
+		case "23514":
205
+			if strings.Contains(pgErr.Message, "no_self_parent") {
206
+				return ErrTeamSelfParent
207
+			}
208
+			return ErrTeamNestingTooDeep
209
+		}
210
+	}
211
+	return err
212
+}
internal/orgs/teams_test.goadded
@@ -0,0 +1,112 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"testing"
9
+
10
+	"github.com/tenseleyFlow/shithub/internal/orgs"
11
+)
12
+
13
+func TestCreateTeam_HappyPath(t *testing.T) {
14
+	_, deps, alice := setup(t)
15
+	ctx := context.Background()
16
+	org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
17
+	team, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
18
+		OrgID: org.ID, Slug: "engineering", DisplayName: "Engineering",
19
+		CreatedByUserID: alice,
20
+	})
21
+	if err != nil {
22
+		t.Fatalf("CreateTeam: %v", err)
23
+	}
24
+	if string(team.Slug) != "engineering" {
25
+		t.Fatalf("slug: want engineering, got %q", team.Slug)
26
+	}
27
+}
28
+
29
+func TestCreateTeam_RejectsInvalidSlug(t *testing.T) {
30
+	_, deps, alice := setup(t)
31
+	ctx := context.Background()
32
+	org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
33
+	_, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
34
+		OrgID: org.ID, Slug: "BAD SLUG", CreatedByUserID: alice,
35
+	})
36
+	if !errors.Is(err, orgs.ErrTeamSlugInvalid) {
37
+		t.Fatalf("want ErrTeamSlugInvalid, got %v", err)
38
+	}
39
+}
40
+
41
+func TestCreateTeam_RejectsDuplicateSlug(t *testing.T) {
42
+	_, deps, alice := setup(t)
43
+	ctx := context.Background()
44
+	org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
45
+	if _, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
46
+		OrgID: org.ID, Slug: "eng", CreatedByUserID: alice,
47
+	}); err != nil {
48
+		t.Fatalf("first: %v", err)
49
+	}
50
+	_, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
51
+		OrgID: org.ID, Slug: "eng", CreatedByUserID: alice,
52
+	})
53
+	if !errors.Is(err, orgs.ErrTeamSlugTaken) {
54
+		t.Fatalf("want ErrTeamSlugTaken, got %v", err)
55
+	}
56
+}
57
+
58
+// One-level-deep nesting: creating a team with a parent that already
59
+// has a parent must be refused at the trigger.
60
+func TestCreateTeam_RejectsTwoLevelNesting(t *testing.T) {
61
+	_, deps, alice := setup(t)
62
+	ctx := context.Background()
63
+	org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
64
+	gp, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
65
+		OrgID: org.ID, Slug: "g", CreatedByUserID: alice,
66
+	})
67
+	if err != nil {
68
+		t.Fatalf("create grandparent: %v", err)
69
+	}
70
+	parent, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
71
+		OrgID: org.ID, Slug: "p", ParentTeamID: gp.ID, CreatedByUserID: alice,
72
+	})
73
+	if err != nil {
74
+		t.Fatalf("create parent: %v", err)
75
+	}
76
+	_, err = orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
77
+		OrgID: org.ID, Slug: "c", ParentTeamID: parent.ID, CreatedByUserID: alice,
78
+	})
79
+	if !errors.Is(err, orgs.ErrTeamNestingTooDeep) {
80
+		t.Fatalf("want ErrTeamNestingTooDeep, got %v", err)
81
+	}
82
+}
83
+
84
+func TestSetTeamParent_RejectsSelfParent(t *testing.T) {
85
+	_, deps, alice := setup(t)
86
+	ctx := context.Background()
87
+	org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
88
+	team, _ := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
89
+		OrgID: org.ID, Slug: "eng", CreatedByUserID: alice,
90
+	})
91
+	err := orgs.SetTeamParent(ctx, deps, team.ID, team.ID)
92
+	if !errors.Is(err, orgs.ErrTeamSelfParent) {
93
+		t.Fatalf("want ErrTeamSelfParent, got %v", err)
94
+	}
95
+}
96
+
97
+func TestCreateTeam_RejectsCrossOrgParent(t *testing.T) {
98
+	pool, deps, alice := setup(t)
99
+	ctx := context.Background()
100
+	bob := mustUser(t, pool, "bob")
101
+	org1, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
102
+	org2, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "beta", CreatedByUserID: bob})
103
+	parent, _ := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
104
+		OrgID: org2.ID, Slug: "g", CreatedByUserID: bob,
105
+	})
106
+	_, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{
107
+		OrgID: org1.ID, Slug: "c", ParentTeamID: parent.ID, CreatedByUserID: alice,
108
+	})
109
+	if !errors.Is(err, orgs.ErrTeamCrossOrgParent) {
110
+		t.Fatalf("want ErrTeamCrossOrgParent, got %v", err)
111
+	}
112
+}