~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/authn/doc.go -rw-r--r-- 13.0 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
// Package authn answers one question for spec.sr.ht — "who is making this
// request?" — and builds the git provenance that answers "who made this write?"
// forever after.
//
// The design has exactly two principals that carry authority:
//
//   - the human owner, recognised by the shared `sr.ht.unified-login.v1`
//     cookie carrying the instance's [sr.ht] owner-name;
//   - an agent, recognised by a bearer token.
//
// Everything else is anonymous. Single-user does not mean "no authorization";
// it relocates it onto agents, which is why Principal distinguishes those two
// and nothing finer. The boundary that bounds an agent's damage is the refs rule
// (agents may only write proposals/*), and that lives in gitx; nothing here
// replaces it.
//
// # Two agent credential planes, and one surface that takes the second
//
// An agent is recognised by a tokens.sr.ht working token — PlaneInstance:
// signed by the instance, expiring, owned by a meta.sr.ht account, and carrying
// a grant set (ActionPropose, ActionRead). It is validated by sr-ht-ecore's
// bearer package, and it is the credential of every surface this service has:
// the REST write plane, /mcp, the machine formats of the read plane, and the
// `git push` hook path.
//
// /query takes a second one — PlaneMeta, an ordinary meta.sr.ht personal access
// token scoped by ScopeRead — and it takes it because of api.sr.ht rather than
// because a second credential is desirable. The gateway forwards ONE client
// Authorization header to every service a federated query touches: its
// AuthMiddleware copies the client's header verbatim into the request context,
// and the internal credential it can mint is used only to fetch schemas at
// startup. So a federated caller arrives here holding whatever credential the
// client had, and the only credential a client can hold that works across the
// whole instance is a meta PAT. An endpoint that refuses one can never be
// federated: adding it to the gateway would be one `api-origin=` line that
// produces 401s.
//
// The scope of that exception is narrow and is kept narrow structurally rather
// than by discipline. MetaAuth is not part of Resolver, so nothing that resolves
// identity through Resolver can produce a PlaneMeta principal — graph builds the
// plane itself, wired from cmd/specsrht at the single call site that may use it,
// and no other surface holds one. A personal access token is therefore not a way
// around the tokens.sr.ht grant an upload, an MCP tool or a push requires.
//
// spec used to mint its own credential as well — the agent_token row: one
// instance-wide shared secret, hashed at rest, with no owner, no expiry and no
// grants. That plane is gone. Issuance is centralised — tokens.sr.ht mints a
// working token, meta.sr.ht mints a PAT — so each plane has one door and nothing
// behind it: a credential a plane refuses is refused, rather than being offered
// to a second store that might say yes. A meta PAT presented to any surface but
// /query is bearer.ErrNotOurs and fails at that door, which is the same answer
// one hash lookup later, said honestly.
//
// Both credential planes name an owner where the local secret had none.
// Principal.Owner means "the human this agent acts for", which on this
// single-owner instance is always [sr.ht] owner-name — a token belonging to
// anybody else is refused rather than admitted as a second identity, because
// every consumer of that field (the provenance committer, the refs rule's
// principal kind, the coreauth AuthContext) is written for one human. That rule
// is why MetaAuth is told the owner too: a PAT is the credential every account on
// the instance can mint, so without it the widest possible credential would be
// the one that skipped the narrowest check.
//
// Grants are orthogonal to the refs rule and to provenance, and replace neither.
// A grant says what an instance token was minted for; the refs rule still says
// where an agent may point a ref, and provenance is still mandatory on every
// agent write.
//
// The cookie and the bearer planes are deliberately asymmetric:
//
//   - A cookie that is missing, forged, expired or unreadable yields an
//     anonymous principal and never an error. Browsing must keep working.
//   - A bearer token that is present but unknown, revoked or corrupt is a hard
//     failure. An agent that presented an explicit credential must not be
//     silently downgraded to a reader; it would then fail confusingly at the
//     write instead of clearly at the door.
//
// Provenance is the other half. Agent identity and session ID are mandatory on
// every agent write, and are recorded in the commit itself so that a plain
// `git log` on any clone carries the audit trail:
//
//	Author:    claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
//	Committer: bigbes <bigbes@gmail.com>
//
//	    Add storage model section
//
//	    X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
//	    X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809
//
// A write missing either field is rejected rather than defaulted: a commit
// stamped with a guessed session is worse than no commit, because it launders
// unattributable output as attributed.
//
// This package owns no storage and opens no connections. The "user" row an
// instance token's owner resolves to is reached through UserLookup, and the
// tokens.sr.ht validator through BearerValidator. authn never imports db and
// never calls core-go's auth.LookupUser itself, so the dependency arrow keeps
// pointing downward and the whole package stays testable with no Postgres and no
// daemon to talk to.
package authn

import (
	"errors"

	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
)

// Sentinel errors. Callers compare with errors.Is. The split that matters is
// permanent (the credential is bad — 401/403) versus transient (the backend
// could not answer — 503); IsAuthFailure draws it.
//
// Everything a working token can be wrong about is spelled by sr-ht-ecore's
// bearer package — ErrInvalid, ErrNotOurs, ErrRevoked — because that plane has
// one issuer and one validator. The sentinels below are what this service adds
// on top of that answer, and they now include the meta plane's three: its
// validator's refusals are metapat's, and its prose has to name meta.sr.ht where
// bearer's names tokens.sr.ht, while the status each one maps to is deliberately
// identical. See ScopeRead and MetaAuth.
var (
	// ErrNoToken is returned when a bearer credential was expected but the
	// request carried no Authorization header, or one in another scheme.
	ErrNoToken = errors.New("no agent token presented")

	// ErrNoAgentPlane is returned when a bearer credential is presented to a
	// resolver that was built without the tokens.sr.ht plane — an instance whose
	// config.ini has no [tokens.sr.ht] origin. It is a wiring failure and not a
	// credential failure, so it is deliberately not an IsAuthFailure: telling an
	// agent its token is bad when the truth is that this service cannot check
	// any token would send it off to re-provision a perfectly good credential.
	ErrNoAgentPlane = errors.New("no agent credential plane is configured")

	// ErrNotAgent is returned when agent provenance is demanded of a principal
	// that is not an agent — the human push path builds no trailers.
	ErrNotAgent = errors.New("principal is not an agent")

	// ErrMissingProvenance marks an agent write that omits the agent identity,
	// the session ID, or the base revision. Mandatory on every agent write; the
	// design is explicit that these are not defaultable.
	ErrMissingProvenance = errors.New("missing agent provenance")

	// ErrInvalidProvenance marks provenance whose values are present but
	// unusable: control characters or angle brackets that would forge a git
	// signature line or inject an extra trailer, an over-long field, or a base
	// revision that is not a hex object name.
	ErrInvalidProvenance = errors.New("invalid agent provenance")

	// ErrMissingConfig is returned by InstanceFromConfig when the instance
	// config lacks a key the provenance identities are built from. It is a
	// startup failure, not a request failure.
	ErrMissingConfig = errors.New("missing instance config key")

	// ErrNotInstanceOwner marks a valid tokens.sr.ht working token whose owner is
	// somebody other than the instance owner. 403: the credential verifies and
	// the holder is who they say they are, there is simply nothing on this
	// single-owner instance to grant them. Deliberately not an IsAuthFailure —
	// presenting it again will not help and neither will logging in.
	ErrNotInstanceOwner = errors.New("token owner is not the instance owner")

	// ErrMissingGrant marks an instance token that authenticated fine but does
	// not carry the action being attempted. 403, for the reason
	// bearer.ErrForbidden is: what the holder needs is a wider grant, not another
	// login.
	//
	// It is raised by Principal.Authorize, at the layer that knows the action —
	// never by the resolver, which runs before the router and so knows none.
	ErrMissingGrant = errors.New("token does not grant this action")

	// ErrMissingScope marks a meta.sr.ht personal access token that authenticated
	// fine and was not minted to read through this service: it does not carry
	// ScopeRead. 403, exactly as ErrMissingGrant is and for the same reason.
	//
	// It is deliberately not ErrMissingGrant. The two name permissions in
	// vocabularies that do not overlap — no PAT can carry "spec:read", because
	// meta's personal-token page cannot spell it, and no working token can carry
	// "spec.sr.ht/SPECS", because ecore's grants grammar does not read that shape
	// — so a refusal has to name the one the caller can actually go and obtain.
	// A PAT holder sent looking for "spec:read" would be hunting a checkbox that
	// does not exist.
	//
	// Unlike ErrMissingGrant it is raised at resolution rather than at the router,
	// which is not an inconsistency: the plane that raises it guards /query alone,
	// every field of which is a read, so the action is known before the router.
	ErrMissingScope = errors.New("token does not carry the required OAuth scope")

	// ErrInvalidPersonalToken marks a personal access token this service will not
	// accept: the signature or the expiry did not hold, meta.sr.ht reports it
	// revoked, or it names an account meta will not resolve. 401 through
	// IsAuthFailure, beside the bearer sentinels that say the same thing about the
	// other plane.
	//
	// It is spec's own rather than a reuse of bearer.ErrInvalid because bearer's
	// sentinels are worded about tokens.sr.ht ("bearer: token was not issued by
	// tokens.sr.ht"), and for this plane every one of those words is wrong. The
	// status is what has to agree between the two planes, not the prose.
	ErrInvalidPersonalToken = errors.New("personal access token was refused")

	// ErrMetaUnavailable marks a personal access token that could not be
	// *checked*: the profile mirror or the revocation lookup at meta.sr.ht did not
	// answer.
	//
	// It is pointedly absent from IsAuthFailure, so StatusFor's fail-closed
	// default answers 503. "I could not decide" is not "your credential is bad",
	// and answering 401 to a meta.sr.ht restart would tell every federated client
	// on the instance to go and re-mint credentials that were never broken.
	//
	// It is not bearer.ErrUnavailable for the same reason as above, sharpened: that
	// sentinel says tokens.sr.ht could not be reached, and tokens.sr.ht can be
	// perfectly healthy while this is raised. An operator reading the wrong daemon
	// out of a log line goes and looks in the wrong place.
	ErrMetaUnavailable = errors.New("meta.sr.ht could not be reached")
)

// IsAuthFailure reports whether err is a permanent credential failure — the
// caller should answer 401/403 — as opposed to a transient backend failure,
// which should answer 503 and be retried. Everything not in this set is
// transient by definition, which is the fail-closed direction: a backend outage
// never reads as a valid credential.
//
// bearer.ErrNotOurs joined the set when the local plane left it. A meta.sr.ht
// PAT used to fall through to spec's own store, where it missed; with one door
// per plane there is nothing to fall through to, and "that credential was issued
// by somebody whose tokens this surface does not take" is as permanent a refusal
// as a signature that does not verify. It is still the answer everywhere but
// /query, which routes a PAT to MetaAuth before this plane is asked and so never
// reaches it — see graph's resolveCaller.
//
// ErrInvalidPersonalToken is that plane's counterpart and sits here for the same
// reason its siblings do: what makes the two planes consistent is that a client
// gets the same status for the same kind of failure, whichever credential it
// presented. bearer.ErrUnavailable and ErrMetaUnavailable are both pointedly
// absent — see StatusFor, which is what surfaces should map with.
func IsAuthFailure(err error) bool {
	return errors.Is(err, ErrNoToken) ||
		errors.Is(err, bearer.ErrInvalid) ||
		errors.Is(err, bearer.ErrNotOurs) ||
		errors.Is(err, bearer.ErrRevoked) ||
		errors.Is(err, ErrInvalidPersonalToken)
}