| 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 |
|
} |