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

Coverage
71.4% 25/35 statements
Δ
Blob
d7a0b77
1 // Package graph is dolt.sr.ht's GraphQL read schema, served at /query.
2 //
3 // # Why a GraphQL surface at all
4 //
5 // Everything on this instance that already speaks SourceHut GraphQL — hut, a
6 // script written against git.sr.ht's API, api.sr.ht itself — can read this
7 // service the moment it has a /query, and could not before it. Federating into
8 // api.sr.ht is then one `api-origin=` line on the gateway that nothing here
9 // depends on.
10 //
11 // # Read only, deliberately
12 //
13 // There are no mutations, for spec.sr.ht's reason: a type federated into the
14 // gateway is a consumed contract, expensive to churn, so only what has settled
15 // is published. Creating, renaming and deleting a database each move a metadata
16 // row and an on-disk store together, and that pairing is young. Rows and diffs
17 // are absent for a different reason — they live on /mcp, where a read that had
18 // to be clipped says so in its own answer.
19 //
20 // # Who may read what
21 //
22 // Not spec.sr.ht's single-owner gate: dolt.sr.ht is multi-user, and its
23 // visibility rules already exist. The endpoint therefore answers anyone, and
24 // every field applies the same access matrix the web pages and the MCP tools do:
25 //
26 // - The credential is the bearer plane /mcp already defines — a meta personal
27 // access token scoped `dolt.sr.ht/repos:RO`, or a tokens.sr.ht working token
28 // carrying `dolt:read`. No cookie: an API client is not a browser, and this
29 // endpoint is deliberately outside web's same-origin group.
30 // - Anonymous is a normal caller. It reads what an anonymous visitor reads,
31 // which is why /query is mounted on the anonymous router: core-go's own auth
32 // middleware 401s an un-cookied request, and a public database is public.
33 // - A database the caller may not see resolves to null, never to an
34 // authorization error, so its existence cannot be read out of the shape of
35 // the refusal.
36 //
37 // # What the cmd layer wires
38 //
39 // gql, err := graph.New(graph.Options{
40 // Repos: dbAdapter, // request-scoped, over db.Store
41 // Browse: browseAdapter, // over browse.Open
42 // Validator: validator, // may be nil: no tokens.sr.ht on the instance
43 // })
44 // if err != nil { return err }
45 // router.Handle("/query", gql)
46 //
47 // Server installs its own credential middleware, so it can be mounted on a
48 // router that resolves none.
49 package graph
50
51 import (
52 "context"
53 "errors"
54 "log/slog"
55 "net/http"
56
57 "github.com/99designs/gqlgen/graphql/handler"
58 "github.com/99designs/gqlgen/graphql/handler/extension"
59 "github.com/99designs/gqlgen/graphql/handler/transport"
60
61 "go.bigb.es/auxilia/culpa"
62 "go.bigb.es/auxilia/scribe"
63
64 "sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
65
66 "sourcecraft.dev/bigbes/sr-ht-dolt/authn"
67 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
68 "sourcecraft.dev/bigbes/sr-ht-dolt/graph/api"
69 )
70
71 // ServiceName is what this service calls itself in a bearer challenge.
72 const ServiceName = "dolt.sr.ht"
73
74 var bearerChallenge = bearer.Challenge(ServiceName)
75
76 // Options is everything a Server needs. New says which one is missing rather
77 // than failing later inside a resolver.
78 type Options struct {
79 // Repos is the metadata store. In production it is the same request-scoped
80 // adapter web and mcpsrv use.
81 Repos Repos
82
83 // Browse opens read-only sessions over the bare stores.
84 Browse BrowseOpener
85
86 // Validator verifies a tokens.sr.ht working token. It may be nil — an
87 // instance that runs no tokens.sr.ht is a supported configuration — and then
88 // a working token is refused while meta PATs and anonymous callers keep
89 // working, exactly as on /mcp.
90 Validator authn.InstanceValidator
91 }
92
93 // Server is the /query endpoint: the executable schema behind the credential
94 // gate. It is built once at startup and is safe for concurrent use.
95 type Server struct {
96 http http.Handler
97 }
98
99 // New builds the endpoint. Repos and Browse are required: a surface that
100 // answered every query "could not be read" because a seam was never wired would
101 // be a daemon that starts and does not work.
102 27 func New(opts Options) (*Server, error) {
103 27 if opts.Repos == nil {
104 1 return nil, culpa.New("graph: nil Repos")
105 1 }
106 26 if opts.Browse == nil {
107 1 return nil, culpa.New("graph: nil BrowseOpener")
108 1 }
109
110 25 srv := handler.New(api.NewExecutableSchema(api.Config{
111 25 Resolvers: &Resolver{repos: opts.Repos, opener: opts.Browse},
112 25 }))
113 25 // POST alone: this schema is read-only but its transport is not a GET API.
114 25 // A GET query would be a cross-origin-readable URL for data that is often
115 25 // private, and there is no cookie plane here to make that safe.
116 25 srv.AddTransport(transport.POST{})
117 25 // Introspection is on: a client that cannot introspect cannot generate a
118 25 // typed client, and everything this schema describes is already gated per
119 25 // field by the access matrix.
120 25 srv.Use(extension.Introspection{})
121 25
122 25 return &Server{
123 25 http: resolveCaller(opts.Validator, requireReadGrant(srv)),
124 25 }, nil
125 }
126
127 // ServeHTTP serves /query behind the chain New built.
128 31 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) }
129
130 // resolveCaller turns the presented bearer credential into the request's
131 // principal, or refuses the request. It is /mcp's middleware, arm for arm,
132 // because it is the same credential plane and a second reading of it would be a
133 // second thing to keep in agreement:
134 //
135 // no credential anonymous — a normal caller here
136 // ErrMissingGrant 403, the credential is good and the caller is known
137 // ErrInvalidToken 401 + the challenge — forged, expired, revoked, or a
138 // working token on an instance with no tokens.sr.ht to
139 // verify it against
140 // anything else 503 — the credential could not be *checked*, which is not
141 // "your token is bad": answering 401 to a restart of
142 // meta.sr.ht tells every client to re-mint credentials that
143 // were never broken.
144 //
145 // The messages are written here from what the caller already knows, never from
146 // the error's own text: authn's errors name usernames, hosts and token ids.
147 25 func resolveCaller(v authn.InstanceValidator, next http.Handler) http.Handler {
148 31 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
149 31 presented := authn.ParseBearer(r)
150 31 if presented == "" {
151 30 next.ServeHTTP(w, r)
152 30 return
153 30 }
154
155 1 bc, err := authn.ResolveBearer(r.Context(), v, presented)
156 1 if err != nil {
157 1 switch {
158 0 case errors.Is(err, authn.ErrMissingGrant):
159 0 // Asked before ErrInvalidToken: ResolveBearer joins the two, so
160 0 // a caller that can say 403 asks for this sentinel first.
161 0 http.Error(w, "this credential does not grant read access to "+ServiceName+" databases",
162 0 http.StatusForbidden)
163 1 case errors.Is(err, authn.ErrInvalidToken):
164 1 w.Header().Set("WWW-Authenticate", bearerChallenge)
165 1 http.Error(w, "the bearer token presented was refused", http.StatusUnauthorized)
166 0 default:
167 0 slog.Error("a bearer credential could not be checked",
168 0 "component", "graph", scribe.Err(err))
169 0 http.Error(w, "the credential could not be verified, try again",
170 0 http.StatusServiceUnavailable)
171 }
172 1 return
173 }
174
175 0 ctx := authn.WithCaller(r.Context(), bc.AuthContext)
176 0 ctx = withBearerCaller(ctx, bc)
177 0 next.ServeHTTP(w, r.WithContext(ctx))
178 })
179 }
180
181 // requireReadGrant is one check at the boundary rather than one per field,
182 // because every field of this schema is a read, so the surface has exactly one
183 // action. A mutation added here must NOT rely on it: it would be admitted by a
184 // read grant, which is not what a read grant says.
185 //
186 // A meta PAT and an anonymous caller pass, and neither is a hole: a PAT carries
187 // no tokens.sr.ht grants at all — the vocabularies do not overlap — and its own
188 // scoping was applied when it resolved, while an anonymous caller is held to
189 // visibility by every resolver.
190 25 func requireReadGrant(next http.Handler) http.Handler {
191 30 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192 30 if bc := bearerCallerFrom(r.Context()); bc != nil {
193 0 if err := bc.Authorize(core.GrantRead); err != nil {
194 0 http.Error(w, "this token does not carry the "+core.GrantRead+" grant",
195 0 http.StatusForbidden)
196 0 return
197 0 }
198 }
199 30 next.ServeHTTP(w, r)
200 })
201 }
202
203 type contextKey struct{ name string }
204
205 // bearerCallerKey holds the resolved *authn.BearerCaller for the grant gate.
206 // The identity itself goes where the rest of the service looks for it
207 // (authn.WithCaller); what has no house-wide home is the tokens.sr.ht grant set.
208 var bearerCallerKey = contextKey{"graph.bearerCaller"}
209
210 0 func withBearerCaller(ctx context.Context, bc *authn.BearerCaller) context.Context {
211 0 return context.WithValue(ctx, bearerCallerKey, bc)
212 0 }
213
214 30 func bearerCallerFrom(ctx context.Context) *authn.BearerCaller {
215 30 bc, _ := ctx.Value(bearerCallerKey).(*authn.BearerCaller)
216 30 return bc
217 30 }