Teams (S31)
Teams are sub-groups within an organization. They're the canonical way to grant repo access to a set of users without per-user collab rows. The S15 policy aggregator unions team-granted permissions with direct collaborator permissions and the org-owner implicit-admin rule.
Schema (migration 0035)
teams — (org_id, slug, parent_team_id, privacy, …)
team_members — (team_id, user_id, role enum('member','maintainer'))
team_repo_access — (team_id, repo_id, role enum('read'..'admin'))
One-level nesting is structurally enforced by a BEFORE INSERT OR UPDATE trigger that checks the parent team's parent_team_id IS NULL. Two-level deep nesting raises SQLSTATE 23514, which the
orchestrator translates to ErrTeamNestingTooDeep.
Permission resolution
The S15 policy contract gets one new source. For an org-owned repo,
policy.effectiveRole returns:
max(
org_role == owner → admin, (S30)
direct collab role, (S15: repo_collaborators)
team grant role over team-set, (S31: team_repo_access)
)
Where the team-set for a user in an org is:
- every team they're a direct member of, plus
- the parent team of each (one hop, per nesting cap).
The aggregator resolves team-set + grants in two indexed queries
(team_members JOIN teams + team_repo_access WHERE team_id = ANY(...))
and stores the winning role in the per-request policy.WithCache
memo so subsequent Can() calls in the same request reuse it.
team_role = maintainer is about managing the team (adding members);
it does not elevate repo perms beyond the team's
team_repo_access.role. Confusion with the repo maintain role is
flagged in the team-management UI.
Visibility
privacy='visible': team metadata visible to all org members; basic info (name + description) visible to anyone who can see the org.privacy='secret': team metadata, members, and repo grants visible only to (team members ∪ org owners).
The list page filters secret teams the viewer can't see; the team
view page 404s for the same reason. The markdown @org/team resolver
must return ok=false for secret teams the viewer can't see — it
falls back to plain text rather than leaking existence via a 404.
Cross-repo refs + mentions
owner/repo#N: now dispatches viaprincipals.Resolveso org-owned repos resolve.internal/issues/references.gowas the S21 deferral; cleared this sprint.@org/team: a new alternation ininternal/markdown/extensions/extensions.go::reCombinedmatches the slash form before the bare@userbranch, so the trailing/teamdoesn't get split off. Routes to/{org}/teams/{team}via the Team resolver. S25 deferral cleared.
Routes
GET /{org}/teams list (secret teams filtered)
POST /{org}/teams create (owner-only)
GET /{org}/teams/{slug} view: members + repo grants
POST /{org}/teams/{slug}/members add (form + role) / remove (action=remove)
POST /{org}/teams/{slug}/repos grant (repo_id + role) / revoke (action=remove)
What we deferred from the spec
- Set-parent UI: orchestrator (
SetTeamParent) + the trigger validation are in place; the form to retarget a parent in the UI lands in a follow-up. - Repo-settings team-grant UI: the team-side grant form on
/{org}/teams/{slug}works today; mirroring it on/{owner}/{repo}/settings/accessis S32 territory. - Team rename + redirects:
team_redirectstable from the spec isn't shipped; defer to the same follow-up that does theusername_redirects → principal_redirectsrename. - Notifications for "added to team" / "team granted access": routing matrix in S29 is wired for arbitrary kinds; emit-side hook lands when these surfaces next get touched.
- API endpoints
GET /api/v1/orgs/{org}/teamsetc.: same posture as S30 — defer to S33/S34 API consolidation. - CODEOWNERS / SCIM team provisioning are post-MVP per spec.
Pitfalls noted in code
- Permission caching staleness: the per-request memo is
invalidated by
policy.InvalidateRepo; cross-request changes are picked up on the next request. - Cycle prevention:
parent_team_id != idrow CHECK + the one-level trigger together prevent every cycle shape. - Deleting the parent team flips children to top-level via
ON DELETE SET NULLonparent_team_id(the spec's lean). - Outside collaborator on a member's account: removing the user
from the org clears team memberships but NOT direct collab rows
— this matches GitHub. Implementation note: today the org member
removal in
orgs.RemoveMemberdoesn't delete team rows explicitly; theorg_membersrow removal cascades throughteam_membersonly via sharedusersFK. Follow-up: whenorgs.RemoveMemberlands its policy-driven cleanup pass, it should alsoDELETE FROM team_members WHERE user_id = $1 AND team_id IN (SELECT id FROM teams WHERE org_id = $2). Tracked. - Secret-team metadata leak via repo-settings page: the
/{owner}/{repo}/settings/accessextension ships in S32; the visibility filter must match the team-list page's logic.
View source
| 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. |