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

Coverage
94.3% 50/53 statements
Δ
Blob
ed6f989
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 the bearer plane /mcp and the REST write plane already
32 // define, and nothing else:
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 // - No cookie. An API client is not a browser. The unified-login cookie is
39 // web/'s plane, and this endpoint is deliberately outside it — so the
40 // principal is overwritten with the anonymous one when no bearer credential
41 // is presented, rather than inherited from whatever middleware happens to
42 // sit above the mount point.
43 // - No meta.sr.ht personal access token. spec.sr.ht authenticates through one
44 // issuer (see authn's package comment) and publishes no OAuth scope for meta
45 // to grant against, so there is nothing a PAT could be scoped for here. A
46 // PAT is bearer.ErrNotOurs and is refused at the door with 401.
47 //
48 // # What the cmd layer wires
49 //
50 // gql, err := graph.New(graph.Options{
51 // Reader: svc, // *service.Service
52 // Searcher: index, // *search.Index
53 // Resolver: svc.Resolver(),
54 // })
55 // if err != nil {
56 // return err
57 // }
58 // router.Handle("/query", gql)
59 //
60 // Server installs its own credential middleware, so it goes on the *anonymous*
61 // router: core-go's server.WithSchema would mount it on the authenticated one,
62 // whose auth.Middleware answers meta's OAuth vocabulary and 401s anything else
63 // — the vocabulary this service deliberately does not speak. A service that
64 // mounts its own /query owes the instance api-meta.json as well, because
65 // core-go serves that file only for the schemas it hosts itself; sr-ht-ecore's
66 // apimeta package is what serves it, and cmd/specsrht wires it beside the
67 // route.
68 //
69 // The router it is mounted on must carry core-go's config and database
70 // middleware. The read path does not need either, but the webhook management
71 // resolvers open transactions through core-go's database context, and
72 // WithDefaultMiddleware installs that on the authenticated router only.
73 package graph
74
75 import (
76 "fmt"
77 "log/slog"
78 "net/http"
79
80 "github.com/99designs/gqlgen/graphql"
81 "github.com/99designs/gqlgen/graphql/handler"
82 "github.com/99designs/gqlgen/graphql/handler/extension"
83 "github.com/99designs/gqlgen/graphql/handler/transport"
84 "go.bigb.es/auxilia/scribe"
85
86 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
87 "sourcecraft.dev/bigbes/sr-ht-spec/coreauth"
88 "sourcecraft.dev/bigbes/sr-ht-spec/graph/api"
89 )
90
91 // Options is everything a Server needs. New says which one is missing rather
92 // than failing later inside a resolver.
93 type Options struct {
94 // Reader is the orchestration layer. *service.Service satisfies it.
95 Reader Reader
96
97 // Searcher is the one global keyword index. *search.Index satisfies it.
98 Searcher Searcher
99
100 // Proposals is the proposal read side. It has no production implementation
101 // yet — see the Proposals interface — so it is the one optional field here,
102 // and while it is nil the `proposals` field of the schema fails with an
103 // error saying so rather than answering "none".
104 Proposals Proposals
105
106 // Resolver verifies the bearer credential a caller presents. Its cookie
107 // plane is not used here: see the package comment.
108 Resolver *authn.Resolver
109 }
110
111 // Server is the /query endpoint: the executable schema behind the credential
112 // gate. It is built once at startup and is safe for concurrent use.
113 type Server struct {
114 http http.Handler
115 schema graphql.ExecutableSchema
116 }
117
118 // newSchema builds the executable schema over the seams in opts.
119 //
120 // It is unexported now that nothing outside this package wants a schema without
121 // an endpoint. The daemon needs both — the endpoint to serve /query, and the
122 // schema to hand to webhooks.NewQueue, which executes a subscription's stored
123 // query at delivery time — and takes them from one Server, so that the two
124 // cannot become two schemas.
125 45 func newSchema(opts Options) (graphql.ExecutableSchema, error) {
126 45 if opts.Reader == nil {
127 1 return nil, fmt.Errorf("graph: Reader is required")
128 1 }
129 44 if opts.Searcher == nil {
130 1 return nil, fmt.Errorf("graph: Searcher is required")
131 1 }
132 43 root := &Resolver{
133 43 reader: opts.Reader,
134 43 searcher: opts.Searcher,
135 43 proposals: opts.Proposals,
136 43 }
137 43 schema := api.NewExecutableSchema(api.Config{Resolvers: root})
138 43 // The root resolver holds the schema it is part of. The knot is deliberate:
139 43 // the webhook resolvers validate and execute a subscriber's stored query
140 43 // against this service's schema, and used to reach it through core-go's
141 43 // server context — which exists on the authenticated router and nowhere
142 43 // else. Holding it here is what lets /query move to the anonymous router
143 43 // without those resolvers reaching for a context value that is not there.
144 43 root.schema = schema
145 43 return schema, nil
146 }
147
148 // New assembles the /query endpoint over the seams in opts.
149 46 func New(opts Options) (*Server, error) {
150 46 if opts.Resolver == nil {
151 1 return nil, fmt.Errorf("graph: authn Resolver is required")
152 1 }
153 45 schema, err := newSchema(opts)
154 45 if err != nil {
155 2 return nil, err
156 2 }
157
158 // The transport set is core-go's, not gqlgen's NewDefaultServer: POST and
159 // introspection, and nothing else. NewDefaultServer would also install GET,
160 // multipart upload and a websocket transport — a second way in, a file
161 // upload path for a schema with no Upload scalar, and a subscription
162 // transport for a schema with no subscriptions. Every SourceHut service on
163 // this instance answers /query over POST, so a client that works against
164 // one works against this.
165 43 exec := handler.New(schema)
166 43 exec.AddTransport(transport.POST{})
167 43 exec.Use(extension.Introspection{})
168 43
169 43 return &Server{
170 43 schema: schema,
171 43 http: resolveCaller(opts.Resolver, gate(coreContext(exec))),
172 43 }, nil
173 }
174
175 // Schema is the executable schema this endpoint serves. The daemon hands it to
176 // webhooks.NewQueue so that the query a subscriber stored is executed against
177 // exactly the schema they wrote it for.
178 0 func (s *Server) Schema() graphql.ExecutableSchema { return s.schema }
179
180 // ServeHTTP serves /query behind the chain New built.
181 66 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) }
182
183 // resolveCaller turns the presented bearer credential into this request's
184 // principal, or refuses the request. It is the whole credential plane of this
185 // endpoint, and it is authn.Resolver's bearer arm and not its Middleware:
186 // Middleware also reads the unified-login cookie, and a cookie is not a
187 // credential here.
188 //
189 // A request with no Authorization header is given the anonymous principal
190 // explicitly rather than being passed through untouched. That overwrite is the
191 // "no cookie" rule made structural: mounted under a router that already
192 // resolved a cookie identity, this endpoint still sees anonymous and still
193 // answers 401.
194 //
195 // The statuses are authn.StatusFor's, which is the one table this service maps
196 // a credential failure with: 401 for a credential that does not verify — forged,
197 // expired, revoked, or issued by somebody else — 403 for one that verifies and
198 // belongs to a human this single-owner instance has nothing to grant, and 503
199 // for a credential that could not be *checked*. The last is not "your token is
200 // bad": answering 401 to a restart of tokens.sr.ht tells every agent to re-mint
201 // credentials that were never broken.
202 //
203 // The messages are written here from what the caller already knows, never from
204 // the error's own text: authn's errors name usernames and token ids.
205 43 func resolveCaller(rs *authn.Resolver, next http.Handler) http.Handler {
206 66 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
207 66 presented := authn.BearerFromRequest(r)
208 66 if presented == "" {
209 8 next.ServeHTTP(w, r.WithContext(
210 8 authn.WithPrincipal(r.Context(), authn.Anonymous())))
211 8 return
212 8 }
213
214 58 p, err := rs.ResolveAgent(r.Context(), presented,
215 58 r.Header.Get(authn.HeaderAgent), r.Header.Get(authn.HeaderAgentSession))
216 58 if err != nil {
217 5 status := authn.StatusFor(err)
218 5 if status >= http.StatusInternalServerError {
219 0 // Fail closed and loudly. The alternative — degrading to
220 0 // anonymous — would turn an unreachable tokens.sr.ht into every
221 0 // agent silently losing its read access.
222 0 slog.ErrorContext(r.Context(), "a bearer credential could not be checked",
223 0 "component", "graph", "path", r.URL.Path, "status", status, scribe.Err(err))
224 0 }
225 5 if status == http.StatusUnauthorized {
226 4 // RFC 9110 requires the challenge on a 401, and every caller
227 4 // here is a machine holding a bearer token: naming the scheme
228 4 // and the realm is what tells it which credential was refused.
229 4 w.Header().Set("WWW-Authenticate", authn.Challenge())
230 4 }
231 5 http.Error(w, refusalMessage(status), status)
232 5 return
233 }
234 53 next.ServeHTTP(w, r.WithContext(authn.WithPrincipal(r.Context(), p)))
235 })
236 }
237
238 // refusalMessage is what a refused caller is told. It is keyed on the status and
239 // not on the error, so that nothing about whose token it was, or whether a row
240 // exists, leaks to a caller holding a credential this service did not accept.
241 5 func refusalMessage(status int) string {
242 5 switch status {
243 4 case http.StatusUnauthorized:
244 4 return "the bearer token presented was refused"
245 1 case http.StatusForbidden:
246 1 return "this token does not authorize requests to " + authn.ConfigSection
247 0 default:
248 0 return "the credential could not be verified, try again"
249 }
250 }
251
252 // gate refuses a caller with no read authority before the query is parsed.
253 //
254 // The ACL is authn.Principal.CanRead — the owner and its agents may read and
255 // nobody else may — the same predicate web/ and mcpsrv/ apply, so the three read
256 // surfaces cannot drift into three policies, which is how a corpus leaks. On
257 // this endpoint the owner half of it is unreachable in practice: the owner is
258 // recognised by a cookie, and resolveCaller above accepts none. It is asked
259 // anyway because it is the shared predicate and not this endpoint's own.
260 //
261 // A caller that clears it and authenticated with a tokens.sr.ht working token
262 // must also hold spec:read — the grant half of the same question, asked here
263 // because here is where the action ("read") is known.
264 //
265 // It is one check at the boundary rather than one per field, because every read
266 // field of this schema is a read and the surface has one action. The webhook
267 // mutations must NOT rely on it: they would be admitted by a read grant, which
268 // is not what a read grant says, so they carry their own owner gate in the
269 // resolver.
270 //
271 // The refusal is a 401 — or a 403 for the missing grant — with a line of text
272 // and never a redirect to meta's login: every caller here is a machine, and
273 // handing a bot 200 and a page of login markup tells it nothing it can act on.
274 // The two statuses stay apart because retrying is worth it for one and never for
275 // the other.
276 49 func gate(next http.Handler) http.Handler {
277 67 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
278 67 p := authn.PrincipalFromContext(r.Context())
279 67 if !p.CanRead() {
280 9 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
281 9 w.Header().Set("WWW-Authenticate", authn.Challenge())
282 9 http.Error(w, "authentication required", http.StatusUnauthorized)
283 9 return
284 9 }
285 58 if err := p.Authorize(authn.ActionRead); err != nil {
286 2 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
287 2 http.Error(w, "this token does not grant "+authn.ActionRead, http.StatusForbidden)
288 2 return
289 2 }
290 56 next.ServeHTTP(w, r)
291 })
292 }
293
294 // coreContext derives core-go's AuthContext from the principal the gate has
295 // already admitted, because core-go's webhook engine reads one out of the
296 // context and this endpoint no longer runs behind the middleware that puts it
297 // there.
298 //
299 // The user id is the credential's own: authn resolved the token's owner to a
300 // local row and refused the token outright if that row had no id, so there is
301 // nothing to substitute and no default to invent here.
302 43 func coreContext(next http.Handler) http.Handler {
303 52 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
304 52 p := authn.PrincipalFromContext(r.Context())
305 52 next.ServeHTTP(w, r.WithContext(coreauth.Context(r.Context(), p, p.UserID)))
306 52 })
307 }