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

Coverage
100.0% 28/28 statements
Δ
Blob
02d751a
Uncovered nothing — every instrumented line ran
1 package authn
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8
9 "sourcecraft.dev/bigbes/sr-ht-core/auth"
10
11 "sourcecraft.dev/bigbes/sr-ht-ecore/metapat"
12
13 "sourcecraft.dev/bigbes/sr-ht-spec/core"
14 )
15
16 // ScopeRead is the OAuth grant a meta.sr.ht personal access token must carry to
17 // read through this service: "spec.sr.ht/SPECS", at :RO or better.
18 //
19 // It is meta.sr.ht's vocabulary and it is not ActionRead spelled differently.
20 // The two grammars share a token format and nothing else: ActionRead
21 // ("spec:read") is what tokens.sr.ht seals into a working token, and no PAT can
22 // carry it because meta's personal-token page cannot spell it; ScopeRead is a
23 // checkbox on that page, and no working token can carry it because ecore's
24 // grants parser does not read that shape at all. A credential has one or the
25 // other and never both, which is why the two planes are asked different
26 // questions about the same request (Principal.Authorize, metapat.Allows).
27 //
28 // The service half is ConfigSection, so a rename breaks the build here rather
29 // than leaving behind a scope nobody can be granted. The bare half — "SPECS" —
30 // is what api-meta.json publishes, because meta prefixes the service name
31 // itself; cmd/specsrht derives that list from this constant rather than spelling
32 // it a second time, and a test on each side asserts the two agree. A scope
33 // published and not checked admits what should have been refused; one checked
34 // and not published cannot be minted at all; neither failure is visible from
35 // inside a single file.
36 const ScopeRead = ConfigSection + "/SPECS"
37
38 // MetaValidator is the sliver of sr-ht-ecore's metapat.Validator this plane
39 // needs.
40 //
41 // The interface is declared in the consumer, as BearerValidator and UserLookup
42 // are and for the same reasons: it states exactly how much of the shared
43 // validator this package depends on — one method — and it is what keeps authn
44 // testable with no meta.sr.ht, no network and no Postgres. The concrete
45 // validator reaches core-go's auth.LookupUser, which reads a database handle out
46 // of the context and panics without one; holding it behind an interface is what
47 // keeps that dependency in cmd/ where the wiring lives, exactly as this package's
48 // doc comment promises.
49 type MetaValidator interface {
50 // Resolve verifies the signature and expiry locally, refuses a token sealed
51 // by tokens.sr.ht, mirrors the owner's profile and asks meta.sr.ht whether
52 // the token has been revoked. It answers with core-go's own OAuth2 caller, or
53 // with one of the metapat package's sentinels.
54 Resolve(ctx context.Context, presented string) (*auth.AuthContext, error)
55 }
56
57 // Compile-time proof that the shared validator satisfies the port. It is what
58 // lets this package depend on the interface rather than on *metapat.Validator,
59 // and it fails the build the moment either side drifts.
60 var _ MetaValidator = (*metapat.Validator)(nil)
61
62 // MetaAuth is the meta.sr.ht plane: an ordinary personal access token, the
63 // credential every upstream service on this instance already accepts.
64 //
65 // # Why spec.sr.ht accepts one at all
66 //
67 // It did not, and the reason it does now is the gateway. api.sr.ht forwards ONE
68 // client "Authorization" header to every service a federated query touches — its
69 // AuthMiddleware copies the client's header verbatim into the request context,
70 // and the internal credential it can mint is used only to fetch schemas at
71 // startup — so a federated caller arrives holding whatever credential the client
72 // had. The only credential that works across the whole instance is a meta PAT,
73 // so an endpoint that refuses them answers 401 to the first authenticated
74 // federated query and can never be part of the gateway's schema, however correct
75 // each individual refusal looks.
76 //
77 // # Where it is and is not accepted
78 //
79 // This plane is deliberately NOT part of Resolver. It is reachable from /query
80 // alone, which is the only surface with the federation problem; the REST write
81 // plane, /mcp and the push hook keep asking for a tokens.sr.ht working token,
82 // where a narrow, short-lived, revocable grant is worth what it costs an agent to
83 // obtain. Nothing on those paths holds a MetaAuth, so nothing on them can produce
84 // a PlaneMeta principal, and a PAT is not a way around a grant.
85 //
86 // # What it is left deciding
87 //
88 // It owns no crypto and no lookups: verification is ecore's metapat, one copy of
89 // that check for every service on the instance. Three questions are this
90 // service's own and are why the type exists at all — whether the token's owner is
91 // the one human this instance answers to, whether it carries the scope, and which
92 // failures of the shared validator are a bad credential, which are a missing
93 // permission, and which are an outage.
94 type MetaAuth struct {
95 validator MetaValidator
96 owner string
97 scope string
98 }
99
100 // NewMetaAuth builds the plane over a validator, the instance owner username
101 // from [sr.ht] owner-name, and the OAuth scope a token must carry to read
102 // through this service.
103 //
104 // Each argument is refused rather than tolerated when it is empty, and the three
105 // reasons are different:
106 //
107 // - A nil validator is not the "this instance has no such plane" configuration.
108 // That one is a nil *MetaAuth, which graph.New refuses outright because the
109 // plane needs no per-instance origin and so can never be legitimately absent.
110 // A validator-less plane would fail every PAT while looking configured.
111 // - An empty owner would compare every token's account against "", so either no
112 // PAT would ever be admitted, or — worse, if the comparison were ever loosened
113 // — the check that makes this a single-owner instance would be the one that
114 // silently did nothing. NewResolver validates its owner for the same reason
115 // and with the same rule, so the two planes cannot disagree about who bigbes
116 // is.
117 // - An empty scope would have metapat.Allows asked about the grant name "",
118 // which no token carries and no meta checkbox can mint, so every PAT on the
119 // instance would be refused with a message naming a permission that does not
120 // exist.
121 25 func NewMetaAuth(validator MetaValidator, owner, scope string) (*MetaAuth, error) {
122 25 if validator == nil {
123 1 return nil, fmt.Errorf("authn: nil MetaValidator")
124 1 }
125 24 owner = strings.TrimPrefix(owner, "~")
126 24 if err := core.ValidateOwner(owner); err != nil {
127 2 return nil, fmt.Errorf("authn: meta plane instance owner: %w", err)
128 2 }
129 22 if scope == "" {
130 1 return nil, fmt.Errorf("authn: empty OAuth scope, e.g. %s", ScopeRead)
131 1 }
132 21 return &MetaAuth{validator: validator, owner: owner, scope: scope}, nil
133 }
134
135 // Scope is the OAuth grant this plane requires, for a caller that wants to name
136 // it in a refusal without spelling it a second time.
137 1 func (a *MetaAuth) Scope() string { return a.scope }
138
139 // Owner is the instance owner username this plane admits tokens for.
140 2 func (a *MetaAuth) Owner() string { return a.owner }
141
142 // VerifyToken validates a presented personal access token and returns the
143 // principal of its owner.
144 //
145 // presented is the bare credential, with the "Bearer " scheme already stripped.
146 //
147 // The principal is KindAgent, never KindOwner, even though the token belongs to
148 // the instance owner's own meta.sr.ht account. That is the security decision of
149 // this whole change: KindOwner may approve proposals and manage webhooks, and it
150 // is reached by a unified-login cookie — a browser session a human is sitting in
151 // front of — while a PAT is a bearer string any process holding it can present,
152 // forwarded through a gateway by whatever client asked. Reading one as the owner
153 // would hand the approved branch to the widest credential on the instance.
154 //
155 // It carries no Grants either: those are tokens.sr.ht's vocabulary and a PAT is
156 // in meta's, which is checked here instead — once, against the scope this plane
157 // was built with, because the whole surface it guards is a read. That is also why
158 // Principal.Authorize passes a PlaneMeta caller through: asking it for a grant a
159 // PAT can never carry would refuse every one of them.
160 //
161 // Agent identity and session are left empty, and this plane is given the
162 // credential rather than the request precisely so that they cannot be otherwise.
163 // Provenance is demanded at a write (AgentWrite.Validate) and /query performs
164 // none — its only mutations are the webhook ones, which webhookAuthorized
165 // restricts to KindOwner — so there is nothing here to attribute. A plane handed
166 // the request could also read a cookie off it, which would quietly reintroduce
167 // the ambient authority this endpoint exists without.
168 //
169 // The failures are this package's sentinels, so that graph turns them into
170 // statuses through the same StatusFor table it already uses for the other plane.
171 19 func (a *MetaAuth) VerifyToken(ctx context.Context, presented string) (Principal, error) {
172 19 if presented == "" {
173 1 return Anonymous(), ErrNoToken
174 1 }
175
176 18 ac, err := a.validator.Resolve(ctx, presented)
177 18 if err != nil {
178 12 return Anonymous(), classifyResolve(err)
179 12 }
180 6 if ac.UserID == 0 {
181 1 // metapat refuses this itself; the check is kept because the seam is an
182 1 // interface, and a validator that answered with an empty context must not
183 1 // produce a principal that owns whichever row has an unset owner id.
184 1 return Anonymous(), fmt.Errorf(
185 1 "%w: it resolved to no meta.sr.ht user id", ErrInvalidPersonalToken)
186 1 }
187
188 // The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
189 // means anything. This is the rule resolveInstanceToken applies to a working
190 // token and the cookie plane applies to a session, and it matters most here:
191 // a PAT is the credential every account on the instance can mint for itself,
192 // so without this check the widest credential in existence would be the one
193 // that skipped the narrowest identity rule, and any user of the instance could
194 // read the whole corpus through /query.
195 //
196 // It is a refusal rather than a downgrade to anonymous because a presented
197 // credential that fails must fail at the door: the asymmetry this package's
198 // doc comment draws between cookies and bearer tokens.
199 5 username := strings.TrimPrefix(ac.Username, "~")
200 5 if username != a.owner {
201 1 return Anonymous(), fmt.Errorf(
202 1 "%w: the personal access token belongs to ~%s, and this instance answers only to ~%s",
203 1 ErrNotInstanceOwner, username, a.owner)
204 1 }
205
206 4 if !metapat.Allows(ac, a.scope, auth.RO) {
207 1 return Anonymous(), fmt.Errorf(
208 1 "%w: this personal access token does not carry %s", ErrMissingScope, a.scope)
209 1 }
210
211 3 return Principal{
212 3 Kind: KindAgent,
213 3 Owner: a.owner,
214 3 // Diagnostics only, as on the other plane. There is no row id to print:
215 3 // meta.sr.ht does not number a PAT the way tokens.sr.ht numbers a working
216 3 // token, and naming the plane is the useful half anyway — it says which of
217 3 // two credentials a log line is about.
218 3 TokenName: "meta.sr.ht personal access token",
219 3 Plane: PlaneMeta,
220 3 UserID: ac.UserID,
221 3 }, nil
222 }
223
224 // classifyResolve maps metapat's sentinels onto this package's, and is the one
225 // place spec.sr.ht decides what each refusal of the shared PAT validator means
226 // here.
227 //
228 // It is deliberately the same shape as the tokens.sr.ht plane's mapping, arm for
229 // arm — resolveInstanceToken wraps bearer's sentinels and StatusFor reads them —
230 // because the two planes owe the surfaces above the same three answers, 401, 403
231 // and 503. A difference between the tables would be a difference in what a client
232 // is told about the same kind of failure, decided by which credential it happened
233 // to be holding.
234 //
235 // metapat.ErrNotOurs is classified for totality and is not reachable in
236 // production: graph routes on metapat.PlaneOf before this plane is asked, so a
237 // working token has already gone to the other one. Were it ever to arrive here it
238 // is a bad credential *for this plane*, and 401 is the honest answer.
239 //
240 // An unrecognised error is ErrMetaUnavailable, and that is the fail-closed
241 // direction rather than a shrug: a sentinel this table has never seen must read
242 // as "I could not decide" — a 503 the caller retries — and never as a verdict
243 // about the credential. Answering 401 to something this function does not
244 // understand would tell a client to re-mint a token that may be perfectly good.
245 12 func classifyResolve(err error) error {
246 12 switch {
247 case errors.Is(err, metapat.ErrInvalid),
248 errors.Is(err, metapat.ErrNotOurs),
249 6 errors.Is(err, metapat.ErrRevoked):
250 6 return fmt.Errorf("%w: %w", ErrInvalidPersonalToken, err)
251 2 case errors.Is(err, metapat.ErrForbidden):
252 2 // Not reachable through Resolve, which is never told a scope; classified
253 2 // so that the table is total.
254 2 return fmt.Errorf("%w: %w", ErrMissingScope, err)
255 2 case errors.Is(err, metapat.ErrUnavailable):
256 2 // meta.sr.ht and not tokens.sr.ht, which is the entire reason this
257 2 // sentinel exists beside bearer.ErrUnavailable rather than reusing it.
258 2 return fmt.Errorf("%w: %w", ErrMetaUnavailable, err)
259 2 default:
260 2 return fmt.Errorf("%w: validating a meta.sr.ht personal access token: %w",
261 2 ErrMetaUnavailable, err)
262 }
263 }