markdown · 7971 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: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:

  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. Compute effective role (owner ⇒ admin; collaborator ⇒ row.role; else RoleNone).
  7. Archived repo + write → deny (DenyArchived). Even owners can't push to archived repos.
  8. 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).
  9. 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

  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.

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.