Go · 14697 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package policy_test
4
5 import (
6 "context"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/tenseleyFlow/shithub/internal/auth/policy"
13 )
14
15 // The matrix is built from three orthogonal axes: actor archetype,
16 // resource shape, and Action. We enumerate every combination and
17 // assert the verdict so a future refactor that breaks one cell fails
18 // loudly.
19
20 type actorKind int
21
22 const (
23 actorAnonymous actorKind = iota
24 actorOwner
25 actorCollabRead
26 actorCollabTriage
27 actorCollabWrite
28 actorCollabMaintain
29 actorCollabAdmin
30 actorUnrelated
31 actorSuspendedOwner
32 actorSuspendedCollabWrite
33 actorSiteAdmin
34 )
35
36 func (k actorKind) String() string {
37 return [...]string{
38 "anonymous", "owner",
39 "collab-read", "collab-triage", "collab-write", "collab-maintain", "collab-admin",
40 "unrelated", "suspended-owner", "suspended-collab-write", "site-admin",
41 }[k]
42 }
43
44 type repoKind int
45
46 const (
47 repoPublic repoKind = iota
48 repoPrivate
49 repoArchivedPublic
50 repoArchivedPrivate
51 repoDeletedPublic
52 )
53
54 func (k repoKind) String() string {
55 return [...]string{"public", "private", "archived-public", "archived-private", "deleted-public"}[k]
56 }
57
58 const (
59 ownerID int64 = 100
60 otherID int64 = 200
61 repoIDV int64 = 1000
62 )
63
64 func makeActor(k actorKind) policy.Actor {
65 switch k {
66 case actorAnonymous:
67 return policy.AnonymousActor()
68 case actorOwner:
69 return policy.UserActor(ownerID, "owner", false, false)
70 case actorCollabRead, actorCollabTriage, actorCollabWrite, actorCollabMaintain, actorCollabAdmin:
71 return policy.UserActor(otherID, "collab", false, false)
72 case actorUnrelated:
73 return policy.UserActor(otherID+1, "stranger", false, false)
74 case actorSuspendedOwner:
75 return policy.UserActor(ownerID, "owner", true, false)
76 case actorSuspendedCollabWrite:
77 return policy.UserActor(otherID, "collab", true, false)
78 case actorSiteAdmin:
79 return policy.UserActor(otherID+2, "admin", false, true)
80 }
81 return policy.AnonymousActor()
82 }
83
84 func makeRepo(k repoKind) policy.RepoRef {
85 r := policy.RepoRef{ID: repoIDV, OwnerUserID: ownerID}
86 switch k {
87 case repoPublic:
88 r.Visibility = "public"
89 case repoPrivate:
90 r.Visibility = "private"
91 case repoArchivedPublic:
92 r.Visibility = "public"
93 r.IsArchived = true
94 case repoArchivedPrivate:
95 r.Visibility = "private"
96 r.IsArchived = true
97 case repoDeletedPublic:
98 r.Visibility = "public"
99 r.IsDeleted = true
100 }
101 return r
102 }
103
104 // fakeRoleResolver intercepts the cache layer so tests can assert role
105 // outcomes without hitting Postgres. Each collab actorKind seeds a
106 // specific role in the cache pre-Can.
107 func ctxWithCollabRole(t *testing.T, kind actorKind) context.Context {
108 t.Helper()
109 ctx := policy.WithCache(context.Background())
110 role := map[actorKind]policy.Role{
111 actorCollabRead: policy.RoleRead,
112 actorCollabTriage: policy.RoleTriage,
113 actorCollabWrite: policy.RoleWrite,
114 actorCollabMaintain: policy.RoleMaintain,
115 actorCollabAdmin: policy.RoleAdmin,
116 actorSuspendedCollabWrite: policy.RoleWrite,
117 }[kind]
118 if role != policy.RoleNone {
119 policy.PrimeCacheForTest(ctx, otherID, repoIDV, role)
120 }
121 return ctx
122 }
123
124 // expect computes the canonical verdict for a (actorKind, repoKind, Action)
125 // triple. The function below is the policy spec restated in plain Go;
126 // if Can() and expect() ever disagree, one of them has a bug.
127 //
128 //nolint:gocyclo // exhaustive shape is by design.
129 func expect(actor actorKind, repo repoKind, action policy.Action) bool {
130 // Deleted repo → nothing.
131 if repo == repoDeletedPublic {
132 return false
133 }
134 isWrite := action != policy.ActionRepoRead && action != policy.ActionIssueRead && action != policy.ActionPullRead
135
136 // Site admin can read everything (except deleted handled above).
137 if actor == actorSiteAdmin && !isWrite {
138 return true
139 }
140
141 // Suspended actors: writes always blocked. Reads against public repos
142 // still allowed (matches the suspended-then-read public code path).
143 if actor == actorSuspendedOwner || actor == actorSuspendedCollabWrite {
144 if isWrite {
145 return false
146 }
147 // fall through to the visibility check
148 }
149
150 isPrivate := repo == repoPrivate || repo == repoArchivedPrivate
151 isArchived := repo == repoArchivedPublic || repo == repoArchivedPrivate
152
153 // Anonymous + private → deny.
154 if actor == actorAnonymous && isPrivate {
155 return false
156 }
157
158 // Public repo reads: anyone.
159 if !isWrite && !isPrivate {
160 return true
161 }
162
163 // Public issue participation: any logged-in user can open/comment
164 // on issues in a non-archived public repo.
165 if (action == policy.ActionIssueCreate || action == policy.ActionIssueComment) && !isPrivate {
166 if actor == actorAnonymous || isArchived {
167 return false
168 }
169 return true
170 }
171
172 // Compute role.
173 var have policy.Role
174 switch actor {
175 case actorOwner, actorSuspendedOwner:
176 have = policy.RoleAdmin
177 case actorCollabRead:
178 have = policy.RoleRead
179 case actorCollabTriage:
180 have = policy.RoleTriage
181 case actorCollabWrite, actorSuspendedCollabWrite:
182 have = policy.RoleWrite
183 case actorCollabMaintain:
184 have = policy.RoleMaintain
185 case actorCollabAdmin:
186 have = policy.RoleAdmin
187 }
188
189 // Archived: writes denied (covers owner too).
190 if isArchived && isWrite {
191 return false
192 }
193
194 // Login-only social actions.
195 if action == policy.ActionStarCreate || action == policy.ActionForkCreate ||
196 action == policy.ActionWatchSet {
197 return have != policy.RoleNone || (actor != actorAnonymous)
198 }
199
200 // Role check.
201 want := mirrorMinRoleFor(action)
202 if want == policy.RoleNone {
203 return actor != actorAnonymous
204 }
205 return policy.RoleAtLeast(have, want)
206 }
207
208 // mirrorMinRoleFor mirrors policy.minRoleFor for test side. We could
209 // expose the internal helper; keeping the mirror enforces that the
210 // matrix knows about every action explicitly.
211 func mirrorMinRoleFor(a policy.Action) policy.Role {
212 switch a {
213 case policy.ActionRepoRead, policy.ActionIssueRead, policy.ActionPullRead:
214 return policy.RoleRead
215 case policy.ActionIssueClose, policy.ActionIssueLabel, policy.ActionIssueAssign:
216 return policy.RoleTriage
217 case policy.ActionIssueCreate, policy.ActionIssueComment:
218 return policy.RoleRead
219 case policy.ActionRepoWrite, policy.ActionPullCreate, policy.ActionPullReview, policy.ActionPullClose:
220 return policy.RoleWrite
221 case policy.ActionRepoSettingsGeneral, policy.ActionRepoSettingsBranches:
222 return policy.RoleMaintain
223 case policy.ActionRepoAdmin, policy.ActionRepoSettingsCollaborators, policy.ActionRepoSettingsActions,
224 policy.ActionRepoArchive, policy.ActionRepoDelete, policy.ActionRepoTransfer, policy.ActionRepoVisibility,
225 policy.ActionPullMerge:
226 return policy.RoleAdmin
227 case policy.ActionStarCreate, policy.ActionForkCreate, policy.ActionWatchSet:
228 return policy.RoleNone
229 }
230 return policy.RoleAdmin
231 }
232
233 func TestCan_Matrix(t *testing.T) {
234 t.Parallel()
235 d := policy.Deps{} // pool nil OK; cache primes the role
236
237 for _, ak := range []actorKind{
238 actorAnonymous, actorOwner,
239 actorCollabRead, actorCollabTriage, actorCollabWrite, actorCollabMaintain, actorCollabAdmin,
240 actorUnrelated, actorSuspendedOwner, actorSuspendedCollabWrite, actorSiteAdmin,
241 } {
242 for _, rk := range []repoKind{
243 repoPublic, repoPrivate, repoArchivedPublic, repoArchivedPrivate, repoDeletedPublic,
244 } {
245 for _, action := range policy.AllActions {
246 ak, rk, action := ak, rk, action
247 name := ak.String() + "/" + rk.String() + "/" + string(action)
248 t.Run(name, func(t *testing.T) {
249 t.Parallel()
250 ctx := ctxWithCollabRole(t, ak)
251 actor := makeActor(ak)
252 repo := makeRepo(rk)
253 got := policy.Can(ctx, d, actor, action, repo).Allow
254 want := expect(ak, rk, action)
255 if got != want {
256 t.Errorf("Can(%s, %s, %s) = %v, want %v",
257 ak, rk, action, got, want)
258 }
259 })
260 }
261 }
262 }
263 }
264
265 func TestCan_PublicIssueParticipation(t *testing.T) {
266 t.Parallel()
267
268 d := policy.Deps{}
269 publicRepo := makeRepo(repoPublic)
270 privateRepo := makeRepo(repoPrivate)
271 archivedPublicRepo := makeRepo(repoArchivedPublic)
272 stranger := makeActor(actorUnrelated)
273 readCollab := makeActor(actorCollabRead)
274
275 for _, action := range []policy.Action{policy.ActionIssueCreate, policy.ActionIssueComment} {
276 action := action
277 t.Run(string(action), func(t *testing.T) {
278 t.Parallel()
279
280 if got := policy.Can(context.Background(), d, stranger, action, publicRepo); !got.Allow {
281 t.Fatalf("logged-in non-collab on public repo should be allowed to %s: %#v", action, got)
282 }
283 if got := policy.Can(context.Background(), d, policy.AnonymousActor(), action, publicRepo); got.Allow || got.Code != policy.DenyAnonymous {
284 t.Fatalf("anonymous public repo %s = %#v, want DenyAnonymous", action, got)
285 }
286 ctx := ctxWithCollabRole(t, actorCollabRead)
287 if got := policy.Can(ctx, d, readCollab, action, privateRepo); !got.Allow {
288 t.Fatalf("read collab on private repo should be allowed to %s: %#v", action, got)
289 }
290 if got := policy.Can(context.Background(), d, stranger, action, privateRepo); got.Allow || got.Code != policy.DenyVisibility {
291 t.Fatalf("stranger private repo %s = %#v, want DenyVisibility", action, got)
292 }
293 if got := policy.Can(context.Background(), d, stranger, action, archivedPublicRepo); got.Allow || got.Code != policy.DenyArchived {
294 t.Fatalf("archived public repo %s = %#v, want DenyArchived", action, got)
295 }
296 })
297 }
298 }
299
300 func TestRoleAtLeast(t *testing.T) {
301 t.Parallel()
302 cases := []struct {
303 have, want policy.Role
304 ok bool
305 }{
306 {policy.RoleAdmin, policy.RoleRead, true},
307 {policy.RoleAdmin, policy.RoleAdmin, true},
308 {policy.RoleWrite, policy.RoleAdmin, false},
309 {policy.RoleRead, policy.RoleTriage, false},
310 {policy.RoleNone, policy.RoleRead, false},
311 {policy.RoleRead, policy.RoleNone, false}, // RoleNone is meaningless as a target
312 }
313 for _, c := range cases {
314 if got := policy.RoleAtLeast(c.have, c.want); got != c.ok {
315 t.Errorf("RoleAtLeast(%q, %q) = %v, want %v", c.have, c.want, got, c.ok)
316 }
317 }
318 }
319
320 func TestMaybe404(t *testing.T) {
321 t.Parallel()
322 priv := policy.RepoRef{ID: 1, OwnerUserID: ownerID, Visibility: "private"}
323 pub := policy.RepoRef{ID: 1, OwnerUserID: ownerID, Visibility: "public"}
324 owner := policy.UserActor(ownerID, "o", false, false)
325 stranger := policy.UserActor(otherID, "s", false, false)
326
327 // Anonymous on private → 404 leak guard.
328 if got := policy.Maybe404(policy.Decision{Allow: false}, priv, policy.AnonymousActor()); got != 404 {
329 t.Errorf("anonymous on private: got %d, want 404", got)
330 }
331 // Stranger on private → 404 (don't reveal existence).
332 if got := policy.Maybe404(policy.Decision{Allow: false}, priv, stranger); got != 404 {
333 t.Errorf("stranger on private: got %d, want 404", got)
334 }
335 // Owner on private (e.g. archived push) → real 403.
336 if got := policy.Maybe404(policy.Decision{Allow: false}, priv, owner); got != 403 {
337 t.Errorf("owner deny on private: got %d, want 403", got)
338 }
339 // Public deny → 403 always.
340 if got := policy.Maybe404(policy.Decision{Allow: false}, pub, policy.AnonymousActor()); got != 403 {
341 t.Errorf("anonymous deny on public: got %d, want 403", got)
342 }
343 // Allow → 200 regardless of repo shape.
344 if got := policy.Maybe404(policy.Decision{Allow: true}, priv, owner); got != 200 {
345 t.Errorf("allow: got %d, want 200", got)
346 }
347 }
348
349 func TestCacheInvalidate(t *testing.T) {
350 t.Parallel()
351 ctx := policy.WithCache(context.Background())
352 policy.PrimeCacheForTest(ctx, otherID, repoIDV, policy.RoleAdmin)
353
354 // Confirm primed.
355 d := policy.Deps{}
356 actor := policy.UserActor(otherID, "u", false, false)
357 repo := policy.RepoRef{ID: repoIDV, OwnerUserID: ownerID, Visibility: "private"}
358 if !policy.Can(ctx, d, actor, policy.ActionRepoAdmin, repo).Allow {
359 t.Fatalf("primed cache should grant admin")
360 }
361
362 // Invalidate; with no DB pool the next lookup falls through to RoleNone.
363 policy.InvalidateRepo(ctx, repoIDV)
364 if policy.Can(ctx, d, actor, policy.ActionRepoAdmin, repo).Allow {
365 t.Errorf("after invalidate, admin should deny without role row")
366 }
367 }
368
369 // TestPermissionsDoc_CoversEveryAction guards against drift between
370 // the policy package and docs/internal/permissions.md. The doc's
371 // "Action → minimum role" table must mention every Action constant
372 // exposed via AllActions, so an action added in code without a doc
373 // update fails this test loudly. (S00-S25 audit, finding for doc/code
374 // sync.)
375 //
376 // Matching is by the action's string value (e.g. "repo:read") which
377 // is what the doc renders verbatim in backticks. We do not check the
378 // exact min-role column — that lives in `mirrorMinRoleFor` already.
379 func TestPermissionsDoc_CoversEveryAction(t *testing.T) {
380 t.Parallel()
381 docPath := filepath.Join("..", "..", "..", "docs", "internal", "permissions.md")
382 body, err := os.ReadFile(docPath)
383 if err != nil {
384 t.Fatalf("read permissions.md: %v", err)
385 }
386 doc := string(body)
387 for _, a := range policy.AllActions {
388 needle := "`" + string(a) + "`"
389 if !strings.Contains(doc, needle) {
390 t.Errorf("permissions.md does not mention action %q (looking for %s)", a, needle)
391 }
392 }
393 }
394
395 // TestImpersonation_ReadOnlyDeniesWrites pins the canonical foot-gun
396 // guard: an impersonating admin without ImpersonateWriteOK must not
397 // be able to write, regardless of the underlying actor's role.
398 func TestImpersonation_ReadOnlyDeniesWrites(t *testing.T) {
399 ctx := t.Context()
400 d := policy.Deps{Pool: nil}
401 repo := policy.RepoRef{ID: 1, OwnerUserID: 99, Visibility: "public"}
402 actor := policy.Actor{
403 UserID: 99, // the impersonated user
404 Impersonating: true,
405 }
406 dec := policy.Can(ctx, d, actor, policy.ActionIssueComment, repo)
407 if dec.Allow {
408 t.Fatalf("impersonation read-only must deny writes; got allow")
409 }
410 if dec.Code != policy.DenyImpersonationReadOnly {
411 t.Errorf("Code = %v; want DenyImpersonationReadOnly", dec.Code)
412 }
413 }
414
415 // TestImpersonation_WriteModeOverridesReadOnly: with ImpersonateWriteOK
416 // flipped on, the policy proceeds to the normal role checks (the
417 // underlying role + repo state still gate, this just lifts the
418 // impersonation-specific deny).
419 func TestImpersonation_WriteModeOverridesReadOnly(t *testing.T) {
420 ctx := t.Context()
421 d := policy.Deps{Pool: nil}
422 repo := policy.RepoRef{ID: 1, OwnerUserID: 99, Visibility: "public"}
423 actor := policy.Actor{
424 UserID: 99,
425 Impersonating: true,
426 ImpersonateWriteOK: true,
427 }
428 dec := policy.Can(ctx, d, actor, policy.ActionIssueComment, repo)
429 // We don't assert Allow here because the action still passes
430 // through role checks (which will deny without DB-backed role
431 // info). The relevant assertion is "not the impersonation deny."
432 if !dec.Allow && dec.Code == policy.DenyImpersonationReadOnly {
433 t.Fatalf("write-mode should clear impersonation deny; got %v", dec)
434 }
435 }
436