tenseleyflow/shithub / 35e2c34

Browse files

S30: organizations docs + routing + status block

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
35e2c34dc9b163869923e646a58a318fa068e65e
Parents
0b2faf4
Tree
d52ad4e

1 changed file

StatusFile+-
A docs/internal/orgs.md 122 0
docs/internal/orgs.mdadded
@@ -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.