~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/graph/server.go -rw-r--r-- 21.6 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
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
// Package graph is spec.sr.ht's GraphQL read schema, served at /query.
//
// # Read only, deliberately
//
// There are no proposal mutations here. The design defers them until the
// proposal state machine has settled, and the reason is technical rather than
// scope discipline: the write plane's concurrency story is `If-Match:
// <base-rev>`, an HTTP idiom with well-defined 409 semantics that agents get
// right by default, and a type that has been federated into api.sr.ht is a
// consumed contract — expensive to churn. Read types (space, document, project,
// search) are stable from the start; the review types are not, and that is
// where the line is drawn. The webhook management mutations are the exception
// and are not a proposal write: core-go's webhook engine is GraphQL-native and
// has no other surface.
//
// Serving GraphQL at all is justified without federation: Phase 5's webhooks
// are GraphQL-native so gqlgen arrives regardless, and this is the read surface
// anything on the instance that already speaks SourceHut GraphQL can consume.
// Federating into api.sr.ht is then one `api-origin=` line on the gateway that
// nothing here depends on — `hut` builds its endpoint from the per-service
// origin and talks to this /query directly either way.
//
// # Who may read, and with what
//
// The read plane is fail-closed and this is the same one-line ACL web/ and
// mcpsrv/ apply: the instance owner's agents may read, and nobody else may. A
// viewer with no read authority gets 401 before the query is parsed — including
// for introspection, which is why a gateway federating this schema has to
// present a token like any other client.
//
// The credential is a bearer token, and this is the one surface of this service
// that takes either of the instance's two:
//
//   - A tokens.sr.ht working token, verified through sr-ht-ecore's bearer
//     package by authn.Resolver, owned by [sr.ht] owner-name, and carrying
//     authn.ActionRead. A token that verifies but does not carry that grant is
//     403; one that does not verify is 401 with the bearer challenge.
//   - A meta.sr.ht personal access token, verified through sr-ht-ecore's metapat
//     package by authn.MetaAuth, owned by that same [sr.ht] owner-name, and
//     carrying authn.ScopeRead — the same permission in meta's OAuth vocabulary
//     rather than in tokens.sr.ht's. The statuses are the other plane's arm for
//     arm; what differs is the permission a 403 names, because the two
//     vocabularies do not overlap.
//   - No cookie. An API client is not a browser. The unified-login cookie is
//     web/'s plane, and this endpoint is deliberately outside it — so the
//     principal is overwritten with the anonymous one when no bearer credential
//     is presented, rather than inherited from whatever middleware happens to
//     sit above the mount point.
//
// # Why this surface takes a meta PAT when no other one does
//
// Because of the gateway, and for no other reason. api.sr.ht 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. The only credential that works across the whole instance is a meta
// PAT, so an endpoint that refuses them can never be part of the gateway's
// schema: federating it would be one `api-origin=` line that produced 401s.
//
// The REST write plane, /mcp and the push hook keep exactly one plane,
// tokens.sr.ht's, because they have no such problem and a narrow, revocable,
// short-lived grant is worth its cost there. They resolve identity through
// authn.Resolver, which holds no MetaAuth and therefore cannot produce an
// authn.PlaneMeta principal at all — the scope of the exception is structural
// rather than a convention to remember.
//
// A PAT admitted here is an agent and never the owner, so webhook management
// stays out of its reach: those mutations ask authn.Principal.IsOwner, which only
// the unified-login cookie satisfies and which this endpoint reads none of.
//
// # What the cmd layer wires
//
//	gql, err := graph.New(graph.Options{
//		Reader:   svc,             // *service.Service
//		Searcher: index,           // *search.Index
//		Resolver: svc.Resolver(),
//		Meta:     metaAuth,        // *authn.MetaAuth, this surface alone
//	})
//	if err != nil {
//		return err
//	}
//	router.Handle("/query", gql)
//
// Server installs its own credential middleware, so it goes on the *anonymous*
// router: core-go's server.WithSchema would mount it on the authenticated one,
// whose auth.Middleware speaks meta's OAuth vocabulary and 401s anything else —
// including the working token every other surface of this service takes. Serving
// both planes from one endpoint is exactly what that middleware cannot do, which
// is why this package resolves the credential itself. A service that mounts its
// own /query owes the instance api-meta.json as well, because core-go serves that
// file only for the schemas it hosts itself; sr-ht-ecore's apimeta package is
// what serves it, and cmd/specsrht wires it beside the route with the scope list
// GrantScopes derives from authn.ScopeRead.
//
// The router it is mounted on must carry core-go's config and database
// middleware. The read path does not need either, but the webhook management
// resolvers open transactions through core-go's database context, and
// WithDefaultMiddleware installs that on the authenticated router only.
package graph

import (
	"errors"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/99designs/gqlgen/graphql"
	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/handler/extension"
	"github.com/99designs/gqlgen/graphql/handler/transport"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-ecore/metapat"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/coreauth"
	"sourcecraft.dev/bigbes/sr-ht-spec/graph/api"
)

// GrantScopes is what this service publishes in api-meta.json: the scopes a
// meta.sr.ht personal access token can be minted for here.
//
// meta.sr.ht reads that file to build the checkboxes of its personal-token page,
// prefixing each entry with the service's own name — so "SPECS" here is what
// makes "spec.sr.ht/SPECS:RO" a token a human can actually obtain. An empty list,
// which this service published until /query started accepting a PAT, does not
// mean "a service with no scopes": it means no PAT can be scoped for this service
// at all, so the credential the rest of the instance uses could never be
// presented here even in principle. That was the stronger half of the refusal,
// and the half no amount of code in this package could have worked around.
//
// It is derived from authn.ScopeRead rather than spelled a second time, because
// the two spellings must agree: what meta's checkbox mints, and what
// MetaAuth.VerifyToken checks. A scope published and not checked admits what
// should have been refused, one checked and not published cannot be minted at
// all, and neither failure is visible from inside a single file — so a test on
// each side asserts the pair.
//
// This is meta.sr.ht's vocabulary. The other plane's permission is
// authn.ActionRead ("spec:read"), in tokens.sr.ht's; the two grammars share a
// token format and nothing else (sr-ht-ecore's grants and metapat packages).
var GrantScopes = []string{metapat.ScopeName(authn.ScopeRead)}

// Options is everything a Server needs. New says which one is missing rather
// than failing later inside a resolver.
type Options struct {
	// Reader is the orchestration layer. *service.Service satisfies it.
	Reader Reader

	// Searcher is the one global keyword index. *search.Index satisfies it.
	Searcher Searcher

	// Proposals is the proposal read side. It has no production implementation
	// yet — see the Proposals interface — so it is the one optional field here,
	// and while it is nil the `proposals` field of the schema fails with an
	// error saying so rather than answering "none".
	Proposals Proposals

	// Resolver verifies a presented tokens.sr.ht working token. Its cookie plane
	// is not used here: see the package comment.
	Resolver *authn.Resolver

	// Meta verifies a presented meta.sr.ht personal access token — the plane that
	// exists so this endpoint can be federated into api.sr.ht at all (see
	// authn.MetaAuth).
	//
	// Required, and unlike the working-token plane it needs no configuration to be
	// available: it depends on no per-instance origin, only on the meta.sr.ht
	// every SourceHut service already talks to. So there is no legitimate "this
	// instance has no such plane" case to tolerate a nil for, and an endpoint
	// built without it would answer 401 to the gateway while looking configured.
	Meta MetaAuthenticator
}

// Server is the /query endpoint: the executable schema behind the credential
// gate. It is built once at startup and is safe for concurrent use.
type Server struct {
	http   http.Handler
	schema graphql.ExecutableSchema
}

// newSchema builds the executable schema over the seams in opts.
//
// It is unexported now that nothing outside this package wants a schema without
// an endpoint. The daemon needs both — the endpoint to serve /query, and the
// schema to hand to webhooks.NewQueue, which executes a subscription's stored
// query at delivery time — and takes them from one Server, so that the two
// cannot become two schemas.
func newSchema(opts Options) (graphql.ExecutableSchema, error) {
	if opts.Reader == nil {
		return nil, fmt.Errorf("graph: Reader is required")
	}
	if opts.Searcher == nil {
		return nil, fmt.Errorf("graph: Searcher is required")
	}
	root := &Resolver{
		reader:    opts.Reader,
		searcher:  opts.Searcher,
		proposals: opts.Proposals,
	}
	schema := api.NewExecutableSchema(api.Config{Resolvers: root})
	// The root resolver holds the schema it is part of. The knot is deliberate:
	// the webhook resolvers validate and execute a subscriber's stored query
	// against this service's schema, and used to reach it through core-go's
	// server context — which exists on the authenticated router and nowhere
	// else. Holding it here is what lets /query move to the anonymous router
	// without those resolvers reaching for a context value that is not there.
	root.schema = schema
	return schema, nil
}

// New assembles the /query endpoint over the seams in opts.
func New(opts Options) (*Server, error) {
	if opts.Resolver == nil {
		return nil, fmt.Errorf("graph: authn Resolver is required")
	}
	// The seam whose absence would not announce itself: an endpoint with no PAT
	// plane starts, serves every working token exactly as before, and answers 401
	// to every federated query — a failure visible only from the gateway.
	if opts.Meta == nil {
		return nil, fmt.Errorf("graph: MetaAuthenticator is required")
	}
	schema, err := newSchema(opts)
	if err != nil {
		return nil, err
	}

	// The transport set is core-go's, not gqlgen's NewDefaultServer: POST and
	// introspection, and nothing else. NewDefaultServer would also install GET,
	// multipart upload and a websocket transport — a second way in, a file
	// upload path for a schema with no Upload scalar, and a subscription
	// transport for a schema with no subscriptions. Every SourceHut service on
	// this instance answers /query over POST, so a client that works against
	// one works against this.
	exec := handler.New(schema)
	exec.AddTransport(transport.POST{})
	exec.Use(extension.Introspection{})

	return &Server{
		schema: schema,
		http:   resolveCaller(opts.Resolver, opts.Meta, gate(coreContext(exec))),
	}, nil
}

// Schema is the executable schema this endpoint serves. The daemon hands it to
// webhooks.NewQueue so that the query a subscriber stored is executed against
// exactly the schema they wrote it for.
func (s *Server) Schema() graphql.ExecutableSchema { return s.schema }

// ServeHTTP serves /query behind the chain New built.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) }

// resolveCaller turns the presented bearer credential into this request's
// principal, or refuses the request. It is the whole credential plane of this
// endpoint, and it is authn.Resolver's bearer arm and not its Middleware:
// Middleware also reads the unified-login cookie, and a cookie is not a
// credential here.
//
// A request with no Authorization header is given the anonymous principal
// explicitly rather than being passed through untouched. That overwrite is the
// "no cookie" rule made structural: mounted under a router that already
// resolved a cookie identity, this endpoint still sees anonymous and still
// answers 401.
//
// # Two planes, and which one gets the request
//
// This is where spec.sr.ht's /query differs from every other surface it has, and
// the difference is api.sr.ht's. The gateway forwards ONE client "Authorization"
// header to every service a federated query touches, so a federated caller
// arrives holding whatever credential the client had — in practice a meta.sr.ht
// personal access token, the only credential that works instance-wide. An
// endpoint that refused those could not be federated at all, however correct its
// refusal looked.
//
// So the plane is chosen by what was presented, with metapat.PlaneOf, which
// decodes locally and asks no daemon anything:
//
//	PlaneMeta       the meta.sr.ht plane, scoped by authn.ScopeRead in meta's own
//	                OAuth vocabulary
//	PlaneWorking    the tokens.sr.ht plane, scoped by authn.ActionRead
//	PlaneUnknown    the tokens.sr.ht plane as well — the credential is unreadable
//	                (forged, corrupt or expired: DecodeBearerToken checks expiry
//	                before it reports an issuer), both planes owe it the same 401,
//	                and routing it to the one that already words that refusal
//	                keeps one message rather than two
//
// Routing on the credential rather than on the failure of one plane is what lets
// the meta plane work on an instance whose config.ini has no [tokens.sr.ht]
// section at all: there is no working-token validator there to fail first, only
// an authn.ErrNoAgentPlane that would have to be told apart from a real refusal.
//
// # What each plane is then asked
//
// Both answer the same three questions and only the vocabulary differs. Is the
// credential live; does it belong to the one human this instance answers to; was
// it minted for reading. The last is asked at different moments — the working
// plane's grant in gate below, where the action is known, the PAT's scope inside
// VerifyToken, because that plane guards this one read surface and nothing else —
// and that is the only asymmetry between them.
//
// The statuses are authn.StatusFor's, which is the one table this service maps a
// credential failure with, and it is one table across both planes on purpose:
// 401 for a credential that does not verify — forged, expired, revoked, or issued
// by somebody else — 403 for one that verifies and belongs to a human this
// single-owner instance has nothing to grant, and 503 for a credential that could
// not be *checked*. The last is not "your token is bad": answering 401 to a
// restart of tokens.sr.ht, or to a meta.sr.ht that will not answer, tells every
// agent to re-mint credentials that were never broken.
//
// The messages are written here from what the caller already knows, never from
// the error's own text: authn's errors name usernames and token ids.
func resolveCaller(rs *authn.Resolver, meta MetaAuthenticator, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		presented := authn.BearerFromRequest(r)
		if presented == "" {
			next.ServeHTTP(w, r.WithContext(
				authn.WithPrincipal(r.Context(), authn.Anonymous())))
			return
		}

		var (
			p   authn.Principal
			err error
		)
		if metapat.PlaneOf(presented) == metapat.PlaneMeta {
			// The credential and not the request: a plane that cannot see the
			// request cannot read a cookie off it, and the provenance headers are
			// a write's business, which this surface has none of. See
			// MetaAuthenticator.
			p, err = meta.VerifyToken(r.Context(), presented)
		} else {
			p, err = rs.ResolveAgent(r.Context(), presented,
				r.Header.Get(authn.HeaderAgent), r.Header.Get(authn.HeaderAgentSession))
		}
		if err != nil {
			status := authn.StatusFor(err)
			if status >= http.StatusInternalServerError {
				// Fail closed and loudly. The alternative — degrading to
				// anonymous — would turn an unreachable tokens.sr.ht into every
				// agent silently losing its read access.
				slog.ErrorContext(r.Context(), "a bearer credential could not be checked",
					"component", "graph", "path", r.URL.Path, "status", status, scribe.Err(err))
			}
			if status == http.StatusUnauthorized {
				// RFC 9110 requires the challenge on a 401, and every caller
				// here is a machine holding a bearer token: naming the scheme
				// and the realm is what tells it which credential was refused.
				w.Header().Set("WWW-Authenticate", authn.Challenge())
			}
			http.Error(w, refusalMessage(status, err), status)
			return
		}
		next.ServeHTTP(w, r.WithContext(authn.WithPrincipal(r.Context(), p)))
	})
}

// refusalMessage is what a refused caller is told.
//
// It is keyed on the status, so that nothing about whose token it was, or whether
// a row exists, leaks to a caller holding a credential this service did not
// accept. The one thing it reads off the error is authn.ErrMissingScope, and that
// is not a hole in the rule: what it adds is a compile-time constant naming a
// permission the caller can go and tick a box for, which is precisely what gate
// below already does for the other plane's grant. It says nothing about the
// credential, the account or the database.
//
// Without that arm a PAT missing the scope would be told "this token does not
// authorize requests to spec.sr.ht" and have nowhere to go: meta's personal-token
// page is a list of checkboxes, and a client that is not told which one it lacks
// cannot ask for it.
func refusalMessage(status int, err error) string {
	switch status {
	case http.StatusUnauthorized:
		return "the bearer token presented was refused"
	case http.StatusForbidden:
		if errors.Is(err, authn.ErrMissingScope) {
			return "this personal access token does not carry " + authn.ScopeRead
		}
		return "this token does not authorize requests to " + authn.ConfigSection
	default:
		return "the credential could not be verified, try again"
	}
}

// gate refuses a caller with no read authority before the query is parsed.
//
// The ACL is authn.Principal.CanRead — the owner and its agents may read and
// nobody else may — the same predicate web/ and mcpsrv/ apply, so the three read
// surfaces cannot drift into three policies, which is how a corpus leaks. On
// this endpoint the owner half of it is unreachable in practice: the owner is
// recognised by a cookie, and resolveCaller above accepts none. It is asked
// anyway because it is the shared predicate and not this endpoint's own.
//
// A caller that clears it and authenticated with a tokens.sr.ht working token
// must also hold spec:read — the grant half of the same question, asked here
// because here is where the action ("read") is known.
//
// A caller on the meta.sr.ht plane passes that half untouched, and has already
// answered it. Principal.Authorize is a no-op for authn.PlaneMeta because no PAT
// can carry "spec:read" at all — asking would refuse every one of them — so the
// equivalent question was put to it in meta's own vocabulary at resolution, where
// authn.ScopeRead is what it had to carry. The permission is checked exactly once
// on either plane; only the moment and the grammar differ.
//
// It is one check at the boundary rather than one per field, because every read
// field of this schema is a read and the surface has one action. The webhook
// mutations must NOT rely on it: they would be admitted by a read grant, which
// is not what a read grant says, so they carry their own owner gate in the
// resolver.
//
// The refusal is a 401 — or a 403 for the missing grant — with a line of text
// and never a redirect to meta's login: every caller here is a machine, and
// handing a bot 200 and a page of login markup tells it nothing it can act on.
// The two statuses stay apart because retrying is worth it for one and never for
// the other.
func gate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := authn.PrincipalFromContext(r.Context())
		if !p.CanRead() {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
			w.Header().Set("WWW-Authenticate", authn.Challenge())
			http.Error(w, "authentication required", http.StatusUnauthorized)
			return
		}
		if err := p.Authorize(authn.ActionRead); err != nil {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
			http.Error(w, "this token does not grant "+authn.ActionRead, http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// coreContext derives core-go's AuthContext from the principal the gate has
// already admitted, because core-go's webhook engine reads one out of the
// context and this endpoint no longer runs behind the middleware that puts it
// there.
//
// The user id is the credential's own: authn resolved the token's owner to a
// local row and refused the token outright if that row had no id, so there is
// nothing to substitute and no default to invent here.
func coreContext(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := authn.PrincipalFromContext(r.Context())
		next.ServeHTTP(w, r.WithContext(coreauth.Context(r.Context(), p, p.UserID)))
	})
}