coverage~bigbes/sr-ht-spec64cae3afauthn/doc.go

Coverage
100.0% 1/1 statements
Δ
+0.0
Blob
455bc48
Uncovered nothing — every instrumented line ran
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 }