@@ -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. |