M authn/principal.go => authn/principal.go +8 -0
@@ 83,6 83,14 @@ func (p Principal) IsOwner() bool { return p.Kind == KindOwner }
// proposals/*.
func (p Principal) IsAgent() bool { return p.Kind == KindAgent }
+// CanRead reports whether this principal may read content: the owner and its
+// agents may, nobody else may. This is the whole read-plane ACL — one human, no
+// visibility levels, and a non-owner human already resolved to anonymous by
+// authn — and it lives here, in one place, because every read surface (graph's
+// /query, the web UI, the MCP tools) must apply the identical policy: two read
+// surfaces with two spellings of it is how a corpus leaks.
+func (p Principal) CanRead() bool { return p.IsOwner() || p.IsAgent() }
+
// String renders the principal for logs. It never includes the token name's
// secret (there is none — the name is not the token) and never includes the
// cookie value.
A authn/principal_test.go => authn/principal_test.go +27 -0
@@ 0,0 1,27 @@
+package authn
+
+import "testing"
+
+// TestCanRead pins the read-plane ACL every surface shares: the owner and its
+// agents may read, and every other principal — including an unrecognised or
+// zero Kind — is denied rather than accidentally admitted.
+func TestCanRead(t *testing.T) {
+ cases := []struct {
+ name string
+ p Principal
+ want bool
+ }{
+ {"owner may read", Principal{Kind: KindOwner}, true},
+ {"agent may read", Principal{Kind: KindAgent}, true},
+ {"anonymous may not", Anonymous(), false},
+ {"zero value may not", Principal{}, false},
+ {"unknown kind may not", Principal{Kind: "wat"}, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.p.CanRead(); got != tc.want {
+ t.Errorf("CanRead() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
M graph/server.go => graph/server.go +4 -7
@@ 146,19 146,16 @@ func (s *Server) Endpoint() http.Handler {
// gate refuses a caller with no read authority before the query is parsed.
//
-// One human, no visibility levels: the owner and its agents may read and nobody
-// else may. A logged-in human who is not the instance owner has already been
-// resolved to anonymous by authn, so this is the whole ACL — the same one web/
-// applies, spelled the same way, because two read surfaces with two policies is
-// how a corpus leaks.
+// 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.
//
// The refusal is a 401 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.
func gate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- p := authn.PrincipalFromContext(r.Context())
- if !p.IsOwner() && !p.IsAgent() {
+ if !authn.PrincipalFromContext(r.Context()).CanRead() {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.Error(w, "authentication required", http.StatusUnauthorized)
return
M mcpsrv/mcpsrv.go => mcpsrv/mcpsrv.go +5 -6
@@ 239,10 239,10 @@ func allowHosts(next http.Handler, origin string) http.Handler {
}
// Gate refuses a caller with no read authority before any MCP method — not just
-// tools/call, but initialize and tools/list too — reaches the server. It is the
-// read plane's ACL for this surface: the owner and its agents may read and
-// nobody else may, the same policy graph's /query and the web UI apply, spelled
-// the same way. Two read surfaces with two policies is how a corpus leaks.
+// tools/call, but initialize and tools/list too — reaches the server. The ACL
+// is authn.Principal.CanRead — the owner and its agents may read and nobody else
+// may — the same predicate graph's /query and the web UI apply, so the three
+// read surfaces cannot drift into three policies, which is how a corpus leaks.
//
// It reads the principal the resolver middleware set, so it must be mounted
// INSIDE that middleware:
@@ 255,8 255,7 @@ func allowHosts(next http.Handler, origin string) http.Handler {
// line of plain text and never a login redirect: every caller here is a machine.
func Gate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- p := authn.PrincipalFromContext(r.Context())
- if !p.IsOwner() && !p.IsAgent() {
+ if !authn.PrincipalFromContext(r.Context()).CanRead() {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.Error(w, "authentication required", http.StatusUnauthorized)
return
M web/handlers.go => web/handlers.go +4 -7
@@ 51,14 51,11 @@ func splitFormat(rest string) (string, format) {
// ---- authorization --------------------------------------------------------
-// mayRead reports whether a request carries authority to read content.
-//
-// One human, no visibility levels: the owner and its agents may read and nobody
-// else may. A logged-in human who is not the instance owner has already been
-// resolved to anonymous by authn, so this is the whole ACL.
+// mayRead reports whether a request carries authority to read content. The ACL
+// itself — owner and its agents, nobody else — is authn.Principal.CanRead, the
+// one spelling every read surface shares.
func mayRead(r *http.Request) bool {
- p := authn.PrincipalFromContext(r.Context())
- return p.IsOwner() || p.IsAgent()
+ return authn.PrincipalFromContext(r.Context()).CanRead()
}
// denyRead answers a viewer with no read authority in the shape their client