| 1 |
|
// Package authn answers one question for spec.sr.ht — "who is making this |
| 2 |
|
// request?" — and builds the git provenance that answers "who made this write?" |
| 3 |
|
// forever after. |
| 4 |
|
// |
| 5 |
|
// The design has exactly two principals that carry authority: |
| 6 |
|
// |
| 7 |
|
// - the human owner, recognised by the shared `sr.ht.unified-login.v1` |
| 8 |
|
// cookie carrying the instance's [sr.ht] owner-name; |
| 9 |
|
// - an agent, recognised by a bearer token. |
| 10 |
|
// |
| 11 |
|
// Everything else is anonymous. Single-user does not mean "no authorization"; |
| 12 |
|
// it relocates it onto agents, which is why Principal distinguishes those two |
| 13 |
|
// and nothing finer. The boundary that bounds an agent's damage is the refs rule |
| 14 |
|
// (agents may only write proposals/*), and that lives in gitx; nothing here |
| 15 |
|
// replaces it. |
| 16 |
|
// |
| 17 |
|
// # Two agent credential planes, and one surface that takes the second |
| 18 |
|
// |
| 19 |
|
// An agent is recognised by a tokens.sr.ht working token — PlaneInstance: |
| 20 |
|
// signed by the instance, expiring, owned by a meta.sr.ht account, and carrying |
| 21 |
|
// a grant set (ActionPropose, ActionRead). It is validated by sr-ht-ecore's |
| 22 |
|
// bearer package, and it is the credential of every surface this service has: |
| 23 |
|
// the REST write plane, /mcp, the machine formats of the read plane, and the |
| 24 |
|
// `git push` hook path. |
| 25 |
|
// |
| 26 |
|
// /query takes a second one — PlaneMeta, an ordinary meta.sr.ht personal access |
| 27 |
|
// token scoped by ScopeRead — and it takes it because of api.sr.ht rather than |
| 28 |
|
// because a second credential is desirable. The gateway forwards ONE client |
| 29 |
|
// Authorization header to every service a federated query touches: its |
| 30 |
|
// AuthMiddleware copies the client's header verbatim into the request context, |
| 31 |
|
// and the internal credential it can mint is used only to fetch schemas at |
| 32 |
|
// startup. So a federated caller arrives here holding whatever credential the |
| 33 |
|
// client had, and the only credential a client can hold that works across the |
| 34 |
|
// whole instance is a meta PAT. An endpoint that refuses one can never be |
| 35 |
|
// federated: adding it to the gateway would be one `api-origin=` line that |
| 36 |
|
// produces 401s. |
| 37 |
|
// |
| 38 |
|
// The scope of that exception is narrow and is kept narrow structurally rather |
| 39 |
|
// than by discipline. MetaAuth is not part of Resolver, so nothing that resolves |
| 40 |
|
// identity through Resolver can produce a PlaneMeta principal — graph builds the |
| 41 |
|
// plane itself, wired from cmd/specsrht at the single call site that may use it, |
| 42 |
|
// and no other surface holds one. A personal access token is therefore not a way |
| 43 |
|
// around the tokens.sr.ht grant an upload, an MCP tool or a push requires. |
| 44 |
|
// |
| 45 |
|
// spec used to mint its own credential as well — the agent_token row: one |
| 46 |
|
// instance-wide shared secret, hashed at rest, with no owner, no expiry and no |
| 47 |
|
// grants. That plane is gone. Issuance is centralised — tokens.sr.ht mints a |
| 48 |
|
// working token, meta.sr.ht mints a PAT — so each plane has one door and nothing |
| 49 |
|
// behind it: a credential a plane refuses is refused, rather than being offered |
| 50 |
|
// to a second store that might say yes. A meta PAT presented to any surface but |
| 51 |
|
// /query is bearer.ErrNotOurs and fails at that door, which is the same answer |
| 52 |
|
// one hash lookup later, said honestly. |
| 53 |
|
// |
| 54 |
|
// Both credential planes name an owner where the local secret had none. |
| 55 |
|
// Principal.Owner means "the human this agent acts for", which on this |
| 56 |
|
// single-owner instance is always [sr.ht] owner-name — a token belonging to |
| 57 |
|
// anybody else is refused rather than admitted as a second identity, because |
| 58 |
|
// every consumer of that field (the provenance committer, the refs rule's |
| 59 |
|
// principal kind, the coreauth AuthContext) is written for one human. That rule |
| 60 |
|
// is why MetaAuth is told the owner too: a PAT is the credential every account on |
| 61 |
|
// the instance can mint, so without it the widest possible credential would be |
| 62 |
|
// the one that skipped the narrowest check. |
| 63 |
|
// |
| 64 |
|
// Grants are orthogonal to the refs rule and to provenance, and replace neither. |
| 65 |
|
// A grant says what an instance token was minted for; the refs rule still says |
| 66 |
|
// where an agent may point a ref, and provenance is still mandatory on every |
| 67 |
|
// agent write. |
| 68 |
|
// |
| 69 |
|
// The cookie and the bearer planes are deliberately asymmetric: |
| 70 |
|
// |
| 71 |
|
// - A cookie that is missing, forged, expired or unreadable yields an |
| 72 |
|
// anonymous principal and never an error. Browsing must keep working. |
| 73 |
|
// - A bearer token that is present but unknown, revoked or corrupt is a hard |
| 74 |
|
// failure. An agent that presented an explicit credential must not be |
| 75 |
|
// silently downgraded to a reader; it would then fail confusingly at the |
| 76 |
|
// write instead of clearly at the door. |
| 77 |
|
// |
| 78 |
|
// Provenance is the other half. Agent identity and session ID are mandatory on |
| 79 |
|
// every agent write, and are recorded in the commit itself so that a plain |
| 80 |
|
// `git log` on any clone carries the audit trail: |
| 81 |
|
// |
| 82 |
|
// Author: claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es> |
| 83 |
|
// Committer: bigbes <bigbes@gmail.com> |
| 84 |
|
// |
| 85 |
|
// Add storage model section |
| 86 |
|
// |
| 87 |
|
// X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921 |
| 88 |
|
// X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809 |
| 89 |
|
// |
| 90 |
|
// A write missing either field is rejected rather than defaulted: a commit |
| 91 |
|
// stamped with a guessed session is worse than no commit, because it launders |
| 92 |
|
// unattributable output as attributed. |
| 93 |
|
// |
| 94 |
|
// This package owns no storage and opens no connections. The "user" row an |
| 95 |
|
// instance token's owner resolves to is reached through UserLookup, and the |
| 96 |
|
// tokens.sr.ht validator through BearerValidator. authn never imports db and |
| 97 |
|
// never calls core-go's auth.LookupUser itself, so the dependency arrow keeps |
| 98 |
|
// pointing downward and the whole package stays testable with no Postgres and no |
| 99 |
|
// daemon to talk to. |
| 100 |
|
package authn |
| 101 |
|
|
| 102 |
|
import ( |
| 103 |
|
"errors" |
| 104 |
|
|
| 105 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer" |
| 106 |
|
) |
| 107 |
|
|
| 108 |
|
// Sentinel errors. Callers compare with errors.Is. The split that matters is |
| 109 |
|
// permanent (the credential is bad — 401/403) versus transient (the backend |
| 110 |
|
// could not answer — 503); IsAuthFailure draws it. |
| 111 |
|
// |
| 112 |
|
// Everything a working token can be wrong about is spelled by sr-ht-ecore's |
| 113 |
|
// bearer package — ErrInvalid, ErrNotOurs, ErrRevoked — because that plane has |
| 114 |
|
// one issuer and one validator. The sentinels below are what this service adds |
| 115 |
|
// on top of that answer, and they now include the meta plane's three: its |
| 116 |
|
// validator's refusals are metapat's, and its prose has to name meta.sr.ht where |
| 117 |
|
// bearer's names tokens.sr.ht, while the status each one maps to is deliberately |
| 118 |
|
// identical. See ScopeRead and MetaAuth. |
| 119 |
|
var ( |
| 120 |
|
// ErrNoToken is returned when a bearer credential was expected but the |
| 121 |
|
// request carried no Authorization header, or one in another scheme. |
| 122 |
|
ErrNoToken = errors.New("no agent token presented") |
| 123 |
|
|
| 124 |
|
// ErrNoAgentPlane is returned when a bearer credential is presented to a |
| 125 |
|
// resolver that was built without the tokens.sr.ht plane — an instance whose |
| 126 |
|
// config.ini has no [tokens.sr.ht] origin. It is a wiring failure and not a |
| 127 |
|
// credential failure, so it is deliberately not an IsAuthFailure: telling an |
| 128 |
|
// agent its token is bad when the truth is that this service cannot check |
| 129 |
|
// any token would send it off to re-provision a perfectly good credential. |
| 130 |
|
ErrNoAgentPlane = errors.New("no agent credential plane is configured") |
| 131 |
|
|
| 132 |
|
// ErrNotAgent is returned when agent provenance is demanded of a principal |
| 133 |
|
// that is not an agent — the human push path builds no trailers. |
| 134 |
|
ErrNotAgent = errors.New("principal is not an agent") |
| 135 |
|
|
| 136 |
|
// ErrMissingProvenance marks an agent write that omits the agent identity, |
| 137 |
|
// the session ID, or the base revision. Mandatory on every agent write; the |
| 138 |
|
// design is explicit that these are not defaultable. |
| 139 |
|
ErrMissingProvenance = errors.New("missing agent provenance") |
| 140 |
|
|
| 141 |
|
// ErrInvalidProvenance marks provenance whose values are present but |
| 142 |
|
// unusable: control characters or angle brackets that would forge a git |
| 143 |
|
// signature line or inject an extra trailer, an over-long field, or a base |
| 144 |
|
// revision that is not a hex object name. |
| 145 |
|
ErrInvalidProvenance = errors.New("invalid agent provenance") |
| 146 |
|
|
| 147 |
|
// ErrMissingConfig is returned by InstanceFromConfig when the instance |
| 148 |
|
// config lacks a key the provenance identities are built from. It is a |
| 149 |
|
// startup failure, not a request failure. |
| 150 |
|
ErrMissingConfig = errors.New("missing instance config key") |
| 151 |
|
|
| 152 |
|
// ErrNotInstanceOwner marks a valid tokens.sr.ht working token whose owner is |
| 153 |
|
// somebody other than the instance owner. 403: the credential verifies and |
| 154 |
|
// the holder is who they say they are, there is simply nothing on this |
| 155 |
|
// single-owner instance to grant them. Deliberately not an IsAuthFailure — |
| 156 |
|
// presenting it again will not help and neither will logging in. |
| 157 |
|
ErrNotInstanceOwner = errors.New("token owner is not the instance owner") |
| 158 |
|
|
| 159 |
|
// ErrMissingGrant marks an instance token that authenticated fine but does |
| 160 |
|
// not carry the action being attempted. 403, for the reason |
| 161 |
|
// bearer.ErrForbidden is: what the holder needs is a wider grant, not another |
| 162 |
|
// login. |
| 163 |
|
// |
| 164 |
|
// It is raised by Principal.Authorize, at the layer that knows the action — |
| 165 |
|
// never by the resolver, which runs before the router and so knows none. |
| 166 |
|
ErrMissingGrant = errors.New("token does not grant this action") |
| 167 |
|
|
| 168 |
|
// ErrMissingScope marks a meta.sr.ht personal access token that authenticated |
| 169 |
|
// fine and was not minted to read through this service: it does not carry |
| 170 |
|
// ScopeRead. 403, exactly as ErrMissingGrant is and for the same reason. |
| 171 |
|
// |
| 172 |
|
// It is deliberately not ErrMissingGrant. The two name permissions in |
| 173 |
|
// vocabularies that do not overlap — no PAT can carry "spec:read", because |
| 174 |
|
// meta's personal-token page cannot spell it, and no working token can carry |
| 175 |
|
// "spec.sr.ht/SPECS", because ecore's grants grammar does not read that shape |
| 176 |
|
// — so a refusal has to name the one the caller can actually go and obtain. |
| 177 |
|
// A PAT holder sent looking for "spec:read" would be hunting a checkbox that |
| 178 |
|
// does not exist. |
| 179 |
|
// |
| 180 |
|
// Unlike ErrMissingGrant it is raised at resolution rather than at the router, |
| 181 |
|
// which is not an inconsistency: the plane that raises it guards /query alone, |
| 182 |
|
// every field of which is a read, so the action is known before the router. |
| 183 |
|
ErrMissingScope = errors.New("token does not carry the required OAuth scope") |
| 184 |
|
|
| 185 |
|
// ErrInvalidPersonalToken marks a personal access token this service will not |
| 186 |
|
// accept: the signature or the expiry did not hold, meta.sr.ht reports it |
| 187 |
|
// revoked, or it names an account meta will not resolve. 401 through |
| 188 |
|
// IsAuthFailure, beside the bearer sentinels that say the same thing about the |
| 189 |
|
// other plane. |
| 190 |
|
// |
| 191 |
|
// It is spec's own rather than a reuse of bearer.ErrInvalid because bearer's |
| 192 |
|
// sentinels are worded about tokens.sr.ht ("bearer: token was not issued by |
| 193 |
|
// tokens.sr.ht"), and for this plane every one of those words is wrong. The |
| 194 |
|
// status is what has to agree between the two planes, not the prose. |
| 195 |
|
ErrInvalidPersonalToken = errors.New("personal access token was refused") |
| 196 |
|
|
| 197 |
|
// ErrMetaUnavailable marks a personal access token that could not be |
| 198 |
|
// *checked*: the profile mirror or the revocation lookup at meta.sr.ht did not |
| 199 |
|
// answer. |
| 200 |
|
// |
| 201 |
|
// It is pointedly absent from IsAuthFailure, so StatusFor's fail-closed |
| 202 |
|
// default answers 503. "I could not decide" is not "your credential is bad", |
| 203 |
|
// and answering 401 to a meta.sr.ht restart would tell every federated client |
| 204 |
|
// on the instance to go and re-mint credentials that were never broken. |
| 205 |
|
// |
| 206 |
|
// It is not bearer.ErrUnavailable for the same reason as above, sharpened: that |
| 207 |
|
// sentinel says tokens.sr.ht could not be reached, and tokens.sr.ht can be |
| 208 |
|
// perfectly healthy while this is raised. An operator reading the wrong daemon |
| 209 |
|
// out of a log line goes and looks in the wrong place. |
| 210 |
|
ErrMetaUnavailable = errors.New("meta.sr.ht could not be reached") |
| 211 |
|
) |
| 212 |
|
|
| 213 |
|
// IsAuthFailure reports whether err is a permanent credential failure — the |
| 214 |
|
// caller should answer 401/403 — as opposed to a transient backend failure, |
| 215 |
|
// which should answer 503 and be retried. Everything not in this set is |
| 216 |
|
// transient by definition, which is the fail-closed direction: a backend outage |
| 217 |
|
// never reads as a valid credential. |
| 218 |
|
// |
| 219 |
|
// bearer.ErrNotOurs joined the set when the local plane left it. A meta.sr.ht |
| 220 |
|
// PAT used to fall through to spec's own store, where it missed; with one door |
| 221 |
|
// per plane there is nothing to fall through to, and "that credential was issued |
| 222 |
|
// by somebody whose tokens this surface does not take" is as permanent a refusal |
| 223 |
|
// as a signature that does not verify. It is still the answer everywhere but |
| 224 |
|
// /query, which routes a PAT to MetaAuth before this plane is asked and so never |
| 225 |
|
// reaches it — see graph's resolveCaller. |
| 226 |
|
// |
| 227 |
|
// ErrInvalidPersonalToken is that plane's counterpart and sits here for the same |
| 228 |
|
// reason its siblings do: what makes the two planes consistent is that a client |
| 229 |
|
// gets the same status for the same kind of failure, whichever credential it |
| 230 |
|
// presented. bearer.ErrUnavailable and ErrMetaUnavailable are both pointedly |
| 231 |
|
// absent — see StatusFor, which is what surfaces should map with. |
| 232 |
29 |
func IsAuthFailure(err error) bool { |
| 233 |
29 |
return errors.Is(err, ErrNoToken) || |
| 234 |
29 |
errors.Is(err, bearer.ErrInvalid) || |
| 235 |
29 |
errors.Is(err, bearer.ErrNotOurs) || |
| 236 |
29 |
errors.Is(err, bearer.ErrRevoked) || |
| 237 |
29 |
errors.Is(err, ErrInvalidPersonalToken) |
| 238 |
29 |
} |