| 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.ActionActionsRun, policy.ActionPullCreate, policy.ActionPullReview, policy.ActionPullClose: |
| 220 | return policy.RoleWrite |
| 221 | case policy.ActionRepoSettingsGeneral, policy.ActionRepoSettingsBranches, policy.ActionActionsApprove: |
| 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 |