@@ -0,0 +1,122 @@ |
| 1 | +# Organizations (S30) |
| 2 | + |
| 3 | +Organizations as first-class repo owners. The `principals` table |
| 4 | +unifies `/{slug}` resolution across users + orgs so a slug collision |
| 5 | +is structurally impossible at the DB layer. |
| 6 | + |
| 7 | +## Schema (migration 0034) |
| 8 | + |
| 9 | +``` |
| 10 | +orgs — slug citext, plan, allow_member_repo_create, suspended/deleted |
| 11 | +org_members — (org_id, user_id) → role enum('owner','member') |
| 12 | +org_invitations — pending invites by username OR email; HMAC-hashed tokens |
| 13 | +principals — (slug citext PK, kind enum('user','org'), id) maintained |
| 14 | + by AFTER triggers on users + orgs |
| 15 | +``` |
| 16 | + |
| 17 | +The `repos.owner_org_id` FK to `orgs.id` is added now (the column was |
| 18 | +already present from 0017 with the XOR CHECK). |
| 19 | + |
| 20 | +## Routing |
| 21 | + |
| 22 | +``` |
| 23 | +GET /organizations/new create form (auth required) |
| 24 | +POST /organizations create submit |
| 25 | +GET /{slug} /{user-or-org} — dispatched via principals.Resolve |
| 26 | +GET /{org}/people members + (owner-only) invite form |
| 27 | +POST /{org}/people/invite invite by username OR email |
| 28 | +POST /{org}/people/{userID}/role change role (owner-only) |
| 29 | +POST /{org}/people/{userID}/remove remove member (owner-only) |
| 30 | +GET /invitations/{token} accept/decline view (auth required) |
| 31 | +POST /invitations/{token}/accept |
| 32 | +POST /invitations/{token}/decline |
| 33 | +``` |
| 34 | + |
| 35 | +`/{slug}` resolution flow inside `internal/web/handlers/profile/profile.go`: |
| 36 | + |
| 37 | +1. Reserved-name check (defense in depth — chi already matches static |
| 38 | + routes first). |
| 39 | +2. `orgs.Resolve(slug)` against `principals`. On `kind='org'`, dispatch |
| 40 | + to `serveOrgProfile`. On `kind='user'`, fall through to the |
| 41 | + existing user path. On miss, fall through to `username_redirects` |
| 42 | + for renamed users. |
| 43 | + |
| 44 | +## Member roles |
| 45 | + |
| 46 | +* `owner` → implicit `admin` on every org-owned repo (policy hook |
| 47 | + forthcoming). |
| 48 | +* `member` → org-membership badge; no implicit access to private repos. |
| 49 | + Repo-level access is granted via direct collaboration (S15) or |
| 50 | + teams (S31). |
| 51 | + |
| 52 | +## Last-owner protection |
| 53 | + |
| 54 | +`ChangeRole` and `RemoveMember` both call `CountOrgOwners` and refuse |
| 55 | +the operation if it would leave the org with zero owners |
| 56 | +(`ErrLastOwner`). UI must surface this; the orchestrator is the |
| 57 | +canonical enforcer. |
| 58 | + |
| 59 | +## Invitation flow |
| 60 | + |
| 61 | +* By **username**: orchestrator resolves the username → user_id and |
| 62 | + checks for existing membership before issuing the invite. |
| 63 | +* By **email**: stores the email; recipient claims by signing in with |
| 64 | + any account that owns the verified email (or by signing up later |
| 65 | + with that email — pending invites surface in the inbox via |
| 66 | + `ListPendingInvitationsForEmail`). |
| 67 | + |
| 68 | +Both paths use a 7-day expiry. Tokens are sha256-hashed at rest; |
| 69 | +`token_hash` is the column we look up by. |
| 70 | + |
| 71 | +## Principals trigger |
| 72 | + |
| 73 | +Two AFTER triggers (`tg_principals_user_sync`, `tg_principals_org_sync`) |
| 74 | +maintain `principals` on every users/orgs INSERT/UPDATE/DELETE. The |
| 75 | +slug PK on `principals` enforces global uniqueness across both tables |
| 76 | +— a slug collision either with another user or another org is |
| 77 | +rejected with SQLSTATE 23505, which the create path translates to |
| 78 | +`ErrSlugTaken`. |
| 79 | + |
| 80 | +Soft-deleted users/orgs are dropped from `principals` so their slug |
| 81 | +becomes available — the username_redirects table still preserves the |
| 82 | +old slug for 301s during the rename cooldown. |
| 83 | + |
| 84 | +## What we deferred from the spec |
| 85 | + |
| 86 | +* **`username_redirects` rename to `principal_redirects`**. The |
| 87 | + rename + `kind` column would cascade through every sqlc bundle |
| 88 | + (each one gets a regenerated model). Org renames aren't in the |
| 89 | + S30 DoD; deferred to a follow-up sprint that owns the rename |
| 90 | + refactor end to end. |
| 91 | +* **Owner-implicit-admin in policy.Can**. The `org_members.role` |
| 92 | + shape is in place; wiring it into `policy.Can` so org owners |
| 93 | + automatically get `admin` on org-owned repos lands when the |
| 94 | + policy refactor next touches the repo-permission resolver. |
| 95 | +* **Repo creation owner picker**. Repo create form still defaults |
| 96 | + to user-owner; extending the picker to list orgs the viewer is a |
| 97 | + member of (and honoring `allow_member_repo_create`) is one |
| 98 | + follow-up handler change. |
| 99 | +* **Org-level audit log surface**, **suspension UI**, **soft-delete |
| 100 | + + grace + `org:hard_delete` worker**, **org settings page**, |
| 101 | + **avatar upload**, **email notifications for invite / role-change |
| 102 | + / remove**. Schema columns are present; UI + worker land in |
| 103 | + follow-ups. |
| 104 | +* **Org renaming via `principal_redirects`** — depends on the |
| 105 | + rename refactor. |
| 106 | +* **Daily digest / billing / SAML** — post-MVP per spec. |
| 107 | + |
| 108 | +## Pitfalls noted in code |
| 109 | + |
| 110 | +* **Slug-vs-username collision**: enforced by `principals` PK, |
| 111 | + tested by `TestCreate_RejectsCollisionWithUsername`. |
| 112 | +* **Last-owner orphaning**: tested by |
| 113 | + `TestChangeRole_LastOwnerProtection`. |
| 114 | +* **Email-invite claim by wrong user**: tested by |
| 115 | + `TestInvite_AcceptByEmailRejectsWrongUser` — only an account |
| 116 | + that owns the verified email matching the invite's `target_email` |
| 117 | + can claim. |
| 118 | +* **Duplicate invitations**: idempotency check returns |
| 119 | + `ErrInvitationDuplicate` rather than minting a new token, so |
| 120 | + re-clicking Invite doesn't spam the recipient. |
| 121 | +* **Reserved slugs**: `auth.IsReserved` filter applies to org |
| 122 | + slugs the same way it applies to usernames. |