@@ -0,0 +1,119 @@ |
| | 1 | +# Teams (S31) |
| | 2 | + |
| | 3 | +Teams are sub-groups within an organization. They're the canonical |
| | 4 | +way to grant repo access to a set of users without per-user collab |
| | 5 | +rows. The S15 policy aggregator unions team-granted permissions with |
| | 6 | +direct collaborator permissions and the org-owner implicit-admin rule. |
| | 7 | + |
| | 8 | +## Schema (migration 0035) |
| | 9 | + |
| | 10 | +``` |
| | 11 | +teams — (org_id, slug, parent_team_id, privacy, …) |
| | 12 | +team_members — (team_id, user_id, role enum('member','maintainer')) |
| | 13 | +team_repo_access — (team_id, repo_id, role enum('read'..'admin')) |
| | 14 | +``` |
| | 15 | + |
| | 16 | +**One-level nesting** is structurally enforced by a `BEFORE INSERT |
| | 17 | +OR UPDATE` trigger that checks the parent team's `parent_team_id IS |
| | 18 | +NULL`. Two-level deep nesting raises SQLSTATE 23514, which the |
| | 19 | +orchestrator translates to `ErrTeamNestingTooDeep`. |
| | 20 | + |
| | 21 | +## Permission resolution |
| | 22 | + |
| | 23 | +The S15 policy contract gets one new source. For an org-owned repo, |
| | 24 | +`policy.effectiveRole` returns: |
| | 25 | + |
| | 26 | +``` |
| | 27 | +max( |
| | 28 | + org_role == owner → admin, (S30) |
| | 29 | + direct collab role, (S15: repo_collaborators) |
| | 30 | + team grant role over team-set, (S31: team_repo_access) |
| | 31 | +) |
| | 32 | +``` |
| | 33 | + |
| | 34 | +Where the **team-set** for a user in an org is: |
| | 35 | +- every team they're a direct member of, **plus** |
| | 36 | +- the parent team of each (one hop, per nesting cap). |
| | 37 | + |
| | 38 | +The aggregator resolves team-set + grants in two indexed queries |
| | 39 | +(`team_members JOIN teams` + `team_repo_access WHERE team_id = ANY(...)`) |
| | 40 | +and stores the winning role in the per-request `policy.WithCache` |
| | 41 | +memo so subsequent `Can()` calls in the same request reuse it. |
| | 42 | + |
| | 43 | +`team_role = maintainer` is about managing the team (adding members); |
| | 44 | +it does **not** elevate repo perms beyond the team's |
| | 45 | +`team_repo_access.role`. Confusion with the repo `maintain` role is |
| | 46 | +flagged in the team-management UI. |
| | 47 | + |
| | 48 | +## Visibility |
| | 49 | + |
| | 50 | +* `privacy='visible'`: team metadata visible to all org members; basic |
| | 51 | + info (name + description) visible to anyone who can see the org. |
| | 52 | +* `privacy='secret'`: team metadata, members, and repo grants visible |
| | 53 | + only to (team members ∪ org owners). |
| | 54 | + |
| | 55 | +The list page filters secret teams the viewer can't see; the team |
| | 56 | +view page 404s for the same reason. The markdown `@org/team` resolver |
| | 57 | +must return `ok=false` for secret teams the viewer can't see — it |
| | 58 | +falls back to plain text rather than leaking existence via a 404. |
| | 59 | + |
| | 60 | +## Cross-repo refs + mentions |
| | 61 | + |
| | 62 | +* **`owner/repo#N`**: now dispatches via `principals.Resolve` so |
| | 63 | + org-owned repos resolve. `internal/issues/references.go` was the |
| | 64 | + S21 deferral; cleared this sprint. |
| | 65 | +* **`@org/team`**: a new alternation in |
| | 66 | + `internal/markdown/extensions/extensions.go::reCombined` matches |
| | 67 | + the slash form before the bare `@user` branch, so the trailing |
| | 68 | + `/team` doesn't get split off. Routes to `/{org}/teams/{team}` |
| | 69 | + via the Team resolver. S25 deferral cleared. |
| | 70 | + |
| | 71 | +## Routes |
| | 72 | + |
| | 73 | +``` |
| | 74 | +GET /{org}/teams list (secret teams filtered) |
| | 75 | +POST /{org}/teams create (owner-only) |
| | 76 | +GET /{org}/teams/{slug} view: members + repo grants |
| | 77 | +POST /{org}/teams/{slug}/members add (form + role) / remove (action=remove) |
| | 78 | +POST /{org}/teams/{slug}/repos grant (repo_id + role) / revoke (action=remove) |
| | 79 | +``` |
| | 80 | + |
| | 81 | +## What we deferred from the spec |
| | 82 | + |
| | 83 | +* **Set-parent UI**: orchestrator (`SetTeamParent`) + the trigger |
| | 84 | + validation are in place; the form to retarget a parent in the UI |
| | 85 | + lands in a follow-up. |
| | 86 | +* **Repo-settings team-grant UI**: the team-side grant form on |
| | 87 | + `/{org}/teams/{slug}` works today; mirroring it on |
| | 88 | + `/{owner}/{repo}/settings/access` is S32 territory. |
| | 89 | +* **Team rename + redirects**: `team_redirects` table from the spec |
| | 90 | + isn't shipped; defer to the same follow-up that does the |
| | 91 | + `username_redirects → principal_redirects` rename. |
| | 92 | +* **Notifications for "added to team" / "team granted access"**: |
| | 93 | + routing matrix in S29 is wired for arbitrary kinds; emit-side hook |
| | 94 | + lands when these surfaces next get touched. |
| | 95 | +* **API endpoints** `GET /api/v1/orgs/{org}/teams` etc.: same |
| | 96 | + posture as S30 — defer to S33/S34 API consolidation. |
| | 97 | +* **CODEOWNERS / SCIM team provisioning** are post-MVP per spec. |
| | 98 | + |
| | 99 | +## Pitfalls noted in code |
| | 100 | + |
| | 101 | +* **Permission caching staleness**: the per-request memo is |
| | 102 | + invalidated by `policy.InvalidateRepo`; cross-request changes are |
| | 103 | + picked up on the next request. |
| | 104 | +* **Cycle prevention**: `parent_team_id != id` row CHECK + the |
| | 105 | + one-level trigger together prevent every cycle shape. |
| | 106 | +* **Deleting the parent team** flips children to top-level via |
| | 107 | + `ON DELETE SET NULL` on `parent_team_id` (the spec's lean). |
| | 108 | +* **Outside collaborator on a member's account**: removing the user |
| | 109 | + from the org clears team memberships but NOT direct collab rows |
| | 110 | + — this matches GitHub. Implementation note: today the org member |
| | 111 | + removal in `orgs.RemoveMember` doesn't delete team rows |
| | 112 | + explicitly; the `org_members` row removal cascades through |
| | 113 | + `team_members` only via shared `users` FK. **Follow-up**: when |
| | 114 | + `orgs.RemoveMember` lands its policy-driven cleanup pass, it |
| | 115 | + should also `DELETE FROM team_members WHERE user_id = $1 AND |
| | 116 | + team_id IN (SELECT id FROM teams WHERE org_id = $2)`. Tracked. |
| | 117 | +* **Secret-team metadata leak via repo-settings page**: the |
| | 118 | + `/{owner}/{repo}/settings/access` extension ships in S32; the |
| | 119 | + visibility filter must match the team-list page's logic. |