~bigbes/sr-ht-dolt

ref: ede3b0bb2671ffde35a66527b920c40eaccc096f sr-ht-dolt/graph/server.go -rw-r--r-- 8.6 KiB
ede3b0bb — 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
// Package graph is dolt.sr.ht's GraphQL read schema, served at /query.
//
// # Why a GraphQL surface at all
//
// Everything on this instance that already speaks SourceHut GraphQL — hut, a
// script written against git.sr.ht's API, api.sr.ht itself — can read this
// service the moment it has a /query, and could not before it. Federating into
// api.sr.ht is then one `api-origin=` line on the gateway that nothing here
// depends on.
//
// # Read only, deliberately
//
// There are no mutations, for spec.sr.ht's reason: a type federated into the
// gateway is a consumed contract, expensive to churn, so only what has settled
// is published. Creating, renaming and deleting a database each move a metadata
// row and an on-disk store together, and that pairing is young. Rows and diffs
// are absent for a different reason — they live on /mcp, where a read that had
// to be clipped says so in its own answer.
//
// # Who may read what
//
// Not spec.sr.ht's single-owner gate: dolt.sr.ht is multi-user, and its
// visibility rules already exist. The endpoint therefore answers anyone, and
// every field applies the same access matrix the web pages and the MCP tools do:
//
//   - The credential is the bearer plane /mcp already defines — a meta personal
//     access token scoped `dolt.sr.ht/repos:RO`, or a tokens.sr.ht working token
//     carrying `dolt:read`. No cookie: an API client is not a browser, and this
//     endpoint is deliberately outside web's same-origin group.
//   - Anonymous is a normal caller. It reads what an anonymous visitor reads,
//     which is why /query is mounted on the anonymous router: core-go's own auth
//     middleware 401s an un-cookied request, and a public database is public.
//   - A database the caller may not see resolves to null, never to an
//     authorization error, so its existence cannot be read out of the shape of
//     the refusal.
//
// # What the cmd layer wires
//
//	gql, err := graph.New(graph.Options{
//		Repos:     dbAdapter,      // request-scoped, over db.Store
//		Browse:    browseAdapter,  // over browse.Open
//		Validator: validator,      // may be nil: no tokens.sr.ht on the instance
//	})
//	if err != nil { return err }
//	router.Handle("/query", gql)
//
// Server installs its own credential middleware, so it can be mounted on a
// router that resolves none.
package graph

import (
	"context"
	"errors"
	"log/slog"
	"net/http"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/handler/extension"
	"github.com/99designs/gqlgen/graphql/handler/transport"

	"go.bigb.es/auxilia/culpa"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
	"sourcecraft.dev/bigbes/sr-ht-dolt/graph/api"
)

// ServiceName is what this service calls itself in a bearer challenge.
const ServiceName = "dolt.sr.ht"

var bearerChallenge = bearer.Challenge(ServiceName)

// Options is everything a Server needs. New says which one is missing rather
// than failing later inside a resolver.
type Options struct {
	// Repos is the metadata store. In production it is the same request-scoped
	// adapter web and mcpsrv use.
	Repos Repos

	// Browse opens read-only sessions over the bare stores.
	Browse BrowseOpener

	// Validator verifies a tokens.sr.ht working token. It may be nil — an
	// instance that runs no tokens.sr.ht is a supported configuration — and then
	// a working token is refused while meta PATs and anonymous callers keep
	// working, exactly as on /mcp.
	Validator authn.InstanceValidator
}

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

// New builds the endpoint. Repos and Browse are required: a surface that
// answered every query "could not be read" because a seam was never wired would
// be a daemon that starts and does not work.
func New(opts Options) (*Server, error) {
	if opts.Repos == nil {
		return nil, culpa.New("graph: nil Repos")
	}
	if opts.Browse == nil {
		return nil, culpa.New("graph: nil BrowseOpener")
	}

	srv := handler.New(api.NewExecutableSchema(api.Config{
		Resolvers: &Resolver{repos: opts.Repos, opener: opts.Browse},
	}))
	// POST alone: this schema is read-only but its transport is not a GET API.
	// A GET query would be a cross-origin-readable URL for data that is often
	// private, and there is no cookie plane here to make that safe.
	srv.AddTransport(transport.POST{})
	// Introspection is on: a client that cannot introspect cannot generate a
	// typed client, and everything this schema describes is already gated per
	// field by the access matrix.
	srv.Use(extension.Introspection{})

	return &Server{
		http: resolveCaller(opts.Validator, requireReadGrant(srv)),
	}, nil
}

// 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 the request's
// principal, or refuses the request. It is /mcp's middleware, arm for arm,
// because it is the same credential plane and a second reading of it would be a
// second thing to keep in agreement:
//
//	no credential      anonymous — a normal caller here
//	ErrMissingGrant    403, the credential is good and the caller is known
//	ErrInvalidToken    401 + the challenge — forged, expired, revoked, or a
//	                   working token on an instance with no tokens.sr.ht to
//	                   verify it against
//	anything else      503 — the credential could not be *checked*, which is not
//	                   "your token is bad": answering 401 to a restart of
//	                   meta.sr.ht tells every client 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, hosts and token ids.
func resolveCaller(v authn.InstanceValidator, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		presented := authn.ParseBearer(r)
		if presented == "" {
			next.ServeHTTP(w, r)
			return
		}

		bc, err := authn.ResolveBearer(r.Context(), v, presented)
		if err != nil {
			switch {
			case errors.Is(err, authn.ErrMissingGrant):
				// Asked before ErrInvalidToken: ResolveBearer joins the two, so
				// a caller that can say 403 asks for this sentinel first.
				http.Error(w, "this credential does not grant read access to "+ServiceName+" databases",
					http.StatusForbidden)
			case errors.Is(err, authn.ErrInvalidToken):
				w.Header().Set("WWW-Authenticate", bearerChallenge)
				http.Error(w, "the bearer token presented was refused", http.StatusUnauthorized)
			default:
				slog.Error("a bearer credential could not be checked",
					"component", "graph", scribe.Err(err))
				http.Error(w, "the credential could not be verified, try again",
					http.StatusServiceUnavailable)
			}
			return
		}

		ctx := authn.WithCaller(r.Context(), bc.AuthContext)
		ctx = withBearerCaller(ctx, bc)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// requireReadGrant is one check at the boundary rather than one per field,
// because every field of this schema is a read, so the surface has exactly one
// action. A mutation added here must NOT rely on it: it would be admitted by a
// read grant, which is not what a read grant says.
//
// A meta PAT and an anonymous caller pass, and neither is a hole: a PAT carries
// no tokens.sr.ht grants at all — the vocabularies do not overlap — and its own
// scoping was applied when it resolved, while an anonymous caller is held to
// visibility by every resolver.
func requireReadGrant(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if bc := bearerCallerFrom(r.Context()); bc != nil {
			if err := bc.Authorize(core.GrantRead); err != nil {
				http.Error(w, "this token does not carry the "+core.GrantRead+" grant",
					http.StatusForbidden)
				return
			}
		}
		next.ServeHTTP(w, r)
	})
}

type contextKey struct{ name string }

// bearerCallerKey holds the resolved *authn.BearerCaller for the grant gate.
// The identity itself goes where the rest of the service looks for it
// (authn.WithCaller); what has no house-wide home is the tokens.sr.ht grant set.
var bearerCallerKey = contextKey{"graph.bearerCaller"}

func withBearerCaller(ctx context.Context, bc *authn.BearerCaller) context.Context {
	return context.WithValue(ctx, bearerCallerKey, bc)
}

func bearerCallerFrom(ctx context.Context) *authn.BearerCaller {
	bc, _ := ctx.Value(bearerCallerKey).(*authn.BearerCaller)
	return bc
}