@@ -124,6 +124,45 @@ If a handler mutates collaborator state mid-request and re-checks |
| 124 | policy in the same flight, call `policy.InvalidateRepo(ctx, repoID)` | 124 | policy in the same flight, call `policy.InvalidateRepo(ctx, repoID)` |
| 125 | between the mutation and the re-check. | 125 | between the mutation and the re-check. |
| 126 | | 126 | |
| | 127 | +## Suspended actors and the auth surfaces |
| | 128 | + |
| | 129 | +The `IsSuspended` flag on `Actor` is the canonical input the policy |
| | 130 | +package uses to deny writes by suspended accounts. Each entrypoint |
| | 131 | +that constructs an actor must source it correctly: |
| | 132 | + |
| | 133 | +* **Web (session)** — `middleware.OptionalUser` populates |
| | 134 | + `CurrentUser.IsSuspended` from `users.suspended_at`. Handlers pass |
| | 135 | + `viewer.IsSuspended` straight into `policy.UserActor`. The lookup |
| | 136 | + is run on every request (no cookie-baked state), so an admin |
| | 137 | + suspending an account takes effect on the user's next click. |
| | 138 | +* **Web (PAT)** — `middleware.PATAuthMiddleware` rejects requests |
| | 139 | + whose owning user has `suspended_at IS NOT NULL` with a 401 before |
| | 140 | + the handler runs. Code paths under PAT auth construct |
| | 141 | + `policy.UserActor(..., IsSuspended: false, ...)` because the gate |
| | 142 | + is upstream; the field is still passed for honesty and is correct |
| | 143 | + by construction. |
| | 144 | +* **git over HTTPS (`internal/web/handlers/githttp`)** — the basic- |
| | 145 | + auth resolver (`auth.go::resolveViaPAT`/`resolveViaPassword`) |
| | 146 | + rejects suspended owners with `errBadCredentials` *before* the |
| | 147 | + policy check runs, so the `policy.UserActor(..., false, ...)` call |
| | 148 | + in `handler.go` never sees a suspended actor. Suspension on the |
| | 149 | + HTTPS git path is enforced at credential resolution, not at policy |
| | 150 | + evaluation. If the credential resolver is ever reorganised to |
| | 151 | + return a populated user even for suspended accounts, propagate the |
| | 152 | + flag here. |
| | 153 | +* **git over SSH (`internal/git/protocol/ssh_dispatch.go`)** — the |
| | 154 | + dispatcher loads the user row before constructing the actor and |
| | 155 | + passes `user.SuspendedAt.Valid` directly into `policy.UserActor`. |
| | 156 | + The `authorized_keys` invocation also rejects up-front (see |
| | 157 | + `docs/internal/git-ssh.md`), but the policy call is the |
| | 158 | + defence-in-depth layer. |
| | 159 | +* **post-receive hook (`cmd/shithubd/hook.go`)** — same shape as |
| | 160 | + SSH dispatch: load user, pass `SuspendedAt.Valid` into the actor. |
| | 161 | + |
| | 162 | +When adding a new auth entrypoint (e.g. an OAuth-bearing webhook |
| | 163 | +ingest), the rule is: load the user record, source `IsSuspended` |
| | 164 | +from `users.suspended_at`, and *never* hard-code `false`. |
| | 165 | + |
| 127 | ## Site-admin scope | 166 | ## Site-admin scope |
| 128 | | 167 | |
| 129 | `actor.IsSiteAdmin = true` short-circuits to allow on read actions | 168 | `actor.IsSiteAdmin = true` short-circuits to allow on read actions |