~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/graph/webhook_authz_test.go -rw-r--r-- 8.0 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day 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 graph

import (
	"context"
	"net/http"
	"testing"

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

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	coreerrors "sourcecraft.dev/bigbes/sr-ht-core/errors"

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

// The webhook chapter of this schema is owner-only, and an agent token is not
// the owner however wide its grant set.
//
// This is the property the conversion had to preserve and briefly did not.
// Before /query moved to the anonymous router it sat behind core-go's
// auth.Middleware and the daemon's ownerOnly wrapper, which refused every caller
// whose username was not the instance owner. The move admits tokens.sr.ht
// working tokens, and for a while the resolvers asked only whether the caller
// was AUTH_INTERNAL — which coreauth answers "yes" for an agent too. A token
// carrying nothing but spec:read could therefore create and delete
// subscriptions: a read grant buying a mutation.

// webhookFields is every field guarded by webhookAuthorized. The two mutations
// are the dangerous half, but the queries are listed with them on purpose: they
// serve a subscription's URL, its stored query and its delivery bodies, and they
// were behind the same wall before the conversion.
var webhookFields = map[string]string{
	"createUserWebhook": `mutation { createUserWebhook(config: {url: "https://example.invalid/h", ` +
		`events: [PROPOSAL_OPENED], query: "query { webhook { uuid } }"}) { id } }`,
	"deleteUserWebhook": `mutation { deleteUserWebhook(id: 1) { id } }`,
	"userWebhooks":      `{ userWebhooks { results { id url query } } }`,
	"userWebhook":       `{ userWebhook(id: 1) { id url query } }`,
}

// An agent reaches /query — that is what the conversion was for — and is refused
// every webhook field, whether its token names the read grant or the universal
// one.
//
// The refusal has to come from webhookAuthorized and not from a missing
// database: the harness wires no core-go database context at all, so a resolver
// that got past the ACL would panic in database.WithTx rather than answer an
// error. That is why the assertions check the message.
func TestAgentTokenIsRefusedEveryWebhookField(t *testing.T) {
	for _, grantString := range []string{authn.ActionRead, "*"} {
		t.Run("token granting "+grantString, func(t *testing.T) {
			for name, q := range webhookFields {
				t.Run(name, func(t *testing.T) {
					h := newHarness(t, false)
					r := post(t, h, q, func(req *http.Request) {
						req.Header.Set("Authorization", "Bearer "+agentToken(grantString))
					})

					// The gate admits the caller: this is an authorization
					// failure inside the schema, not a refusal at the door.
					require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
					assert.Contains(t, r.errText(), "Access denied",
						"an agent must not reach the webhook surface")
					assert.NotContains(t, r.body, "example.invalid",
						"the refusal leaked a subscription URL")
				})
			}
		})
	}
}

// The same agent still reads. The point of the fix is that the webhook chapter
// closed, not that the endpoint did — a token carrying spec:read is exactly the
// credential /query was converted to accept.
func TestTheSameAgentTokenStillReads(t *testing.T) {
	h := newHarness(t, false)
	r := post(t, h, probeQuery, readToken)

	require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
	assert.Empty(t, r.errText())
	assert.Contains(t, string(r.Data), "~bigbes/rfcs")
}

// The owner half of the rule, at the only level where it can be observed.
//
// There is no HTTP test for it and there cannot be one: authn produces
// authn.KindOwner from the unified-login cookie alone, and this endpoint reads
// no cookie, so no request it accepts resolves to the owner. The guard is
// written against the right predicate anyway — see webhookAuthorized — and this
// is what pins it, so that a credential plane which does yield the owner is
// admitted the moment it exists.
func TestWebhookAuthorizedAdmitsTheOwnerAndNobodyElse(t *testing.T) {
	const ownerUserID = 42

	cases := []struct {
		name      string
		principal authn.Principal
		wantErr   bool
	}{
		{
			"the owner",
			authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"},
			false,
		},
		{
			// The case the fix is about. coreauth maps this to AUTH_INTERNAL
			// exactly like the owner, so a check that read only the AuthContext
			// would admit it.
			"an agent on a working token with the universal grant",
			authn.Principal{
				Kind: authn.KindAgent, Owner: "bigbes", Plane: authn.PlaneInstance,
				Grants: mustGrants(t, "*"), UserID: ownerUserID,
			},
			true,
		},
		{
			"an agent a local process asserted",
			authn.Principal{Kind: authn.KindAgent, Owner: "bigbes"},
			true,
		},
		{
			"anonymous",
			authn.Anonymous(),
			true,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ctx := authn.WithPrincipal(context.Background(), tc.principal)
			ctx = coreauth.Context(ctx, tc.principal, ownerUserID)

			err := webhookAuthorized(ctx)
			if tc.wantErr {
				require.Error(t, err)
				assert.True(t, coreerrors.Is(err, coreerrors.ErrAccessDenied),
					"want ERR_ACCESS_DENIED, got %v", err)
				return
			}
			assert.NoError(t, err)
		})
	}
}

// coreauth maps the owner and an agent to the same AuthMethod, which is why the
// principal and not the AuthContext is what webhookAuthorized asks. Asserted
// rather than described: if this ever stops being true, the comment explaining
// why the guard is written the way it is stops being true with it.
func TestCoreauthCannotTellTheOwnerFromAnAgent(t *testing.T) {
	const ownerUserID = 7
	owner := coreauth.Derive(authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}, ownerUserID)
	agent := coreauth.Derive(authn.Principal{Kind: authn.KindAgent, Owner: "bigbes"}, ownerUserID)

	assert.Equal(t, auth.AUTH_INTERNAL, owner.AuthMethod)
	assert.Equal(t, owner.AuthMethod, agent.AuthMethod,
		"if these ever differ, webhookAuthorized could read the AuthContext again")
	assert.Equal(t, owner.UserID, agent.UserID)
}

// A refusal must name the field it refused, and two refusals in one process must
// not share an error object.
//
// coreerrors.ErrAccessDenied is a package-level *gqlerror.Error and gqlgen
// assigns the field path onto whatever error it is handed, so returning that
// singleton makes every later refusal in the daemon report the path of an
// earlier one — a wrong answer, a cross-request leak of which field somebody
// else asked for, and a data race on a global. Found by driving a live daemon:
// a refused deleteUserWebhook came back as "path":["createUserWebhook"].
func TestARefusalNamesItsOwnField(t *testing.T) {
	h := newHarness(t, false)

	// createUserWebhook first, deliberately: it is the path that used to be
	// stamped onto the shared object and inherited by everything after it.
	first := post(t, h, webhookFields["createUserWebhook"], readToken)
	require.Contains(t, first.errText(), "Access denied", "body %s", first.body)

	for _, field := range []string{"deleteUserWebhook", "userWebhooks", "userWebhook"} {
		t.Run(field, func(t *testing.T) {
			r := post(t, h, webhookFields[field], readToken)

			require.Contains(t, r.errText(), "Access denied", "body %s", r.body)
			assert.Contains(t, r.body, `"path":["`+field+`"]`,
				"the refusal reported another field's path")
			assert.NotContains(t, r.body, `"path":["createUserWebhook"]`,
				"a shared error object carried the first refusal's path")
		})
	}
}

// The one webhook field with no ACL of its own. It reads the payload of the
// delivery being rendered, and outside a webhook context there is none, so it
// answers an error rather than anything about this instance's subscriptions.
// Left ungated deliberately; asserted so that "it leaks nothing" is measured.
func TestWebhookPayloadFieldLeaksNothingOverHTTP(t *testing.T) {
	h := newHarness(t, false)
	r := post(t, h, `{ webhook { uuid event } }`, readToken)

	require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
	assert.Contains(t, r.errText(), "without an active webhook context")
}