~bigbes/sr-ht-spec

ref: 3cb1c03d8078d5748cc13a2e9bd7ba7d078e1b37 sr-ht-spec/graph/server.go -rw-r--r-- 13.7 KiB
3cb1c03d — Eugene Blikh go.mod: take the shared libraries' current heads 2 days 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
// 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 the bearer plane /mcp and the REST write plane already
// define, and nothing else:
//
//   - 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.
//   - 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.
//   - No meta.sr.ht personal access token. spec.sr.ht authenticates through one
//     issuer (see authn's package comment) and publishes no OAuth scope for meta
//     to grant against, so there is nothing a PAT could be scoped for here. A
//     PAT is bearer.ErrNotOurs and is refused at the door with 401.
//
// # What the cmd layer wires
//
//	gql, err := graph.New(graph.Options{
//		Reader:   svc,             // *service.Service
//		Searcher: index,           // *search.Index
//		Resolver: svc.Resolver(),
//	})
//	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 answers meta's OAuth vocabulary and 401s anything else
// — the vocabulary this service deliberately does not speak. 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.
//
// 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 (
	"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-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/coreauth"
	"sourcecraft.dev/bigbes/sr-ht-spec/graph/api"
)

// 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 the bearer credential a caller presents. Its cookie
	// plane is not used here: see the package comment.
	Resolver *authn.Resolver
}

// 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")
	}
	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, 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.
//
// The statuses are authn.StatusFor's, which is the one table this service maps
// a credential failure with: 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 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, 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
		}

		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), 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 and
// not on the error, 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.
func refusalMessage(status int) string {
	switch status {
	case http.StatusUnauthorized:
		return "the bearer token presented was refused"
	case http.StatusForbidden:
		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.
//
// 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)))
	})
}