~bigbes/sr-ht-spec

ref: 9cec0f542b65dc63883514cd54dc664f8c00157c sr-ht-spec/mcpsrv/grant_internal_test.go -rw-r--r-- 7.3 KiB
9cec0f54 — Eugene Blikh deps: tidy after the third uplift 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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
package mcpsrv

import (
	"context"
	"database/sql"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

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

// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
	t.Helper()
	g, err := grants.Parse(s)
	require.NoError(t, err, "parse grants %q", s)
	return g
}

// instancePrincipal is agentPrincipal as a tokens.sr.ht working token resolves
// it: the same agent, with a grant set behind it.
func instancePrincipal(t *testing.T, grantString string) authn.Principal {
	t.Helper()
	p := agentPrincipal()
	p.Plane = authn.PlaneInstance
	p.Grants = mustGrants(t, grantString)
	return p
}

// cliPrincipal is agentPrincipal as `specsrht doc propose` builds it: an agent
// a local process asserted, on no credential plane and therefore with no grant
// set. The resolver never produces one — it is why Plane outlived the local
// agent-token plane it used to distinguish.
func cliPrincipal() authn.Principal { return agentPrincipal() }

// Every tool that serves content asks for spec:read, and asks for it per tool
// rather than at the Gate — /mcp carries the write tools too, and a surface-wide
// read grant would refuse a propose-only token at `initialize`, before it named
// anything.
func TestReadToolsRequireTheReadGrant(t *testing.T) {
	refused := instancePrincipal(t, "spec:propose")

	t.Run("spec_read", func(t *testing.T) {
		ctx := authn.WithPrincipal(context.Background(), refused)
		_, err := readHandler(ctx, Backend{}, readInput{Space: "~bigbes/rfcs", Document: "SPEC-0007"})
		assert.ErrorIs(t, err, authn.ErrMissingGrant)
	})

	t.Run("spec_list", func(t *testing.T) {
		ctx := authn.WithPrincipal(context.Background(), refused)
		_, err := listHandler(ctx, Backend{}, listInput{})
		assert.ErrorIs(t, err, authn.ErrMissingGrant)
	})

	t.Run("spec_search", func(t *testing.T) {
		ctx := authn.WithPrincipal(context.Background(), refused)
		_, err := searchHandler(ctx, Backend{}, searchInput{Query: "storage"})
		assert.ErrorIs(t, err, authn.ErrMissingGrant)
	})

	t.Run("spec_comment", func(t *testing.T) {
		ctx := authn.WithPrincipal(context.Background(), refused)
		_, err := commentHandler(ctx, commentFixture(t), commentInput{Proposal: 7})
		assert.ErrorIs(t, err, authn.ErrMissingGrant)
	})
}

// And every principal that carries no grants passes untouched: the owner's
// cookie is a person rather than a machine credential, and the CLI's agent
// presented nothing to have a grant clipped out of.
func TestReadToolsPassEveryUngrantedCredential(t *testing.T) {
	for name, p := range map[string]authn.Principal{
		"locally asserted CLI agent":   cliPrincipal(),
		"owner cookie":                 {Kind: authn.KindOwner, Owner: "bigbes"},
		"instance token, spec:read":    instancePrincipal(t, "spec:read"),
		"instance token, universal":    instancePrincipal(t, "*"),
		"instance token, both actions": instancePrincipal(t, "spec:read spec:propose"),
	} {
		t.Run(name, func(t *testing.T) {
			ctx := authn.WithPrincipal(context.Background(), p)
			assert.NoError(t, requireRead(ctx))

			// Listing threads goes all the way through with a fake service.
			out, err := commentHandler(ctx, commentFixture(t), commentInput{Proposal: 7})
			require.NoError(t, err)
			assert.NotEmpty(t, out.Threads)
		})
	}
}

// realWriter builds an actual *service.Service over a database nothing answers
// on, so that spec_propose's grant check here is service.Propose's own and not a
// copy of it. Propose refuses a principal before it opens anything, which is
// what makes an unreachable pool enough.
func realWriter(t *testing.T) *service.Service {
	t.Helper()
	root := t.TempDir()
	pool, err := sql.Open("postgres",
		"postgres://nobody@127.0.0.1:1/nothing?sslmode=disable&connect_timeout=1")
	require.NoError(t, err)
	t.Cleanup(func() { pool.Close() })

	svc, err := service.New(service.Config{
		Repos:            filepath.Join(root, "repos"),
		Cache:            filepath.Join(root, "cache"),
		Origin:           "https://spec.srht.bigb.es",
		ConnectionString: "postgres://nobody@127.0.0.1:1/nothing",
		Instance: authn.Instance{
			OwnerName:  "bigbes",
			OwnerEmail: "bigbes@gmail.com",
			AgentEmail: "agent@spec.srht.bigb.es",
		},
	}, pool)
	require.NoError(t, err)
	return svc
}

// proposeCall is one well-formed spec_propose, so the only thing a test varies
// is the credential behind it.
func proposeCall() proposeInput {
	return proposeInput{
		Space:     "~bigbes/rfcs",
		IfMatch:   "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809",
		Title:     "Add a note",
		Message:   "write it",
		Documents: []proposeDoc{{Path: "notes/a.md", Content: "---\nid: N-1\n---\n"}},
	}
}

// spec_propose asks for spec:propose, through the same service.Propose the REST
// surface calls — the rule is spelled once, below both write surfaces, and MCP
// gets it by going through it rather than by repeating it here.
func TestProposeToolRequiresTheProposeGrant(t *testing.T) {
	svc := realWriter(t)

	for _, grantString := range []string{"spec:read", "bench:upload"} {
		t.Run(grantString, func(t *testing.T) {
			ctx := authn.WithPrincipal(context.Background(), instancePrincipal(t, grantString))
			_, err := proposeHandler(ctx, svc, proposeCall())
			require.Error(t, err)
			assert.ErrorIs(t, err, service.ErrForbidden,
				"the refusal is the one this surface already uses for an unauthorised call")
			assert.ErrorIs(t, err, authn.ErrMissingGrant)
		})
	}
}

// And every credential that may propose clears it.
func TestProposeToolAcceptsTheGrantedCredentials(t *testing.T) {
	svc := realWriter(t)

	for name, p := range map[string]authn.Principal{
		"locally asserted CLI agent":   cliPrincipal(),
		"instance token, spec:propose": instancePrincipal(t, "spec:propose"),
		"instance token, universal":    instancePrincipal(t, "*"),
	} {
		t.Run(name, func(t *testing.T) {
			ctx := authn.WithPrincipal(context.Background(), p)
			// The write cannot land — there is no space and no database — but it
			// must not be turned away at the ACL.
			_, err := proposeHandler(ctx, svc, proposeCall())
			require.Error(t, err, "the fixture has no space, so this cannot succeed")
			assert.NotErrorIs(t, err, service.ErrForbidden, "%s must clear the write ACL", name)
			assert.NotErrorIs(t, err, authn.ErrMissingGrant)
		})
	}
}

// A grant is not a substitute for provenance. An instance token with the widest
// grant there is still cannot write without saying who wrote it — the refusal
// changes from authorization to provenance, and does not go away.
func TestProposeToolStillDemandsProvenance(t *testing.T) {
	svc := realWriter(t)

	for name, p := range map[string]authn.Principal{
		"locally asserted CLI agent": cliPrincipal(),
		"instance token":             instancePrincipal(t, "*"),
	} {
		t.Run(name, func(t *testing.T) {
			noSession := p
			noSession.Session = ""
			ctx := authn.WithPrincipal(context.Background(), noSession)

			_, err := proposeHandler(ctx, svc, proposeCall())
			require.Error(t, err)
			assert.NotErrorIs(t, err, service.ErrForbidden,
				"a missing session is a provenance failure, not an authorization one")

			_, err = noSession.AgentWriteFor("1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809")
			assert.ErrorIs(t, err, authn.ErrMissingProvenance)
		})
	}
}