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:archive |
admin |
repo:delete |
admin |
repo:transfer |
admin |
repo:visibility |
admin |
issue:read |
read (private) |
issue:create |
write |
issue:comment |
write |
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 |
Read actions on public repos are short-circuited to allow before the role check — anyone (anonymous or otherwise) can read a public repo.
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.
- 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).
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.
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.
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:archive` | `admin` | |
| 55 | | `repo:delete` | `admin` | |
| 56 | | `repo:transfer` | `admin` | |
| 57 | | `repo:visibility` | `admin` | |
| 58 | | `issue:read` | `read` (private) | |
| 59 | | `issue:create` | `write` | |
| 60 | | `issue:comment` | `write` | |
| 61 | | `issue:close` | `triage` | |
| 62 | | `issue:label` | `triage` | |
| 63 | | `issue:assign` | `triage` | |
| 64 | | `pull:read` | `read` (private) | |
| 65 | | `pull:create` | `write` | |
| 66 | | `pull:merge` | `admin` | |
| 67 | | `pull:review` | `write` | |
| 68 | | `pull:close` | `write` | |
| 69 | | `star:create` | logged in | |
| 70 | | `fork:create` | logged in | |
| 71 | |
| 72 | Read actions on **public** repos are short-circuited to allow before the |
| 73 | role check — anyone (anonymous or otherwise) can read a public repo. |
| 74 | |
| 75 | ## Decision precedence |
| 76 | |
| 77 | `Can()` evaluates in a fixed order; the first matching rule produces |
| 78 | the verdict. Ordered from most-decisive to least: |
| 79 | |
| 80 | 1. **Soft-deleted repo** → deny (`DenyRepoDeleted`). Nothing else |
| 81 | matters. |
| 82 | 2. **Site-admin + read action** → allow. (Write actions still go |
| 83 | through the rest of the pipeline; broad admin overrides hide bugs |
| 84 | and create insider-threat surface.) |
| 85 | 3. **Suspended actor + write action** → deny (`DenyActorSuspended`). |
| 86 | Reads against public repos still allowed. |
| 87 | 4. **Anonymous + private repo** → deny (`DenyVisibility`). Caller maps |
| 88 | to 404, not 403, to avoid existence leak. |
| 89 | 5. **Public repo + read** → allow. |
| 90 | 6. Compute effective role (owner ⇒ admin; collaborator ⇒ row.role; |
| 91 | else `RoleNone`). |
| 92 | 7. **Archived repo + write** → deny (`DenyArchived`). Even owners |
| 93 | can't push to archived repos. |
| 94 | 8. **Min role for action** vs effective role. Below threshold + private |
| 95 | repo + no role → deny as visibility (404). Below threshold with any |
| 96 | role → deny as `DenyRoleTooLow` (403). |
| 97 | 9. **Login-required actions** (star/fork) on anonymous → deny |
| 98 | (`DenyAnonymous`). |
| 99 | |
| 100 | ## Existence-leak guard |
| 101 | |
| 102 | `policy.Maybe404(decision, repo, actor)` maps a denial to a status |
| 103 | code that doesn't reveal whether a private repo exists. Convention: |
| 104 | |
| 105 | * Allow → 200. |
| 106 | * Deny on a private repo, viewer is not the owner → **404**. |
| 107 | * Deny on a private repo, viewer **is** the owner (e.g. push to |
| 108 | archived) → **403**, since the owner already knows the repo exists. |
| 109 | * Deny on a public repo → **403**. |
| 110 | |
| 111 | Handlers that care about user-facing message tone (e.g. the HTTP git |
| 112 | handler's "repository is archived" stderr line) should switch on |
| 113 | `Decision.Code` rather than parse `Decision.Reason`. |
| 114 | |
| 115 | ## Per-request memoization |
| 116 | |
| 117 | `policy.WithCache(ctx)` attaches a request-scoped memo. The web layer |
| 118 | wires this in `internal/web/middleware/policy.go::PolicyCache`. Within |
| 119 | one request, repeated `Can()` calls for the same `(actor, repo)` pair |
| 120 | hit the cache. Across requests there's no cache — staleness is hard to |
| 121 | get right and the per-request DB cost of fresh lookups is acceptable. |
| 122 | |
| 123 | If a handler mutates collaborator state mid-request and re-checks |
| 124 | policy in the same flight, call `policy.InvalidateRepo(ctx, repoID)` |
| 125 | between the mutation and the re-check. |
| 126 | |
| 127 | ## Site-admin scope |
| 128 | |
| 129 | `actor.IsSiteAdmin = true` short-circuits to allow on read actions |
| 130 | only. Write actions go through the normal role check, which means a |
| 131 | site admin who is **not** a collaborator on a private repo cannot |
| 132 | push or change settings without explicit impersonation (S34 ships the |
| 133 | impersonation surface). Non-impersonated admin write attempts go |
| 134 | through Can() like any other request and audit-log loudly via the S05 |
| 135 | audit recorder. |
| 136 | |
| 137 | ## Adding a new action |
| 138 | |
| 139 | 1. Add the constant to `internal/auth/policy/actions.go`. |
| 140 | 2. Append it to `AllActions` in the same file. |
| 141 | 3. Add a case to `minRoleFor(action)` in `policy.go`. Unknown actions |
| 142 | default to `RoleAdmin` (deny by default for strangers). |
| 143 | 4. The matrix test (`policy_test.go`) iterates `AllActions` and will |
| 144 | automatically demand a verdict for every actor × resource × this |
| 145 | action combination. Update `mirrorMinRoleFor` in the test file with |
| 146 | the same minimum role. |
| 147 | |
| 148 | If you add an action that involves a new resource type, add a `*Ref` |
| 149 | struct in `resources.go` and an adapter in `adapters.go`, then |
| 150 | overload `Can` with a new entrypoint (e.g. `CanIssue`, `CanOrg`). |
| 151 | |
| 152 | ## Boundary lint |
| 153 | |
| 154 | `scripts/lint-policy-boundary.sh` runs as part of `make ci`. It fails |
| 155 | when the following patterns appear outside `internal/auth/policy/`, |
| 156 | `internal/repos/`, and `internal/web/handlers/repo/` (which constructs |
| 157 | the policy actor at the lookup wrapper): |
| 158 | |
| 159 | * `OwnerUserID == ` or `== row.OwnerUserID` — direct owner equality |
| 160 | * `Visibility == reposdb.Repo...` — direct visibility branching |
| 161 | * `if X.IsArchived` — archived as a control predicate |
| 162 | |
| 163 | Test files everywhere are exempt — they legitimately seed state. If a |
| 164 | new pattern surfaces (e.g. an issue handler reads `issue.author_id`), |
| 165 | extend the script accordingly. |