coverage~bigbes/sr-ht-spec64cae3afgraph/server.go

Coverage
95.0% 57/60 statements
Δ
+0.7
Blob
f4c059b
1 // Package graph is spec.sr.ht's GraphQL read schema, served at /query.
2 //
3 // # Read only, deliberately
4 //
5 // There are no proposal mutations here. The design defers them until the
6 // proposal state machine has settled, and the reason is technical rather than
7 // scope discipline: the write plane's concurrency story is `If-Match:
8 // <base-rev>`, an HTTP idiom with well-defined 409 semantics that agents get
9 // right by default, and a type that has been federated into api.sr.ht is a
10 // consumed contract — expensive to churn. Read types (space, document, project,
11 // search) are stable from the start; the review types are not, and that is
12 // where the line is drawn. The webhook management mutations are the exception
13 // and are not a proposal write: core-go's webhook engine is GraphQL-native and
14 // has no other surface.
15 //
16 // Serving GraphQL at all is justified without federation: Phase 5's webhooks
17 // are GraphQL-native so gqlgen arrives regardless, and this is the read surface
18 // anything on the instance that already speaks SourceHut GraphQL can consume.
19 // Federating into api.sr.ht is then one `api-origin=` line on the gateway that
20 // nothing here depends on — `hut` builds its endpoint from the per-service
21 // origin and talks to this /query directly either way.
22 //
23 // # Who may read, and with what
24 //
25 // The read plane is fail-closed and this is the same one-line ACL web/ and
26 // mcpsrv/ apply: the instance owner's agents may read, and nobody else may. A
27 // viewer with no read authority gets 401 before the query is parsed — including
28 // for introspection, which is why a gateway federating this schema has to
29 // present a token like any other client.
30 //
31 // The credential is a bearer token, and this is the one surface of this service
32 // that takes either of the instance's two:
33 //
34 // - A tokens.sr.ht working token, verified through sr-ht-ecore's bearer
35 // package by authn.Resolver, owned by [sr.ht] owner-name, and carrying
36 // authn.ActionRead. A token that verifies but does not carry that grant is
37 // 403; one that does not verify is 401 with the bearer challenge.
38 // - A meta.sr.ht personal access token, verified through sr-ht-ecore's metapat
39 // package by authn.MetaAuth, owned by that same [sr.ht] owner-name, and
40 // carrying authn.ScopeRead — the same permission in meta's OAuth vocabulary
41 // rather than in tokens.sr.ht's. The statuses are the other plane's arm for
42 // arm; what differs is the permission a 403 names, because the two
43 // vocabularies do not overlap.
44 // - No cookie. An API client is not a browser. The unified-login cookie is
45 // web/'s plane, and this endpoint is deliberately outside it — so the
46 // principal is overwritten with the anonymous one when no bearer credential
47 // is presented, rather than inherited from whatever middleware happens to
48 // sit above the mount point.
49 //
50 // # Why this surface takes a meta PAT when no other one does
51 //
52 // Because of the gateway, and for no other reason. api.sr.ht forwards ONE client
53 // "Authorization" header to every service a federated query touches — its
54 // AuthMiddleware copies the client's header verbatim into the request context,
55 // and the internal credential it can mint is used only to fetch schemas at
56 // startup — so a federated caller arrives here holding whatever credential the
57 // client had. The only credential that works across the whole instance is a meta
58 // PAT, so an endpoint that refuses them can never be part of the gateway's
59 // schema: federating it would be one `api-origin=` line that produced 401s.
60 //
61 // The REST write plane, /mcp and the push hook keep exactly one plane,
62 // tokens.sr.ht's, because they have no such problem and a narrow, revocable,
63 // short-lived grant is worth its cost there. They resolve identity through
64 // authn.Resolver, which holds no MetaAuth and therefore cannot produce an
65 // authn.PlaneMeta principal at all — the scope of the exception is structural
66 // rather than a convention to remember.
67 //
68 // A PAT admitted here is an agent and never the owner, so webhook management
69 // stays out of its reach: those mutations ask authn.Principal.IsOwner, which only
70 // the unified-login cookie satisfies and which this endpoint reads none of.
71 //
72 // # What the cmd layer wires
73 //
74 // gql, err := graph.New(graph.Options{
75 // Reader: svc, // *service.Service
76 // Searcher: index, // *search.Index
77 // Resolver: svc.Resolver(),
78 // Meta: metaAuth, // *authn.MetaAuth, this surface alone
79 // })
80 // if err != nil {
81 // return err
82 // }
83 // router.Handle("/query", gql)
84 //
85 // Server installs its own credential middleware, so it goes on the *anonymous*
86 // router: core-go's server.WithSchema would mount it on the authenticated one,
87 // whose auth.Middleware speaks meta's OAuth vocabulary and 401s anything else —
88 // including the working token every other surface of this service takes. Serving
89 // both planes from one endpoint is exactly what that middleware cannot do, which
90 // is why this package resolves the credential itself. A service that mounts its
91 // own /query owes the instance api-meta.json as well, because core-go serves that
92 // file only for the schemas it hosts itself; sr-ht-ecore's apimeta package is
93 // what serves it, and cmd/specsrht wires it beside the route with the scope list
94 // GrantScopes derives from authn.ScopeRead.
95 //
96 // The router it is mounted on must carry core-go's config and database
97 // middleware. The read path does not need either, but the webhook management
98 // resolvers open transactions through core-go's database context, and
99 // WithDefaultMiddleware installs that on the authenticated router only.
100 package graph
101
102 import (
103 "errors"
104 "fmt"
105 "log/slog"
106 "net/http"
107
108 "github.com/99designs/gqlgen/graphql"
109 "github.com/99designs/gqlgen/graphql/handler"
110 "github.com/99designs/gqlgen/graphql/handler/extension"
111 "github.com/99designs/gqlgen/graphql/handler/transport"
112 "go.bigb.es/auxilia/scribe"
113
114 "sourcecraft.dev/bigbes/sr-ht-ecore/metapat"
115
116 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
117 "sourcecraft.dev/bigbes/sr-ht-spec/coreauth"
118 "sourcecraft.dev/bigbes/sr-ht-spec/graph/api"
119 )
120
121 // GrantScopes is what this service publishes in api-meta.json: the scopes a
122 // meta.sr.ht personal access token can be minted for here.
123 //
124 // meta.sr.ht reads that file to build the checkboxes of its personal-token page,
125 // prefixing each entry with the service's own name — so "SPECS" here is what
126 // makes "spec.sr.ht/SPECS:RO" a token a human can actually obtain. An empty list,
127 // which this service published until /query started accepting a PAT, does not
128 // mean "a service with no scopes": it means no PAT can be scoped for this service
129 // at all, so the credential the rest of the instance uses could never be
130 // presented here even in principle. That was the stronger half of the refusal,
131 // and the half no amount of code in this package could have worked around.
132 //
133 // It is derived from authn.ScopeRead rather than spelled a second time, because
134 // the two spellings must agree: what meta's checkbox mints, and what
135 // MetaAuth.VerifyToken checks. A scope published and not checked admits what
136 // should have been refused, one checked and not published cannot be minted at
137 // all, and neither failure is visible from inside a single file — so a test on
138 // each side asserts the pair.
139 //
140 // This is meta.sr.ht's vocabulary. The other plane's permission is
141 // authn.ActionRead ("spec:read"), in tokens.sr.ht's; the two grammars share a
142 // token format and nothing else (sr-ht-ecore's grants and metapat packages).
143 var GrantScopes = []string{metapat.ScopeName(authn.ScopeRead)}
144
145 // Options is everything a Server needs. New says which one is missing rather
146 // than failing later inside a resolver.
147 type Options struct {
148 // Reader is the orchestration layer. *service.Service satisfies it.
149 Reader Reader
150
151 // Searcher is the one global keyword index. *search.Index satisfies it.
152 Searcher Searcher
153
154 // Proposals is the proposal read side. It has no production implementation
155 // yet — see the Proposals interface — so it is the one optional field here,
156 // and while it is nil the `proposals` field of the schema fails with an
157 // error saying so rather than answering "none".
158 Proposals Proposals
159
160 // Resolver verifies a presented tokens.sr.ht working token. Its cookie plane
161 // is not used here: see the package comment.
162 Resolver *authn.Resolver
163
164 // Meta verifies a presented meta.sr.ht personal access token — the plane that
165 // exists so this endpoint can be federated into api.sr.ht at all (see
166 // authn.MetaAuth).
167 //
168 // Required, and unlike the working-token plane it needs no configuration to be
169 // available: it depends on no per-instance origin, only on the meta.sr.ht
170 // every SourceHut service already talks to. So there is no legitimate "this
171 // instance has no such plane" case to tolerate a nil for, and an endpoint
172 // built without it would answer 401 to the gateway while looking configured.
173 Meta MetaAuthenticator
174 }
175
176 // Server is the /query endpoint: the executable schema behind the credential
177 // gate. It is built once at startup and is safe for concurrent use.
178 type Server struct {
179 http http.Handler
180 schema graphql.ExecutableSchema
181 }
182
183 // newSchema builds the executable schema over the seams in opts.
184 //
185 // It is unexported now that nothing outside this package wants a schema without
186 // an endpoint. The daemon needs both — the endpoint to serve /query, and the
187 // schema to hand to webhooks.NewQueue, which executes a subscription's stored
188 // query at delivery time — and takes them from one Server, so that the two
189 // cannot become two schemas.
190 55 func newSchema(opts Options) (graphql.ExecutableSchema, error) {
191 55 if opts.Reader == nil {
192 1 return nil, fmt.Errorf("graph: Reader is required")
193 1 }
194 54 if opts.Searcher == nil {
195 1 return nil, fmt.Errorf("graph: Searcher is required")
196 1 }
197 53 root := &Resolver{
198 53 reader: opts.Reader,
199 53 searcher: opts.Searcher,
200 53 proposals: opts.Proposals,
201 53 }
202 53 schema := api.NewExecutableSchema(api.Config{Resolvers: root})
203 53 // The root resolver holds the schema it is part of. The knot is deliberate:
204 53 // the webhook resolvers validate and execute a subscriber's stored query
205 53 // against this service's schema, and used to reach it through core-go's
206 53 // server context — which exists on the authenticated router and nowhere
207 53 // else. Holding it here is what lets /query move to the anonymous router
208 53 // without those resolvers reaching for a context value that is not there.
209 53 root.schema = schema
210 53 return schema, nil
211 }
212
213 // New assembles the /query endpoint over the seams in opts.
214 57 func New(opts Options) (*Server, error) {
215 57 if opts.Resolver == nil {
216 1 return nil, fmt.Errorf("graph: authn Resolver is required")
217 1 }
218 // The seam whose absence would not announce itself: an endpoint with no PAT
219 // plane starts, serves every working token exactly as before, and answers 401
220 // to every federated query — a failure visible only from the gateway.
221 56 if opts.Meta == nil {
222 1 return nil, fmt.Errorf("graph: MetaAuthenticator is required")
223 1 }
224 55 schema, err := newSchema(opts)
225 55 if err != nil {
226 2 return nil, err
227 2 }
228
229 // The transport set is core-go's, not gqlgen's NewDefaultServer: POST and
230 // introspection, and nothing else. NewDefaultServer would also install GET,
231 // multipart upload and a websocket transport — a second way in, a file
232 // upload path for a schema with no Upload scalar, and a subscription
233 // transport for a schema with no subscriptions. Every SourceHut service on
234 // this instance answers /query over POST, so a client that works against
235 // one works against this.
236 53 exec := handler.New(schema)
237 53 exec.AddTransport(transport.POST{})
238 53 exec.Use(extension.Introspection{})
239 53
240 53 return &Server{
241 53 schema: schema,
242 53 http: resolveCaller(opts.Resolver, opts.Meta, gate(coreContext(exec))),
243 53 }, nil
244 }
245
246 // Schema is the executable schema this endpoint serves. The daemon hands it to
247 // webhooks.NewQueue so that the query a subscriber stored is executed against
248 // exactly the schema they wrote it for.
249 0 func (s *Server) Schema() graphql.ExecutableSchema { return s.schema }
250
251 // ServeHTTP serves /query behind the chain New built.
252 75 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) }
253
254 // resolveCaller turns the presented bearer credential into this request's
255 // principal, or refuses the request. It is the whole credential plane of this
256 // endpoint, and it is authn.Resolver's bearer arm and not its Middleware:
257 // Middleware also reads the unified-login cookie, and a cookie is not a
258 // credential here.
259 //
260 // A request with no Authorization header is given the anonymous principal
261 // explicitly rather than being passed through untouched. That overwrite is the
262 // "no cookie" rule made structural: mounted under a router that already
263 // resolved a cookie identity, this endpoint still sees anonymous and still
264 // answers 401.
265 //
266 // # Two planes, and which one gets the request
267 //
268 // This is where spec.sr.ht's /query differs from every other surface it has, and
269 // the difference is api.sr.ht's. The gateway forwards ONE client "Authorization"
270 // header to every service a federated query touches, so a federated caller
271 // arrives holding whatever credential the client had — in practice a meta.sr.ht
272 // personal access token, the only credential that works instance-wide. An
273 // endpoint that refused those could not be federated at all, however correct its
274 // refusal looked.
275 //
276 // So the plane is chosen by what was presented, with metapat.PlaneOf, which
277 // decodes locally and asks no daemon anything:
278 //
279 // PlaneMeta the meta.sr.ht plane, scoped by authn.ScopeRead in meta's own
280 // OAuth vocabulary
281 // PlaneWorking the tokens.sr.ht plane, scoped by authn.ActionRead
282 // PlaneUnknown the tokens.sr.ht plane as well — the credential is unreadable
283 // (forged, corrupt or expired: DecodeBearerToken checks expiry
284 // before it reports an issuer), both planes owe it the same 401,
285 // and routing it to the one that already words that refusal
286 // keeps one message rather than two
287 //
288 // Routing on the credential rather than on the failure of one plane is what lets
289 // the meta plane work on an instance whose config.ini has no [tokens.sr.ht]
290 // section at all: there is no working-token validator there to fail first, only
291 // an authn.ErrNoAgentPlane that would have to be told apart from a real refusal.
292 //
293 // # What each plane is then asked
294 //
295 // Both answer the same three questions and only the vocabulary differs. Is the
296 // credential live; does it belong to the one human this instance answers to; was
297 // it minted for reading. The last is asked at different moments — the working
298 // plane's grant in gate below, where the action is known, the PAT's scope inside
299 // VerifyToken, because that plane guards this one read surface and nothing else —
300 // and that is the only asymmetry between them.
301 //
302 // The statuses are authn.StatusFor's, which is the one table this service maps a
303 // credential failure with, and it is one table across both planes on purpose:
304 // 401 for a credential that does not verify — forged, expired, revoked, or issued
305 // by somebody else — 403 for one that verifies and belongs to a human this
306 // single-owner instance has nothing to grant, and 503 for a credential that could
307 // not be *checked*. The last is not "your token is bad": answering 401 to a
308 // restart of tokens.sr.ht, or to a meta.sr.ht that will not answer, tells every
309 // agent to re-mint credentials that were never broken.
310 //
311 // The messages are written here from what the caller already knows, never from
312 // the error's own text: authn's errors name usernames and token ids.
313 53 func resolveCaller(rs *authn.Resolver, meta MetaAuthenticator, next http.Handler) http.Handler {
314 75 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
315 75 presented := authn.BearerFromRequest(r)
316 75 if presented == "" {
317 8 next.ServeHTTP(w, r.WithContext(
318 8 authn.WithPrincipal(r.Context(), authn.Anonymous())))
319 8 return
320 8 }
321
322 67 var (
323 67 p authn.Principal
324 67 err error
325 67 )
326 67 if metapat.PlaneOf(presented) == metapat.PlaneMeta {
327 9 // The credential and not the request: a plane that cannot see the
328 9 // request cannot read a cookie off it, and the provenance headers are
329 9 // a write's business, which this surface has none of. See
330 9 // MetaAuthenticator.
331 9 p, err = meta.VerifyToken(r.Context(), presented)
332 58 } else {
333 58 p, err = rs.ResolveAgent(r.Context(), presented,
334 58 r.Header.Get(authn.HeaderAgent), r.Header.Get(authn.HeaderAgentSession))
335 58 }
336 67 if err != nil {
337 9 status := authn.StatusFor(err)
338 9 if status >= http.StatusInternalServerError {
339 0 // Fail closed and loudly. The alternative — degrading to
340 0 // anonymous — would turn an unreachable tokens.sr.ht into every
341 0 // agent silently losing its read access.
342 0 slog.ErrorContext(r.Context(), "a bearer credential could not be checked",
343 0 "component", "graph", "path", r.URL.Path, "status", status, scribe.Err(err))
344 0 }
345 9 if status == http.StatusUnauthorized {
346 4 // RFC 9110 requires the challenge on a 401, and every caller
347 4 // here is a machine holding a bearer token: naming the scheme
348 4 // and the realm is what tells it which credential was refused.
349 4 w.Header().Set("WWW-Authenticate", authn.Challenge())
350 4 }
351 9 http.Error(w, refusalMessage(status, err), status)
352 9 return
353 }
354 58 next.ServeHTTP(w, r.WithContext(authn.WithPrincipal(r.Context(), p)))
355 })
356 }
357
358 // refusalMessage is what a refused caller is told.
359 //
360 // It is keyed on the status, so that nothing about whose token it was, or whether
361 // a row exists, leaks to a caller holding a credential this service did not
362 // accept. The one thing it reads off the error is authn.ErrMissingScope, and that
363 // is not a hole in the rule: what it adds is a compile-time constant naming a
364 // permission the caller can go and tick a box for, which is precisely what gate
365 // below already does for the other plane's grant. It says nothing about the
366 // credential, the account or the database.
367 //
368 // Without that arm a PAT missing the scope would be told "this token does not
369 // authorize requests to spec.sr.ht" and have nowhere to go: meta's personal-token
370 // page is a list of checkboxes, and a client that is not told which one it lacks
371 // cannot ask for it.
372 9 func refusalMessage(status int, err error) string {
373 9 switch status {
374 4 case http.StatusUnauthorized:
375 4 return "the bearer token presented was refused"
376 5 case http.StatusForbidden:
377 5 if errors.Is(err, authn.ErrMissingScope) {
378 3 return "this personal access token does not carry " + authn.ScopeRead
379 3 }
380 2 return "this token does not authorize requests to " + authn.ConfigSection
381 0 default:
382 0 return "the credential could not be verified, try again"
383 }
384 }
385
386 // gate refuses a caller with no read authority before the query is parsed.
387 //
388 // The ACL is authn.Principal.CanRead — the owner and its agents may read and
389 // nobody else may — the same predicate web/ and mcpsrv/ apply, so the three read
390 // surfaces cannot drift into three policies, which is how a corpus leaks. On
391 // this endpoint the owner half of it is unreachable in practice: the owner is
392 // recognised by a cookie, and resolveCaller above accepts none. It is asked
393 // anyway because it is the shared predicate and not this endpoint's own.
394 //
395 // A caller that clears it and authenticated with a tokens.sr.ht working token
396 // must also hold spec:read — the grant half of the same question, asked here
397 // because here is where the action ("read") is known.
398 //
399 // A caller on the meta.sr.ht plane passes that half untouched, and has already
400 // answered it. Principal.Authorize is a no-op for authn.PlaneMeta because no PAT
401 // can carry "spec:read" at all — asking would refuse every one of them — so the
402 // equivalent question was put to it in meta's own vocabulary at resolution, where
403 // authn.ScopeRead is what it had to carry. The permission is checked exactly once
404 // on either plane; only the moment and the grammar differ.
405 //
406 // It is one check at the boundary rather than one per field, because every read
407 // field of this schema is a read and the surface has one action. The webhook
408 // mutations must NOT rely on it: they would be admitted by a read grant, which
409 // is not what a read grant says, so they carry their own owner gate in the
410 // resolver.
411 //
412 // The refusal is a 401 — or a 403 for the missing grant — with a line of text
413 // and never a redirect to meta's login: every caller here is a machine, and
414 // handing a bot 200 and a page of login markup tells it nothing it can act on.
415 // The two statuses stay apart because retrying is worth it for one and never for
416 // the other.
417 59 func gate(next http.Handler) http.Handler {
418 72 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
419 72 p := authn.PrincipalFromContext(r.Context())
420 72 if !p.CanRead() {
421 9 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
422 9 w.Header().Set("WWW-Authenticate", authn.Challenge())
423 9 http.Error(w, "authentication required", http.StatusUnauthorized)
424 9 return
425 9 }
426 63 if err := p.Authorize(authn.ActionRead); err != nil {
427 2 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
428 2 http.Error(w, "this token does not grant "+authn.ActionRead, http.StatusForbidden)
429 2 return
430 2 }
431 61 next.ServeHTTP(w, r)
432 })
433 }
434
435 // coreContext derives core-go's AuthContext from the principal the gate has
436 // already admitted, because core-go's webhook engine reads one out of the
437 // context and this endpoint no longer runs behind the middleware that puts it
438 // there.
439 //
440 // The user id is the credential's own: authn resolved the token's owner to a
441 // local row and refused the token outright if that row had no id, so there is
442 // nothing to substitute and no default to invent here.
443 53 func coreContext(next http.Handler) http.Handler {
444 57 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
445 57 p := authn.PrincipalFromContext(r.Context())
446 57 next.ServeHTTP(w, r.WithContext(coreauth.Context(r.Context(), p, p.UserID)))
447 57 })
448 }