~bigbes/sr-ht-spec

ref: 0a32fd7a58ef967cab397053f9f3b59abd719375 sr-ht-spec/graph/server.go -rw-r--r-- 7.0 KiB
0a32fd7a — Eugene Blikh logging: take the instance's log policy from ecore 9 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
// Package graph is spec.sr.ht's GraphQL read schema, served at /query.
//
// # Read only, deliberately
//
// There are no mutations here. The design defers them until the proposal state
// machine has settled, and the reason is technical rather than scope
// discipline: the write plane's concurrency story is `If-Match: <base-rev>`, an
// HTTP idiom with well-defined 409 semantics that agents get right by default,
// and a type that has been federated into api.sr.ht is a consumed contract —
// expensive to churn. Read types (space, document, project, search) are stable
// from the start; the review types are not, and that is where the line is
// drawn.
//
// Serving GraphQL at all is justified without federation: Phase 5's webhooks
// are GraphQL-native so gqlgen arrives regardless, and this is the read surface
// anything on the instance that already speaks SourceHut GraphQL can consume.
// Federating into api.sr.ht is then one `api-origin=` line on the gateway that
// nothing here depends on — `hut` builds its endpoint from the per-service
// origin and talks to this /query directly either way.
//
// # Who may read
//
// The read plane is fail-closed and this is the same one-line ACL web/ applies:
// the instance owner and its agents may read, and nobody else may. A viewer
// with no read authority gets 401 before the query is parsed — including for
// introspection, which is why a gateway federating this schema has to present a
// token like any other client.
//
// # What the cmd layer must wire
//
//	gql, err := graph.New(graph.Options{
//		Reader:   svc,             // *service.Service
//		Searcher: index,           // *search.Index
//		Resolver: svc.Resolver(),
//	})
//	if err != nil {
//		return err
//	}
//	router.Handle("/query", gql.Handler())
//
// [Server.Handler] installs authn's principal middleware itself, so it can be
// mounted on a router that has none. A caller whose router already resolves a
// principal uses [Server.Endpoint] instead.
package graph

import (
	"fmt"
	"net/http"

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

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

// Options is everything a Server needs. New says which one is missing rather
// than failing later inside a resolver.
type Options struct {
	// Reader is the orchestration layer. *service.Service satisfies it.
	Reader Reader

	// Searcher is the one global keyword index. *search.Index satisfies it.
	Searcher Searcher

	// Proposals is the proposal read side. It has no production implementation
	// yet — see the Proposals interface — so it is the one optional field here,
	// and while it is nil the `proposals` field of the schema fails with an
	// error saying so rather than answering "none".
	Proposals Proposals

	// Resolver turns the unified-login cookie or an agent bearer token into a
	// principal. Handler installs its middleware; Endpoint does not.
	Resolver *authn.Resolver
}

// Server is the /query endpoint: the executable schema plus the read gate. It
// is built once at startup and is safe for concurrent use.
type Server struct {
	exec     http.Handler
	resolver *authn.Resolver
}

// NewSchema builds the executable schema over the seams in opts. The daemon
// hands it to core-go's server.WithSchema (to serve /query) and to
// webhooks.NewQueue (which executes a subscription's stored query against it at
// delivery time), so both share exactly one schema.
func NewSchema(opts Options) (graphql.ExecutableSchema, error) {
	if opts.Reader == nil {
		return nil, fmt.Errorf("graph: Reader is required")
	}
	if opts.Searcher == nil {
		return nil, fmt.Errorf("graph: Searcher is required")
	}
	root := &Resolver{
		reader:    opts.Reader,
		searcher:  opts.Searcher,
		proposals: opts.Proposals,
	}
	return api.NewExecutableSchema(api.Config{Resolvers: root}), nil
}

// New assembles the executable schema over the seams in opts.
func New(opts Options) (*Server, error) {
	if opts.Resolver == nil {
		return nil, fmt.Errorf("graph: authn Resolver is required")
	}
	schema, err := NewSchema(opts)
	if err != nil {
		return nil, err
	}

	// The transport set is core-go's, not gqlgen's NewDefaultServer: POST and
	// introspection, and nothing else. NewDefaultServer would also install GET,
	// multipart upload and a websocket transport — a second way in, a file
	// upload path for a schema with no Upload scalar, and a subscription
	// transport for a schema with no subscriptions. Every SourceHut service on
	// this instance answers /query over POST, so a client that works against
	// one works against this.
	exec := handler.New(schema)
	exec.AddTransport(transport.POST{})
	exec.Use(extension.Introspection{})

	return &Server{exec: exec, resolver: opts.Resolver}, nil
}

// Handler is the /query handler with authn's principal middleware installed, so
// it can be mounted on a router that has none:
//
//	router.Handle("/query", gql.Handler())
//
// Installing that middleware twice is harmless — it is idempotent — so a router
// that already applies it may use this too.
func (s *Server) Handler() http.Handler {
	return s.resolver.Middleware()(s.Endpoint())
}

// Endpoint is the /query handler without any middleware of its own. The router
// it is mounted on must already resolve a principal into the request context
// (authn.Resolver.Middleware), or every caller looks anonymous and is refused.
func (s *Server) Endpoint() http.Handler {
	return gate(s.exec)
}

// gate refuses a caller with no read authority before the query is parsed.
//
// The ACL is authn.Principal.CanRead — the owner and its agents may read and
// nobody else may — the same predicate web/ and mcpsrv/ apply, so the three read
// surfaces cannot drift into three policies, which is how a corpus leaks.
//
// A caller that clears it and authenticated with a tokens.sr.ht working token
// must also hold spec:read — the grant half of the same question, asked here
// because here is where the action ("read") is known. It is a no-op for the
// owner's cookie and for the local agent token, neither of which carries grants.
//
// The refusal is a 401 — or a 403 for the missing grant — with a line of text
// and never a redirect to meta's login: every caller here is a machine, and
// handing a bot 200 and a page of login markup tells it nothing it can act on.
// The two statuses stay apart because retrying is worth it for one and never for
// the other.
func gate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := authn.PrincipalFromContext(r.Context())
		if !p.CanRead() {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
			http.Error(w, "authentication required", http.StatusUnauthorized)
			return
		}
		if err := p.Authorize(authn.ActionRead); err != nil {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
			http.Error(w, "this token does not grant "+authn.ActionRead, http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}