| 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 a bearer token, and this is the one surface of this service |
| 32 |
|
// that takes either of the instance's two: |
| 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 |
|
// - A meta.sr.ht personal access token, verified through sr-ht-ecore's metapat |
| 39 |
|
// package by authn.MetaAuth, owned by that same [sr.ht] owner-name, and |
| 40 |
|
// carrying authn.ScopeRead — the same permission in meta's OAuth vocabulary |
| 41 |
|
// rather than in tokens.sr.ht's. The statuses are the other plane's arm for |
| 42 |
|
// arm; what differs is the permission a 403 names, because the two |
| 43 |
|
// vocabularies do not overlap. |
| 44 |
|
// - No cookie. An API client is not a browser. The unified-login cookie is |
| 45 |
|
// web/'s plane, and this endpoint is deliberately outside it — so the |
| 46 |
|
// principal is overwritten with the anonymous one when no bearer credential |
| 47 |
|
// is presented, rather than inherited from whatever middleware happens to |
| 48 |
|
// sit above the mount point. |
| 49 |
|
// |
| 50 |
|
// # Why this surface takes a meta PAT when no other one does |
| 51 |
|
// |
| 52 |
|
// Because of the gateway, and for no other reason. api.sr.ht forwards ONE client |
| 53 |
|
// "Authorization" header to every service a federated query touches — its |
| 54 |
|
// AuthMiddleware copies the client's header verbatim into the request context, |
| 55 |
|
// and the internal credential it can mint is used only to fetch schemas at |
| 56 |
|
// startup — so a federated caller arrives here holding whatever credential the |
| 57 |
|
// client had. The only credential that works across the whole instance is a meta |
| 58 |
|
// PAT, so an endpoint that refuses them can never be part of the gateway's |
| 59 |
|
// schema: federating it would be one `api-origin=` line that produced 401s. |
| 60 |
|
// |
| 61 |
|
// The REST write plane, /mcp and the push hook keep exactly one plane, |
| 62 |
|
// tokens.sr.ht's, because they have no such problem and a narrow, revocable, |
| 63 |
|
// short-lived grant is worth its cost there. They resolve identity through |
| 64 |
|
// authn.Resolver, which holds no MetaAuth and therefore cannot produce an |
| 65 |
|
// authn.PlaneMeta principal at all — the scope of the exception is structural |
| 66 |
|
// rather than a convention to remember. |
| 67 |
|
// |
| 68 |
|
// A PAT admitted here is an agent and never the owner, so webhook management |
| 69 |
|
// stays out of its reach: those mutations ask authn.Principal.IsOwner, which only |
| 70 |
|
// the unified-login cookie satisfies and which this endpoint reads none of. |
| 71 |
|
// |
| 72 |
|
// # What the cmd layer wires |
| 73 |
|
// |
| 74 |
|
// gql, err := graph.New(graph.Options{ |
| 75 |
|
// Reader: svc, // *service.Service |
| 76 |
|
// Searcher: index, // *search.Index |
| 77 |
|
// Resolver: svc.Resolver(), |
| 78 |
|
// Meta: metaAuth, // *authn.MetaAuth, this surface alone |
| 79 |
|
// }) |
| 80 |
|
// if err != nil { |
| 81 |
|
// return err |
| 82 |
|
// } |
| 83 |
|
// router.Handle("/query", gql) |
| 84 |
|
// |
| 85 |
|
// Server installs its own credential middleware, so it goes on the *anonymous* |
| 86 |
|
// router: core-go's server.WithSchema would mount it on the authenticated one, |
| 87 |
|
// whose auth.Middleware speaks meta's OAuth vocabulary and 401s anything else — |
| 88 |
|
// including the working token every other surface of this service takes. Serving |
| 89 |
|
// both planes from one endpoint is exactly what that middleware cannot do, which |
| 90 |
|
// is why this package resolves the credential itself. A service that mounts its |
| 91 |
|
// own /query owes the instance api-meta.json as well, because core-go serves that |
| 92 |
|
// file only for the schemas it hosts itself; sr-ht-ecore's apimeta package is |
| 93 |
|
// what serves it, and cmd/specsrht wires it beside the route with the scope list |
| 94 |
|
// GrantScopes derives from authn.ScopeRead. |
| 95 |
|
// |
| 96 |
|
// The router it is mounted on must carry core-go's config and database |
| 97 |
|
// middleware. The read path does not need either, but the webhook management |
| 98 |
|
// resolvers open transactions through core-go's database context, and |
| 99 |
|
// WithDefaultMiddleware installs that on the authenticated router only. |
| 100 |
|
package graph |
| 101 |
|
|
| 102 |
|
import ( |
| 103 |
|
"errors" |
| 104 |
|
"fmt" |
| 105 |
|
"log/slog" |
| 106 |
|
"net/http" |
| 107 |
|
|
| 108 |
|
"github.com/99designs/gqlgen/graphql" |
| 109 |
|
"github.com/99designs/gqlgen/graphql/handler" |
| 110 |
|
"github.com/99designs/gqlgen/graphql/handler/extension" |
| 111 |
|
"github.com/99designs/gqlgen/graphql/handler/transport" |
| 112 |
|
"go.bigb.es/auxilia/scribe" |
| 113 |
|
|
| 114 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/metapat" |
| 115 |
|
|
| 116 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 117 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/coreauth" |
| 118 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/graph/api" |
| 119 |
|
) |
| 120 |
|
|
| 121 |
|
// GrantScopes is what this service publishes in api-meta.json: the scopes a |
| 122 |
|
// meta.sr.ht personal access token can be minted for here. |
| 123 |
|
// |
| 124 |
|
// meta.sr.ht reads that file to build the checkboxes of its personal-token page, |
| 125 |
|
// prefixing each entry with the service's own name — so "SPECS" here is what |
| 126 |
|
// makes "spec.sr.ht/SPECS:RO" a token a human can actually obtain. An empty list, |
| 127 |
|
// which this service published until /query started accepting a PAT, does not |
| 128 |
|
// mean "a service with no scopes": it means no PAT can be scoped for this service |
| 129 |
|
// at all, so the credential the rest of the instance uses could never be |
| 130 |
|
// presented here even in principle. That was the stronger half of the refusal, |
| 131 |
|
// and the half no amount of code in this package could have worked around. |
| 132 |
|
// |
| 133 |
|
// It is derived from authn.ScopeRead rather than spelled a second time, because |
| 134 |
|
// the two spellings must agree: what meta's checkbox mints, and what |
| 135 |
|
// MetaAuth.VerifyToken checks. A scope published and not checked admits what |
| 136 |
|
// should have been refused, one checked and not published cannot be minted at |
| 137 |
|
// all, and neither failure is visible from inside a single file — so a test on |
| 138 |
|
// each side asserts the pair. |
| 139 |
|
// |
| 140 |
|
// This is meta.sr.ht's vocabulary. The other plane's permission is |
| 141 |
|
// authn.ActionRead ("spec:read"), in tokens.sr.ht's; the two grammars share a |
| 142 |
|
// token format and nothing else (sr-ht-ecore's grants and metapat packages). |
| 143 |
|
var GrantScopes = []string{metapat.ScopeName(authn.ScopeRead)} |
| 144 |
|
|
| 145 |
|
// Options is everything a Server needs. New says which one is missing rather |
| 146 |
|
// than failing later inside a resolver. |
| 147 |
|
type Options struct { |
| 148 |
|
// Reader is the orchestration layer. *service.Service satisfies it. |
| 149 |
|
Reader Reader |
| 150 |
|
|
| 151 |
|
// Searcher is the one global keyword index. *search.Index satisfies it. |
| 152 |
|
Searcher Searcher |
| 153 |
|
|
| 154 |
|
// Proposals is the proposal read side. It has no production implementation |
| 155 |
|
// yet — see the Proposals interface — so it is the one optional field here, |
| 156 |
|
// and while it is nil the `proposals` field of the schema fails with an |
| 157 |
|
// error saying so rather than answering "none". |
| 158 |
|
Proposals Proposals |
| 159 |
|
|
| 160 |
|
// Resolver verifies a presented tokens.sr.ht working token. Its cookie plane |
| 161 |
|
// is not used here: see the package comment. |
| 162 |
|
Resolver *authn.Resolver |
| 163 |
|
|
| 164 |
|
// Meta verifies a presented meta.sr.ht personal access token — the plane that |
| 165 |
|
// exists so this endpoint can be federated into api.sr.ht at all (see |
| 166 |
|
// authn.MetaAuth). |
| 167 |
|
// |
| 168 |
|
// Required, and unlike the working-token plane it needs no configuration to be |
| 169 |
|
// available: it depends on no per-instance origin, only on the meta.sr.ht |
| 170 |
|
// every SourceHut service already talks to. So there is no legitimate "this |
| 171 |
|
// instance has no such plane" case to tolerate a nil for, and an endpoint |
| 172 |
|
// built without it would answer 401 to the gateway while looking configured. |
| 173 |
|
Meta MetaAuthenticator |
| 174 |
|
} |
| 175 |
|
|
| 176 |
|
// Server is the /query endpoint: the executable schema behind the credential |
| 177 |
|
// gate. It is built once at startup and is safe for concurrent use. |
| 178 |
|
type Server struct { |
| 179 |
|
http http.Handler |
| 180 |
|
schema graphql.ExecutableSchema |
| 181 |
|
} |
| 182 |
|
|
| 183 |
|
// newSchema builds the executable schema over the seams in opts. |
| 184 |
|
// |
| 185 |
|
// It is unexported now that nothing outside this package wants a schema without |
| 186 |
|
// an endpoint. The daemon needs both — the endpoint to serve /query, and the |
| 187 |
|
// schema to hand to webhooks.NewQueue, which executes a subscription's stored |
| 188 |
|
// query at delivery time — and takes them from one Server, so that the two |
| 189 |
|
// cannot become two schemas. |
| 190 |
55 |
func newSchema(opts Options) (graphql.ExecutableSchema, error) { |
| 191 |
55 |
if opts.Reader == nil { |
| 192 |
1 |
return nil, fmt.Errorf("graph: Reader is required") |
| 193 |
1 |
} |
| 194 |
54 |
if opts.Searcher == nil { |
| 195 |
1 |
return nil, fmt.Errorf("graph: Searcher is required") |
| 196 |
1 |
} |
| 197 |
53 |
root := &Resolver{ |
| 198 |
53 |
reader: opts.Reader, |
| 199 |
53 |
searcher: opts.Searcher, |
| 200 |
53 |
proposals: opts.Proposals, |
| 201 |
53 |
} |
| 202 |
53 |
schema := api.NewExecutableSchema(api.Config{Resolvers: root}) |
| 203 |
53 |
// The root resolver holds the schema it is part of. The knot is deliberate: |
| 204 |
53 |
// the webhook resolvers validate and execute a subscriber's stored query |
| 205 |
53 |
// against this service's schema, and used to reach it through core-go's |
| 206 |
53 |
// server context — which exists on the authenticated router and nowhere |
| 207 |
53 |
// else. Holding it here is what lets /query move to the anonymous router |
| 208 |
53 |
// without those resolvers reaching for a context value that is not there. |
| 209 |
53 |
root.schema = schema |
| 210 |
53 |
return schema, nil |
| 211 |
|
} |
| 212 |
|
|
| 213 |
|
// New assembles the /query endpoint over the seams in opts. |
| 214 |
57 |
func New(opts Options) (*Server, error) { |
| 215 |
57 |
if opts.Resolver == nil { |
| 216 |
1 |
return nil, fmt.Errorf("graph: authn Resolver is required") |
| 217 |
1 |
} |
| 218 |
|
// The seam whose absence would not announce itself: an endpoint with no PAT |
| 219 |
|
// plane starts, serves every working token exactly as before, and answers 401 |
| 220 |
|
// to every federated query — a failure visible only from the gateway. |
| 221 |
56 |
if opts.Meta == nil { |
| 222 |
1 |
return nil, fmt.Errorf("graph: MetaAuthenticator is required") |
| 223 |
1 |
} |
| 224 |
55 |
schema, err := newSchema(opts) |
| 225 |
55 |
if err != nil { |
| 226 |
2 |
return nil, err |
| 227 |
2 |
} |
| 228 |
|
|
| 229 |
|
// The transport set is core-go's, not gqlgen's NewDefaultServer: POST and |
| 230 |
|
// introspection, and nothing else. NewDefaultServer would also install GET, |
| 231 |
|
// multipart upload and a websocket transport — a second way in, a file |
| 232 |
|
// upload path for a schema with no Upload scalar, and a subscription |
| 233 |
|
// transport for a schema with no subscriptions. Every SourceHut service on |
| 234 |
|
// this instance answers /query over POST, so a client that works against |
| 235 |
|
// one works against this. |
| 236 |
53 |
exec := handler.New(schema) |
| 237 |
53 |
exec.AddTransport(transport.POST{}) |
| 238 |
53 |
exec.Use(extension.Introspection{}) |
| 239 |
53 |
|
| 240 |
53 |
return &Server{ |
| 241 |
53 |
schema: schema, |
| 242 |
53 |
http: resolveCaller(opts.Resolver, opts.Meta, gate(coreContext(exec))), |
| 243 |
53 |
}, nil |
| 244 |
|
} |
| 245 |
|
|
| 246 |
|
// Schema is the executable schema this endpoint serves. The daemon hands it to |
| 247 |
|
// webhooks.NewQueue so that the query a subscriber stored is executed against |
| 248 |
|
// exactly the schema they wrote it for. |
| 249 |
0 |
func (s *Server) Schema() graphql.ExecutableSchema { return s.schema } |
| 250 |
|
|
| 251 |
|
// ServeHTTP serves /query behind the chain New built. |
| 252 |
75 |
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) } |
| 253 |
|
|
| 254 |
|
// resolveCaller turns the presented bearer credential into this request's |
| 255 |
|
// principal, or refuses the request. It is the whole credential plane of this |
| 256 |
|
// endpoint, and it is authn.Resolver's bearer arm and not its Middleware: |
| 257 |
|
// Middleware also reads the unified-login cookie, and a cookie is not a |
| 258 |
|
// credential here. |
| 259 |
|
// |
| 260 |
|
// A request with no Authorization header is given the anonymous principal |
| 261 |
|
// explicitly rather than being passed through untouched. That overwrite is the |
| 262 |
|
// "no cookie" rule made structural: mounted under a router that already |
| 263 |
|
// resolved a cookie identity, this endpoint still sees anonymous and still |
| 264 |
|
// answers 401. |
| 265 |
|
// |
| 266 |
|
// # Two planes, and which one gets the request |
| 267 |
|
// |
| 268 |
|
// This is where spec.sr.ht's /query differs from every other surface it has, and |
| 269 |
|
// the difference is api.sr.ht's. The gateway forwards ONE client "Authorization" |
| 270 |
|
// header to every service a federated query touches, so a federated caller |
| 271 |
|
// arrives holding whatever credential the client had — in practice a meta.sr.ht |
| 272 |
|
// personal access token, the only credential that works instance-wide. An |
| 273 |
|
// endpoint that refused those could not be federated at all, however correct its |
| 274 |
|
// refusal looked. |
| 275 |
|
// |
| 276 |
|
// So the plane is chosen by what was presented, with metapat.PlaneOf, which |
| 277 |
|
// decodes locally and asks no daemon anything: |
| 278 |
|
// |
| 279 |
|
// PlaneMeta the meta.sr.ht plane, scoped by authn.ScopeRead in meta's own |
| 280 |
|
// OAuth vocabulary |
| 281 |
|
// PlaneWorking the tokens.sr.ht plane, scoped by authn.ActionRead |
| 282 |
|
// PlaneUnknown the tokens.sr.ht plane as well — the credential is unreadable |
| 283 |
|
// (forged, corrupt or expired: DecodeBearerToken checks expiry |
| 284 |
|
// before it reports an issuer), both planes owe it the same 401, |
| 285 |
|
// and routing it to the one that already words that refusal |
| 286 |
|
// keeps one message rather than two |
| 287 |
|
// |
| 288 |
|
// Routing on the credential rather than on the failure of one plane is what lets |
| 289 |
|
// the meta plane work on an instance whose config.ini has no [tokens.sr.ht] |
| 290 |
|
// section at all: there is no working-token validator there to fail first, only |
| 291 |
|
// an authn.ErrNoAgentPlane that would have to be told apart from a real refusal. |
| 292 |
|
// |
| 293 |
|
// # What each plane is then asked |
| 294 |
|
// |
| 295 |
|
// Both answer the same three questions and only the vocabulary differs. Is the |
| 296 |
|
// credential live; does it belong to the one human this instance answers to; was |
| 297 |
|
// it minted for reading. The last is asked at different moments — the working |
| 298 |
|
// plane's grant in gate below, where the action is known, the PAT's scope inside |
| 299 |
|
// VerifyToken, because that plane guards this one read surface and nothing else — |
| 300 |
|
// and that is the only asymmetry between them. |
| 301 |
|
// |
| 302 |
|
// The statuses are authn.StatusFor's, which is the one table this service maps a |
| 303 |
|
// credential failure with, and it is one table across both planes on purpose: |
| 304 |
|
// 401 for a credential that does not verify — forged, expired, revoked, or issued |
| 305 |
|
// by somebody else — 403 for one that verifies and belongs to a human this |
| 306 |
|
// single-owner instance has nothing to grant, and 503 for a credential that could |
| 307 |
|
// not be *checked*. The last is not "your token is bad": answering 401 to a |
| 308 |
|
// restart of tokens.sr.ht, or to a meta.sr.ht that will not answer, tells every |
| 309 |
|
// agent to re-mint credentials that were never broken. |
| 310 |
|
// |
| 311 |
|
// The messages are written here from what the caller already knows, never from |
| 312 |
|
// the error's own text: authn's errors name usernames and token ids. |
| 313 |
53 |
func resolveCaller(rs *authn.Resolver, meta MetaAuthenticator, next http.Handler) http.Handler { |
| 314 |
75 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 315 |
75 |
presented := authn.BearerFromRequest(r) |
| 316 |
75 |
if presented == "" { |
| 317 |
8 |
next.ServeHTTP(w, r.WithContext( |
| 318 |
8 |
authn.WithPrincipal(r.Context(), authn.Anonymous()))) |
| 319 |
8 |
return |
| 320 |
8 |
} |
| 321 |
|
|
| 322 |
67 |
var ( |
| 323 |
67 |
p authn.Principal |
| 324 |
67 |
err error |
| 325 |
67 |
) |
| 326 |
67 |
if metapat.PlaneOf(presented) == metapat.PlaneMeta { |
| 327 |
9 |
// The credential and not the request: a plane that cannot see the |
| 328 |
9 |
// request cannot read a cookie off it, and the provenance headers are |
| 329 |
9 |
// a write's business, which this surface has none of. See |
| 330 |
9 |
// MetaAuthenticator. |
| 331 |
9 |
p, err = meta.VerifyToken(r.Context(), presented) |
| 332 |
58 |
} else { |
| 333 |
58 |
p, err = rs.ResolveAgent(r.Context(), presented, |
| 334 |
58 |
r.Header.Get(authn.HeaderAgent), r.Header.Get(authn.HeaderAgentSession)) |
| 335 |
58 |
} |
| 336 |
67 |
if err != nil { |
| 337 |
9 |
status := authn.StatusFor(err) |
| 338 |
9 |
if status >= http.StatusInternalServerError { |
| 339 |
0 |
// Fail closed and loudly. The alternative — degrading to |
| 340 |
0 |
// anonymous — would turn an unreachable tokens.sr.ht into every |
| 341 |
0 |
// agent silently losing its read access. |
| 342 |
0 |
slog.ErrorContext(r.Context(), "a bearer credential could not be checked", |
| 343 |
0 |
"component", "graph", "path", r.URL.Path, "status", status, scribe.Err(err)) |
| 344 |
0 |
} |
| 345 |
9 |
if status == http.StatusUnauthorized { |
| 346 |
4 |
// RFC 9110 requires the challenge on a 401, and every caller |
| 347 |
4 |
// here is a machine holding a bearer token: naming the scheme |
| 348 |
4 |
// and the realm is what tells it which credential was refused. |
| 349 |
4 |
w.Header().Set("WWW-Authenticate", authn.Challenge()) |
| 350 |
4 |
} |
| 351 |
9 |
http.Error(w, refusalMessage(status, err), status) |
| 352 |
9 |
return |
| 353 |
|
} |
| 354 |
58 |
next.ServeHTTP(w, r.WithContext(authn.WithPrincipal(r.Context(), p))) |
| 355 |
|
}) |
| 356 |
|
} |
| 357 |
|
|
| 358 |
|
// refusalMessage is what a refused caller is told. |
| 359 |
|
// |
| 360 |
|
// It is keyed on the status, so that nothing about whose token it was, or whether |
| 361 |
|
// a row exists, leaks to a caller holding a credential this service did not |
| 362 |
|
// accept. The one thing it reads off the error is authn.ErrMissingScope, and that |
| 363 |
|
// is not a hole in the rule: what it adds is a compile-time constant naming a |
| 364 |
|
// permission the caller can go and tick a box for, which is precisely what gate |
| 365 |
|
// below already does for the other plane's grant. It says nothing about the |
| 366 |
|
// credential, the account or the database. |
| 367 |
|
// |
| 368 |
|
// Without that arm a PAT missing the scope would be told "this token does not |
| 369 |
|
// authorize requests to spec.sr.ht" and have nowhere to go: meta's personal-token |
| 370 |
|
// page is a list of checkboxes, and a client that is not told which one it lacks |
| 371 |
|
// cannot ask for it. |
| 372 |
9 |
func refusalMessage(status int, err error) string { |
| 373 |
9 |
switch status { |
| 374 |
4 |
case http.StatusUnauthorized: |
| 375 |
4 |
return "the bearer token presented was refused" |
| 376 |
5 |
case http.StatusForbidden: |
| 377 |
5 |
if errors.Is(err, authn.ErrMissingScope) { |
| 378 |
3 |
return "this personal access token does not carry " + authn.ScopeRead |
| 379 |
3 |
} |
| 380 |
2 |
return "this token does not authorize requests to " + authn.ConfigSection |
| 381 |
0 |
default: |
| 382 |
0 |
return "the credential could not be verified, try again" |
| 383 |
|
} |
| 384 |
|
} |
| 385 |
|
|
| 386 |
|
// gate refuses a caller with no read authority before the query is parsed. |
| 387 |
|
// |
| 388 |
|
// The ACL is authn.Principal.CanRead — the owner and its agents may read and |
| 389 |
|
// nobody else may — the same predicate web/ and mcpsrv/ apply, so the three read |
| 390 |
|
// surfaces cannot drift into three policies, which is how a corpus leaks. On |
| 391 |
|
// this endpoint the owner half of it is unreachable in practice: the owner is |
| 392 |
|
// recognised by a cookie, and resolveCaller above accepts none. It is asked |
| 393 |
|
// anyway because it is the shared predicate and not this endpoint's own. |
| 394 |
|
// |
| 395 |
|
// A caller that clears it and authenticated with a tokens.sr.ht working token |
| 396 |
|
// must also hold spec:read — the grant half of the same question, asked here |
| 397 |
|
// because here is where the action ("read") is known. |
| 398 |
|
// |
| 399 |
|
// A caller on the meta.sr.ht plane passes that half untouched, and has already |
| 400 |
|
// answered it. Principal.Authorize is a no-op for authn.PlaneMeta because no PAT |
| 401 |
|
// can carry "spec:read" at all — asking would refuse every one of them — so the |
| 402 |
|
// equivalent question was put to it in meta's own vocabulary at resolution, where |
| 403 |
|
// authn.ScopeRead is what it had to carry. The permission is checked exactly once |
| 404 |
|
// on either plane; only the moment and the grammar differ. |
| 405 |
|
// |
| 406 |
|
// It is one check at the boundary rather than one per field, because every read |
| 407 |
|
// field of this schema is a read and the surface has one action. The webhook |
| 408 |
|
// mutations must NOT rely on it: they would be admitted by a read grant, which |
| 409 |
|
// is not what a read grant says, so they carry their own owner gate in the |
| 410 |
|
// resolver. |
| 411 |
|
// |
| 412 |
|
// The refusal is a 401 — or a 403 for the missing grant — with a line of text |
| 413 |
|
// and never a redirect to meta's login: every caller here is a machine, and |
| 414 |
|
// handing a bot 200 and a page of login markup tells it nothing it can act on. |
| 415 |
|
// The two statuses stay apart because retrying is worth it for one and never for |
| 416 |
|
// the other. |
| 417 |
59 |
func gate(next http.Handler) http.Handler { |
| 418 |
72 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 419 |
72 |
p := authn.PrincipalFromContext(r.Context()) |
| 420 |
72 |
if !p.CanRead() { |
| 421 |
9 |
w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 422 |
9 |
w.Header().Set("WWW-Authenticate", authn.Challenge()) |
| 423 |
9 |
http.Error(w, "authentication required", http.StatusUnauthorized) |
| 424 |
9 |
return |
| 425 |
9 |
} |
| 426 |
63 |
if err := p.Authorize(authn.ActionRead); err != nil { |
| 427 |
2 |
w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 428 |
2 |
http.Error(w, "this token does not grant "+authn.ActionRead, http.StatusForbidden) |
| 429 |
2 |
return |
| 430 |
2 |
} |
| 431 |
61 |
next.ServeHTTP(w, r) |
| 432 |
|
}) |
| 433 |
|
} |
| 434 |
|
|
| 435 |
|
// coreContext derives core-go's AuthContext from the principal the gate has |
| 436 |
|
// already admitted, because core-go's webhook engine reads one out of the |
| 437 |
|
// context and this endpoint no longer runs behind the middleware that puts it |
| 438 |
|
// there. |
| 439 |
|
// |
| 440 |
|
// The user id is the credential's own: authn resolved the token's owner to a |
| 441 |
|
// local row and refused the token outright if that row had no id, so there is |
| 442 |
|
// nothing to substitute and no default to invent here. |
| 443 |
53 |
func coreContext(next http.Handler) http.Handler { |
| 444 |
57 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 445 |
57 |
p := authn.PrincipalFromContext(r.Context()) |
| 446 |
57 |
next.ServeHTTP(w, r.WithContext(coreauth.Context(r.Context(), p, p.UserID))) |
| 447 |
57 |
}) |
| 448 |
|
} |