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