markdown · 12703 bytes Raw Blame History

Permissions

Every authorization decision in shithub flows through one function: policy.Can(ctx, deps, actor, action, repo) → Decision. Handlers, hooks, and the SSH/HTTP git transports all funnel through this single entrypoint. No surface reads ownership, visibility, or collaborator state inline; the lint guard at scripts/lint-policy-boundary.sh enforces the boundary in CI.

The shape

  • Actor — who's asking. Anonymous, logged-in user (with IsSuspended and IsSiteAdmin flags), or future org-team principal (S31).
  • Action — what they want to do, drawn from the constant registry in internal/auth/policy/actions.go. New actions go in their owning sprint's PR; the matrix test ensures every constant is covered for every actor archetype.
  • Resource — currently a RepoRef; issue/pull/org refs land in their owning sprints. The RepoRef carries OwnerOrgID shape today (zero) so S31 plugs in without retro-fitting the interface.
  • Decision{Allow bool; Reason string; Code DenyCode}. Allow drives control flow; Code lets handlers pick a friendly user-facing message without re-deriving from the resource fields. Reason is for logs and tests, never end-user surfaces.

Role hierarchy

Five collaborator tiers, mirroring GitHub:

Role Implies Granted by
read Clone/fetch a private repo, view issues/pulls invitation by maintainer
triage read + close/label/assign issues, no code write invitation by maintainer
write triage + push, branch create, PR create/comment invitation by maintainer
maintain write + most settings (general, branches) invitation by admin
admin maintain + delete/transfer/visibility/destructive invitation by admin/owner

The repo owner is implicit admin — there's no repo_collaborators row for them. repo_collaborators lives in migration 0019 and is keyed by (repo_id, user_id).

Action → minimum role table

The complete map (also enforced by the matrix test):

Action Min role on repo
repo:read read (private)
repo:write write
repo:admin admin
repo:settings:general maintain
repo:settings:collaborators admin
repo:settings:branches maintain
repo:settings:actions admin
repo:archive admin
repo:delete admin
repo:transfer admin
repo:visibility admin
actions:run write
actions:approve maintain
issue:read read (private)
issue:create logged in on public; read on private
issue:comment logged in on public; read on private
issue:close triage
issue:label triage
issue:assign triage
pull:read read (private)
pull:create write
pull:merge admin
pull:review write
pull:close write
star:create logged in
fork:create logged in
watch:set logged in

Read actions on public repos are short-circuited to allow before the role check — anyone (anonymous or otherwise) can read a public repo.

The in-browser file editor uses repo:write for every mutation route (edit, new, delete, and upload). Its buttons are only rendered when the same action allows the current web actor on a named branch, and the POST handlers re-run the policy check before committing.

Decision precedence

Can() evaluates in a fixed order; the first matching rule produces the verdict. Ordered from most-decisive to least:

  1. Soft-deleted repo → deny (DenyRepoDeleted). Nothing else matters.
  2. Site-admin + read action → allow. (Write actions still go through the rest of the pipeline; broad admin overrides hide bugs and create insider-threat surface.)
  3. Suspended actor + write action → deny (DenyActorSuspended). Reads against public repos still allowed.
  4. Anonymous + private repo → deny (DenyVisibility). Caller maps to 404, not 403, to avoid existence leak.
  5. Public repo + read → allow.
  6. Public issue participation → logged-in users can create and comment on issues in public repos, subject to the suspended actor, archived repo, and suspended org write gates.
  7. Compute effective role (owner ⇒ admin; collaborator ⇒ row.role; else RoleNone).
  8. Archived repo + write → deny (DenyArchived). Even owners can't push to archived repos.
  9. Min role for action vs effective role. Below threshold + private repo + no role → deny as visibility (404). Below threshold with any role → deny as DenyRoleTooLow (403).
  10. Login-required actions (star/fork) on anonymous → deny (DenyAnonymous).

Authorization versus entitlements

policy.Can answers only one question: is this actor allowed to perform this action on this resource under shithub's permission model? It must not decide whether an organization has paid for a feature.

Paid organization checks are a second gate after authorization. The expected flow for gated writes is:

  1. Load the resource and run the normal policy check.
  2. Preserve policy.Maybe404 behavior for private-resource denials.
  3. Ask the entitlement layer whether the organization has the specific feature key.
  4. If the feature is unavailable, return a billing/upgrade response without re-deriving ownership, visibility, role, or plan state in the handler.

The entitlement layer may inspect billing state and plan-derived features. Policy code, handlers, git transports, and domain packages must not branch directly on orgs.plan or sqlc OrgPlan* constants. That keeps security authorization independent from commercial product packaging, and makes downgrades/grace periods possible without rewriting role checks.

For paid-feature UI, handlers should use the entitlement decision's upgrade metadata instead of inventing per-surface billing state. A missing Team feature maps to HTTP 402 semantics and a billing-settings path after the normal authorization result has already been accepted. Enterprise is not implicitly Team-plus; it is a contact-sales entitlement result until the Enterprise product contract ships.

Existence-leak guard

policy.Maybe404(decision, repo, actor) maps a denial to a status code that doesn't reveal whether a private repo exists. Convention:

  • Allow → 200.
  • Deny on a private repo, viewer is not the owner → 404.
  • Deny on a private repo, viewer is the owner (e.g. push to archived) → 403, since the owner already knows the repo exists.
  • Deny on a public repo → 403.

Handlers that care about user-facing message tone (e.g. the HTTP git handler's "repository is archived" stderr line) should switch on Decision.Code rather than parse Decision.Reason.

Per-request memoization

policy.WithCache(ctx) attaches a request-scoped memo. The web layer wires this in internal/web/middleware/policy.go::PolicyCache. Within one request, repeated Can() calls for the same (actor, repo) pair hit the cache. Across requests there's no cache — staleness is hard to get right and the per-request DB cost of fresh lookups is acceptable.

If a handler mutates collaborator state mid-request and re-checks policy in the same flight, call policy.InvalidateRepo(ctx, repoID) between the mutation and the re-check.

Suspended actors and the auth surfaces

The IsSuspended flag on Actor is the canonical input the policy package uses to deny writes by suspended accounts. Each entrypoint that constructs an actor must source it correctly:

  • Web (session)middleware.OptionalUser populates CurrentUser.IsSuspended from users.suspended_at. Handlers pass viewer.IsSuspended straight into policy.UserActor. The lookup is run on every request (no cookie-baked state), so an admin suspending an account takes effect on the user's next click.
  • Web (PAT)middleware.PATAuthMiddleware rejects requests whose owning user has suspended_at IS NOT NULL with a 401 before the handler runs. It still binds username, suspension, and site-admin fields into middleware.PATAuth; API policy gates must construct actors through PATAuth.PolicyActor() so the request actor stays honest even as the middleware evolves.
  • git over HTTPS (internal/web/handlers/githttp) — the basic- auth resolver (auth.go::resolveViaPAT/resolveViaPassword) rejects suspended owners with errBadCredentials before the policy check runs, so the policy.UserActor(..., false, ...) call in handler.go never sees a suspended actor. Suspension on the HTTPS git path is enforced at credential resolution, not at policy evaluation. If the credential resolver is ever reorganised to return a populated user even for suspended accounts, propagate the flag here.
  • git over SSH (internal/git/protocol/ssh_dispatch.go) — the dispatcher loads the user row before constructing the actor and passes user.SuspendedAt.Valid directly into policy.UserActor. The authorized_keys invocation also rejects up-front (see docs/internal/git-ssh.md), but the policy call is the defence-in-depth layer.
  • post-receive hook (cmd/shithubd/hook.go) — same shape as SSH dispatch: load user, pass SuspendedAt.Valid into the actor.

When adding a new auth entrypoint (e.g. an OAuth-bearing webhook ingest), the rule is: load the user record, source IsSuspended from users.suspended_at, and never hard-code false.

Site-admin scope

actor.IsSiteAdmin = true short-circuits to allow on read actions only. Write actions go through the normal role check, which means a site admin who is not a collaborator on a private repo cannot push or change settings without explicit impersonation (S34 ships the impersonation surface). Non-impersonated admin write attempts go through Can() like any other request and audit-log loudly via the S05 audit recorder.

Adding a new action

  1. Add the constant to internal/auth/policy/actions.go.
  2. Append it to AllActions in the same file.
  3. Add a case to minRoleFor(action) in policy.go. Unknown actions default to RoleAdmin (deny by default for strangers).
  4. The matrix test (policy_test.go) iterates AllActions and will automatically demand a verdict for every actor × resource × this action combination. Update mirrorMinRoleFor in the test file with the same minimum role.

If you add an action that involves a new resource type, add a *Ref struct in resources.go and an adapter in adapters.go, then overload Can with a new entrypoint (e.g. CanIssue, CanOrg).

Boundary lint

scripts/lint-policy-boundary.sh runs as part of make ci. It fails when the following patterns appear outside internal/auth/policy/, internal/repos/, and internal/web/handlers/repo/ (which constructs the policy actor at the lookup wrapper):

  • OwnerUserID == or == row.OwnerUserID — direct owner equality
  • Visibility == reposdb.Repo... — direct visibility branching
  • if X.IsArchived — archived as a control predicate

Test files everywhere are exempt — they legitimately seed state. If a new pattern surfaces (e.g. an issue handler reads issue.author_id), extend the script accordingly.

scripts/lint-org-plan-boundary.sh also runs in make ci. It fails on direct plan feature gates outside internal/billing/, internal/entitlements/, generated sqlc models, migrations, and tests. When adding a paid feature, add or use an entitlement feature key rather than comparing OrgPlanTeam or OrgPlanEnterprise at the call site.

View source
1 # Permissions
2
3 Every authorization decision in shithub flows through one function:
4 `policy.Can(ctx, deps, actor, action, repo) → Decision`. Handlers,
5 hooks, and the SSH/HTTP git transports all funnel through this single
6 entrypoint. No surface reads ownership, visibility, or collaborator
7 state inline; the lint guard at `scripts/lint-policy-boundary.sh`
8 enforces the boundary in CI.
9
10 ## The shape
11
12 * **Actor** — who's asking. Anonymous, logged-in user (with `IsSuspended`
13 and `IsSiteAdmin` flags), or future org-team principal (S31).
14 * **Action** — what they want to do, drawn from the constant registry
15 in `internal/auth/policy/actions.go`. New actions go in their owning
16 sprint's PR; the matrix test ensures every constant is covered for
17 every actor archetype.
18 * **Resource** — currently a `RepoRef`; issue/pull/org refs land in
19 their owning sprints. The `RepoRef` carries `OwnerOrgID` shape today
20 (zero) so S31 plugs in without retro-fitting the interface.
21 * **Decision** — `{Allow bool; Reason string; Code DenyCode}`. Allow
22 drives control flow; Code lets handlers pick a friendly user-facing
23 message without re-deriving from the resource fields. Reason is for
24 logs and tests, never end-user surfaces.
25
26 ## Role hierarchy
27
28 Five collaborator tiers, mirroring GitHub:
29
30 | Role | Implies | Granted by |
31 | ---------- | ------------------------------------------------- | ------------------------- |
32 | `read` | Clone/fetch a private repo, view issues/pulls | invitation by maintainer |
33 | `triage` | read + close/label/assign issues, no code write | invitation by maintainer |
34 | `write` | triage + push, branch create, PR create/comment | invitation by maintainer |
35 | `maintain` | write + most settings (general, branches) | invitation by admin |
36 | `admin` | maintain + delete/transfer/visibility/destructive | invitation by admin/owner |
37
38 The repo **owner** is implicit `admin` — there's no
39 `repo_collaborators` row for them. `repo_collaborators` lives in
40 migration `0019` and is keyed by `(repo_id, user_id)`.
41
42 ## Action → minimum role table
43
44 The complete map (also enforced by the matrix test):
45
46 | Action | Min role on repo |
47 | ------------------------------------- | ---------------- |
48 | `repo:read` | `read` (private) |
49 | `repo:write` | `write` |
50 | `repo:admin` | `admin` |
51 | `repo:settings:general` | `maintain` |
52 | `repo:settings:collaborators` | `admin` |
53 | `repo:settings:branches` | `maintain` |
54 | `repo:settings:actions` | `admin` |
55 | `repo:archive` | `admin` |
56 | `repo:delete` | `admin` |
57 | `repo:transfer` | `admin` |
58 | `repo:visibility` | `admin` |
59 | `actions:run` | `write` |
60 | `actions:approve` | `maintain` |
61 | `issue:read` | `read` (private) |
62 | `issue:create` | logged in on public; `read` on private |
63 | `issue:comment` | logged in on public; `read` on private |
64 | `issue:close` | `triage` |
65 | `issue:label` | `triage` |
66 | `issue:assign` | `triage` |
67 | `pull:read` | `read` (private) |
68 | `pull:create` | `write` |
69 | `pull:merge` | `admin` |
70 | `pull:review` | `write` |
71 | `pull:close` | `write` |
72 | `star:create` | logged in |
73 | `fork:create` | logged in |
74 | `watch:set` | logged in |
75
76 Read actions on **public** repos are short-circuited to allow before the
77 role check — anyone (anonymous or otherwise) can read a public repo.
78
79 The in-browser file editor uses `repo:write` for every mutation route
80 (`edit`, `new`, `delete`, and `upload`). Its buttons are only rendered
81 when the same action allows the current web actor on a named branch, and
82 the POST handlers re-run the policy check before committing.
83
84 ## Decision precedence
85
86 `Can()` evaluates in a fixed order; the first matching rule produces
87 the verdict. Ordered from most-decisive to least:
88
89 1. **Soft-deleted repo** → deny (`DenyRepoDeleted`). Nothing else
90 matters.
91 2. **Site-admin + read action** → allow. (Write actions still go
92 through the rest of the pipeline; broad admin overrides hide bugs
93 and create insider-threat surface.)
94 3. **Suspended actor + write action** → deny (`DenyActorSuspended`).
95 Reads against public repos still allowed.
96 4. **Anonymous + private repo** → deny (`DenyVisibility`). Caller maps
97 to 404, not 403, to avoid existence leak.
98 5. **Public repo + read** → allow.
99 6. **Public issue participation** → logged-in users can create and
100 comment on issues in public repos, subject to the suspended actor,
101 archived repo, and suspended org write gates.
102 7. Compute effective role (owner ⇒ admin; collaborator ⇒ row.role;
103 else `RoleNone`).
104 8. **Archived repo + write** → deny (`DenyArchived`). Even owners
105 can't push to archived repos.
106 9. **Min role for action** vs effective role. Below threshold + private
107 repo + no role → deny as visibility (404). Below threshold with any
108 role → deny as `DenyRoleTooLow` (403).
109 10. **Login-required actions** (star/fork) on anonymous → deny
110 (`DenyAnonymous`).
111
112 ## Authorization versus entitlements
113
114 `policy.Can` answers only one question: is this actor allowed to
115 perform this action on this resource under shithub's permission model?
116 It must not decide whether an organization has paid for a feature.
117
118 Paid organization checks are a second gate after authorization. The
119 expected flow for gated writes is:
120
121 1. Load the resource and run the normal policy check.
122 2. Preserve `policy.Maybe404` behavior for private-resource denials.
123 3. Ask the entitlement layer whether the organization has the specific
124 feature key.
125 4. If the feature is unavailable, return a billing/upgrade response
126 without re-deriving ownership, visibility, role, or plan state in
127 the handler.
128
129 The entitlement layer may inspect billing state and plan-derived
130 features. Policy code, handlers, git transports, and domain packages
131 must not branch directly on `orgs.plan` or sqlc `OrgPlan*` constants.
132 That keeps security authorization independent from commercial product
133 packaging, and makes downgrades/grace periods possible without
134 rewriting role checks.
135
136 For paid-feature UI, handlers should use the entitlement decision's
137 upgrade metadata instead of inventing per-surface billing state. A
138 missing Team feature maps to HTTP 402 semantics and a billing-settings
139 path after the normal authorization result has already been accepted.
140 Enterprise is not implicitly Team-plus; it is a contact-sales
141 entitlement result until the Enterprise product contract ships.
142
143 ## Existence-leak guard
144
145 `policy.Maybe404(decision, repo, actor)` maps a denial to a status
146 code that doesn't reveal whether a private repo exists. Convention:
147
148 * Allow → 200.
149 * Deny on a private repo, viewer is not the owner → **404**.
150 * Deny on a private repo, viewer **is** the owner (e.g. push to
151 archived) → **403**, since the owner already knows the repo exists.
152 * Deny on a public repo → **403**.
153
154 Handlers that care about user-facing message tone (e.g. the HTTP git
155 handler's "repository is archived" stderr line) should switch on
156 `Decision.Code` rather than parse `Decision.Reason`.
157
158 ## Per-request memoization
159
160 `policy.WithCache(ctx)` attaches a request-scoped memo. The web layer
161 wires this in `internal/web/middleware/policy.go::PolicyCache`. Within
162 one request, repeated `Can()` calls for the same `(actor, repo)` pair
163 hit the cache. Across requests there's no cache — staleness is hard to
164 get right and the per-request DB cost of fresh lookups is acceptable.
165
166 If a handler mutates collaborator state mid-request and re-checks
167 policy in the same flight, call `policy.InvalidateRepo(ctx, repoID)`
168 between the mutation and the re-check.
169
170 ## Suspended actors and the auth surfaces
171
172 The `IsSuspended` flag on `Actor` is the canonical input the policy
173 package uses to deny writes by suspended accounts. Each entrypoint
174 that constructs an actor must source it correctly:
175
176 * **Web (session)** — `middleware.OptionalUser` populates
177 `CurrentUser.IsSuspended` from `users.suspended_at`. Handlers pass
178 `viewer.IsSuspended` straight into `policy.UserActor`. The lookup
179 is run on every request (no cookie-baked state), so an admin
180 suspending an account takes effect on the user's next click.
181 * **Web (PAT)** — `middleware.PATAuthMiddleware` rejects requests
182 whose owning user has `suspended_at IS NOT NULL` with a 401 before
183 the handler runs. It still binds username, suspension, and site-admin
184 fields into `middleware.PATAuth`; API policy gates must construct
185 actors through `PATAuth.PolicyActor()` so the request actor stays
186 honest even as the middleware evolves.
187 * **git over HTTPS (`internal/web/handlers/githttp`)** — the basic-
188 auth resolver (`auth.go::resolveViaPAT`/`resolveViaPassword`)
189 rejects suspended owners with `errBadCredentials` *before* the
190 policy check runs, so the `policy.UserActor(..., false, ...)` call
191 in `handler.go` never sees a suspended actor. Suspension on the
192 HTTPS git path is enforced at credential resolution, not at policy
193 evaluation. If the credential resolver is ever reorganised to
194 return a populated user even for suspended accounts, propagate the
195 flag here.
196 * **git over SSH (`internal/git/protocol/ssh_dispatch.go`)** — the
197 dispatcher loads the user row before constructing the actor and
198 passes `user.SuspendedAt.Valid` directly into `policy.UserActor`.
199 The `authorized_keys` invocation also rejects up-front (see
200 `docs/internal/git-ssh.md`), but the policy call is the
201 defence-in-depth layer.
202 * **post-receive hook (`cmd/shithubd/hook.go`)** — same shape as
203 SSH dispatch: load user, pass `SuspendedAt.Valid` into the actor.
204
205 When adding a new auth entrypoint (e.g. an OAuth-bearing webhook
206 ingest), the rule is: load the user record, source `IsSuspended`
207 from `users.suspended_at`, and *never* hard-code `false`.
208
209 ## Site-admin scope
210
211 `actor.IsSiteAdmin = true` short-circuits to allow on read actions
212 only. Write actions go through the normal role check, which means a
213 site admin who is **not** a collaborator on a private repo cannot
214 push or change settings without explicit impersonation (S34 ships the
215 impersonation surface). Non-impersonated admin write attempts go
216 through Can() like any other request and audit-log loudly via the S05
217 audit recorder.
218
219 ## Adding a new action
220
221 1. Add the constant to `internal/auth/policy/actions.go`.
222 2. Append it to `AllActions` in the same file.
223 3. Add a case to `minRoleFor(action)` in `policy.go`. Unknown actions
224 default to `RoleAdmin` (deny by default for strangers).
225 4. The matrix test (`policy_test.go`) iterates `AllActions` and will
226 automatically demand a verdict for every actor × resource × this
227 action combination. Update `mirrorMinRoleFor` in the test file with
228 the same minimum role.
229
230 If you add an action that involves a new resource type, add a `*Ref`
231 struct in `resources.go` and an adapter in `adapters.go`, then
232 overload `Can` with a new entrypoint (e.g. `CanIssue`, `CanOrg`).
233
234 ## Boundary lint
235
236 `scripts/lint-policy-boundary.sh` runs as part of `make ci`. It fails
237 when the following patterns appear outside `internal/auth/policy/`,
238 `internal/repos/`, and `internal/web/handlers/repo/` (which constructs
239 the policy actor at the lookup wrapper):
240
241 * `OwnerUserID == ` or `== row.OwnerUserID` — direct owner equality
242 * `Visibility == reposdb.Repo...` — direct visibility branching
243 * `if X.IsArchived` — archived as a control predicate
244
245 Test files everywhere are exempt — they legitimately seed state. If a
246 new pattern surfaces (e.g. an issue handler reads `issue.author_id`),
247 extend the script accordingly.
248
249 `scripts/lint-org-plan-boundary.sh` also runs in `make ci`. It fails on
250 direct plan feature gates outside `internal/billing/`,
251 `internal/entitlements/`, generated sqlc models, migrations, and tests.
252 When adding a paid feature, add or use an entitlement feature key rather
253 than comparing `OrgPlanTeam` or `OrgPlanEnterprise` at the call site.