ADR-048 — Org → Teams two-level hierarchy
Status: Accepted (implemented — P0→P2) Date: 2026-06-27 (implemented 2026-06-28)
Context
Today iterion's tenancy model is flat: a "team" IS the tenant — one identity.Team type (id / name / slug / role / quotas), no separate "org" concept. A user belongs to many teams and switches the active team (stored on the JWT as id.TeamID); the studio's UserTeamChip already exposes a team switcher. Every tenant-scoped store keys on this single id: forge.Connection, boardmongo (board per team), secrets/OAuth, marketplace viewer, orgusage quotas, audit, pat, webhooks.
The flat model conflates two things operators want to separate:
- Org = the billing/identity boundary: SSO, member roster, plan + quotas, the unit you'd call "SocialGouv".
- Team = a working sub-unit inside an org: its own board, forge connections, enabled bots, runs — e.g. "platform", "data", "revi-squad".
The confusing left-nav chip ("SocialGouv" with SocialGouv/socialgouv on the team page) is a symptom: it's really the tenant switcher, but there's no level above it, so an org with several squads can't model them without creating several unrelated top-level "teams" that don't share SSO/billing/members.
Decision (proposed)
Introduce a two-level hierarchy: Org → Teams.
- Org owns: members (with org-roles: owner/admin/member), SSO config, plan + quotas/cost-cap, billing, the marketplace-submitter identity, audit.
- Team (belongs to exactly one Org) owns: board, forge connections + repo integrations, enabled bots, runs, team-scoped secrets. A team-role (admin/member/viewer) is granted per (user, team).
- A user is a member of an Org and is granted access to 0..N teams within it. Active context = (org_id, team_id); both ride the JWT.
- Personal org/team auto-created on signup (preserves today's single-user UX).
Key design decisions to lock
Tenant key. Two options for the stores currently keyed by
tenant=team_id:- (A) Keep
team_idas the tenant key for resource stores (board, forge, secrets, runs); addorg_idonly where org-level (members, SSO, quotas, billing, marketplace). Smallest migration — existing team-scoped data is untouched; we only add an Org layer above + anorg_idcolumn on teams. - (B) Re-key everything on
(org_id, team_id). Cleaner long-term, large migration of every collection + index. → Recommend (A): org is additive; resources stay team-keyed. Quotas/usage move to org-level (sum across its teams).
- (A) Keep
Quotas/usage. Move
orgusage+ cost-cap + run-quota from per-team to per-org (the launch gate sums a team's run against its org budget). Teams can optionally carry a sub-cap.SSO + members.
auth/orgSSO+ the member roster move to org scope; team membership becomes a grant within the org (you must be an org member to be added to its teams).JWT + active context. Add
org_idalongsideteam_id;/api/auth/mereturns the org tree (orgs → teams the user can see); two switchers (org, then team) — or one combined "Org / Team" picker.Naming/UX. Left-nav chip becomes an explicit Org picker (chevron + "Organisation"), with the Team picker nested or adjacent. Resolves the "c'est le bazar" confusion.
Migration (existing flat teams)
Each current Team becomes an Org with one default Team carrying the same id (so every team-keyed resource — board/forge/secrets/runs — keeps working unchanged under option A). The Org gets the team's name/slug/quotas/SSO/members; the default Team inherits the resources. Idempotent backfill: for each team, create org_id=<new>, set team.org_id, move org-level fields up. Personal teams → personal orgs.
Phased rollout
- P0 — model + migration (no UX change): add
identity.Org+org_idon Team + the Org store + the backfill; JWT carriesorg_id(= the team's new org); quotas/usage/SSO/members re-pointed to org. Everything else unchanged — one org per team, invisible to users. - P1 — multiple teams per org: allow creating Teams within an Org; team switcher scoped to the active org; team-role grants.
- P2 — studio UX: Org picker + Team picker; org settings (members/SSO/ billing) vs team settings (board/forge/bots) split; the chip cleanup.
- P3 — org admin: invite to org, manage team grants, per-team sub-caps.
Risks / open questions
- Migration is the crux — every tenant-scoped store + the JWT + the auth gates must agree on the new context. Option (A) bounds it (resources stay team-keyed) but the auth/usage/SSO re-point is still broad and needs a careful, reversible, well-tested backfill (mirror the cloud Mongo migration pattern).
- Forge/board/marketplace are team-scoped (good) but marketplace submitter + audit are arguably org-scoped — confirm per-surface.
- Do we need cross-team resource sharing within an org (shared forge connection, shared secret)? Defer to post-P2.
Alternatives considered
- Keep flat (status quo). Multi-team already works via the switcher; the only real gap is no shared SSO/billing/members across a user's teams. Cheapest, but doesn't satisfy "an org with several squads".
- Re-key everything on (org, team) (option B). Cleanest, highest migration cost/risk. Rejected for now in favour of additive option (A).
As built (2026-06-28)
Implemented P0→P2 in one branch under a no-backward-compat mandate (young project — no compat shims). Key decisions as they actually landed:
- Model. New
identity.Org+identity.OrgMembership(first-class, user↔org withOrgRolemember/admin/owner) alongside the existingMembership(the team-grant).Team.OrgID+User.DefaultOrgIDadded. The monthly run/cost + memory quotas and the lifecycle/suspend fields moved offTeamontoOrg;TeamkeepsMaxConcurrentRuns+LaunchRatePerMin(executor protection, team-level). Store gains Org/OrgMembership CRUD +ListTeamsByOrg+DeleteOrg(mongoorgs/org_membershipscollections; memory in lock-step). - JWT.
OrgID/OrgRoleadded toAccessClaims/Identity; absent claim degrades to team-derived org and self-heals on refresh. - Launch gate. Monthly budget is org-keyed — switching the usage-counter key from team to org makes it sum across the org's teams for free (test: two teams, one org, shared
MonthlyRunQuota=1). Concurrency + launch-rate stay team-keyed. Org or team suspend blocks a launch. - REST.
/api/admin/orgsrepointed to real orgs (+/teamsdrill-down). New/api/orgs/{id}/{members,invitations,usage,teams,audit}(org self-serve) andPOST /api/auth/me/org/{id}./api/auth/mereturns the org→teams tree.canViewOrg/canManageOrg; org-admin implies team-admin for every team in the org. - SSO — deviation from the proposal. Rather than rewrite every
orgssotenant_idto the new org id (option A in §3), SSO routes moved to/api/orgs/{id}/ssobut storage stays keyed on the org's primary team (resolved viafirstTeamInOrg). This keeps the GitHub team-grant login path correct (it grants that team, which mirrors up to an org membership) and means the backfill does not touchorgssoat all — strictly simpler + lower risk. - Org invitations reuse the team-invitation machinery against the org's primary team (accept → team grant → mirrored org membership); no new invitation model.
- Migration.
iterion migrate orgs [--dry-run] [--reverse](hidden, operator-only). Idempotent (gated onTeam.OrgID==""+Org.MigratedFromTeamID), reversible. Custom per-team quota values are NOT carried over — they decode away from the newTeamstruct, so a migrated org starts on the platform defaults and an operator re-applies any custom cap via the admin console (acceptable under nbc; current-month usage-doc rename was likewise skipped — quotas reset at cutover). - Studio. AuthContext carries the org tree with
teams/activeTeamderived from the active org (existing consumers untouched); two-section Org/Team picker; Org nav entry; newOrgPage(members/SSO/usage/audit/billing) vs slimmedTeamPage.
Deferred follow-ups: precise org-level memory CAS (today the org quota is pushed onto each team bucket); current-month usage-doc rename in the migration; a Playwright cloud smoke (tsc/eslint/vite + Go suite are green).
