@@ -124,6 +124,45 @@ If a handler mutates collaborator state mid-request and re-checks |
| 124 | 124 | policy in the same flight, call `policy.InvalidateRepo(ctx, repoID)` |
| 125 | 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 | 166 | ## Site-admin scope |
| 128 | 167 | |
| 129 | 168 | `actor.IsSiteAdmin = true` short-circuits to allow on read actions |