~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/mcpsrv/grant_internal_test.go -rw-r--r-- 7.2 KiB
c7477607 — Eugene Blikh authn: accept tokens.sr.ht working tokens beside the agent token 10 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
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
}

// localPrincipal is agentPrincipal as spec's own agent_token resolves it.
func localPrincipal() authn.Principal {
	p := agentPrincipal()
	p.Plane = authn.PlaneLocal
	return p
}

// 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 credential that carries no grants passes untouched, which is what
// keeps the agents configured today working: the local agent token and the
// owner's cookie have nothing to check.
func TestReadToolsPassEveryUngrantedCredential(t *testing.T) {
	for name, p := range map[string]authn.Principal{
		"local agent token":            localPrincipal(),
		"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 — the local agent token above
// all, which carries no grants and must keep working exactly as it does today.
func TestProposeToolAcceptsTheGrantedCredentials(t *testing.T) {
	svc := realWriter(t)

	for name, p := range map[string]authn.Principal{
		"local agent token":            localPrincipal(),
		"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 TestProposeToolStillDemandsProvenanceOnBothPlanes(t *testing.T) {
	svc := realWriter(t)

	for name, p := range map[string]authn.Principal{
		"local":    localPrincipal(),
		"instance": 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)
		})
	}
}