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
IsSuspendedandIsSiteAdminflags), 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. TheRepoRefcarriesOwnerOrgIDshape 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:
- Soft-deleted repo → deny (
DenyRepoDeleted). Nothing else matters. - 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.)
- Suspended actor + write action → deny (
DenyActorSuspended). Reads against public repos still allowed. - Anonymous + private repo → deny (
DenyVisibility). Caller maps to 404, not 403, to avoid existence leak. - Public repo + read → allow.
- 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.
- Compute effective role (owner ⇒ admin; collaborator ⇒ row.role;
else
RoleNone). - Archived repo + write → deny (
DenyArchived). Even owners can't push to archived repos. - 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). - 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:
- Load the resource and run the normal policy check.
- Preserve
policy.Maybe404behavior for private-resource denials. - Ask the entitlement layer whether the organization has the specific feature key.
- 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.OptionalUserpopulatesCurrentUser.IsSuspendedfromusers.suspended_at. Handlers passviewer.IsSuspendedstraight intopolicy.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.PATAuthMiddlewarerejects requests whose owning user hassuspended_at IS NOT NULLwith a 401 before the handler runs. It still binds username, suspension, and site-admin fields intomiddleware.PATAuth; API policy gates must construct actors throughPATAuth.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 witherrBadCredentialsbefore the policy check runs, so thepolicy.UserActor(..., false, ...)call inhandler.gonever 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 passesuser.SuspendedAt.Validdirectly intopolicy.UserActor. Theauthorized_keysinvocation also rejects up-front (seedocs/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, passSuspendedAt.Validinto 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
- Add the constant to
internal/auth/policy/actions.go. - Append it to
AllActionsin the same file. - Add a case to
minRoleFor(action)inpolicy.go. Unknown actions default toRoleAdmin(deny by default for strangers). - The matrix test (
policy_test.go) iteratesAllActionsand will automatically demand a verdict for every actor × resource × this action combination. UpdatemirrorMinRoleForin 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 equalityVisibility == reposdb.Repo...— direct visibility branchingif 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. |