| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package policy_test |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/jackc/pgx/v5/pgtype" |
| 10 | "github.com/jackc/pgx/v5/pgxpool" |
| 11 | |
| 12 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 13 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 14 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 15 | "github.com/tenseleyFlow/shithub/internal/testing/dbtest" |
| 16 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 17 | ) |
| 18 | |
| 19 | const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" + |
| 20 | "AAAAAAAAAAAAAAAA$" + |
| 21 | "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" |
| 22 | |
| 23 | // TestOrgOwner_ImplicitAdmin pins the S30 contract: an `org_members.role |
| 24 | // = 'owner'` row promotes the user to RoleAdmin on every repo owned by |
| 25 | // that org. Without this, an org owner can't push to their own org's |
| 26 | // repos (the dogfood-blocking case). |
| 27 | func TestOrgOwner_ImplicitAdmin(t *testing.T) { |
| 28 | pool := dbtest.NewTestDB(t) |
| 29 | ctx := context.Background() |
| 30 | |
| 31 | creator, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ |
| 32 | Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash, |
| 33 | }) |
| 34 | if err != nil { |
| 35 | t.Fatalf("create user: %v", err) |
| 36 | } |
| 37 | deps := orgs.Deps{Pool: pool} |
| 38 | org, err := orgs.Create(ctx, deps, orgs.CreateParams{ |
| 39 | Slug: "acme", DisplayName: "Acme", CreatedByUserID: creator.ID, |
| 40 | }) |
| 41 | if err != nil { |
| 42 | t.Fatalf("create org: %v", err) |
| 43 | } |
| 44 | // Org-owned private repo. |
| 45 | repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{ |
| 46 | OwnerUserID: pgtype.Int8{Valid: false}, |
| 47 | OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true}, |
| 48 | Name: "secret", |
| 49 | DefaultBranch: "trunk", |
| 50 | Visibility: reposdb.RepoVisibilityPrivate, |
| 51 | }) |
| 52 | if err != nil { |
| 53 | t.Fatalf("create org repo: %v", err) |
| 54 | } |
| 55 | ref := policy.NewRepoRefFromRepo(repo) |
| 56 | |
| 57 | actor := policy.UserActor(creator.ID, "alice", false, false) |
| 58 | pdeps := policy.Deps{Pool: pool} |
| 59 | |
| 60 | // Push (write tier) must allow. |
| 61 | if got := policy.Can(ctx, pdeps, actor, policy.ActionRepoWrite, ref); !got.Allow { |
| 62 | t.Fatalf("org owner should be able to write: %+v", got) |
| 63 | } |
| 64 | // Repo-admin tier must allow. |
| 65 | if got := policy.Can(ctx, pdeps, actor, policy.ActionRepoAdmin, ref); !got.Allow { |
| 66 | t.Fatalf("org owner should be admin: %+v", got) |
| 67 | } |
| 68 | |
| 69 | // A non-member must NOT see a private org repo. |
| 70 | bob, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ |
| 71 | Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash, |
| 72 | }) |
| 73 | if err != nil { |
| 74 | t.Fatalf("create bob: %v", err) |
| 75 | } |
| 76 | bobActor := policy.UserActor(bob.ID, "bob", false, false) |
| 77 | if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoRead, ref); got.Allow { |
| 78 | t.Fatalf("non-member should not read private org repo: %+v", got) |
| 79 | } |
| 80 | |
| 81 | // Member-but-not-owner must NOT get implicit admin (teams are S31). |
| 82 | if err := orgs.AddMember(ctx, deps, org.ID, bob.ID, creator.ID, "member"); err != nil { |
| 83 | t.Fatalf("add member: %v", err) |
| 84 | } |
| 85 | if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); got.Allow { |
| 86 | t.Fatalf("plain org member should NOT have implicit write: %+v", got) |
| 87 | } |
| 88 | // And read on a private org repo for a plain member is also denied |
| 89 | // today — implicit read for org members is deferred to S31 (teams). |
| 90 | if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoRead, ref); got.Allow { |
| 91 | t.Fatalf("plain org member should NOT have implicit read on private repo: %+v", got) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // TestTeamGrant_GivesWriteAccess pins the S31 contract: a team grant |
| 96 | // at `write` on an org repo lets that team's members push, even |
| 97 | // though they're plain org members (not owners) and have no direct |
| 98 | // collaborator row. |
| 99 | func TestTeamGrant_GivesWriteAccess(t *testing.T) { |
| 100 | pool := dbtest.NewTestDB(t) |
| 101 | ctx := context.Background() |
| 102 | |
| 103 | creator, _ := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ |
| 104 | Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash, |
| 105 | }) |
| 106 | deps := orgs.Deps{Pool: pool} |
| 107 | org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: creator.ID}) |
| 108 | repo, _ := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{ |
| 109 | OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true}, |
| 110 | Name: "demo", DefaultBranch: "trunk", Visibility: reposdb.RepoVisibilityPublic, |
| 111 | }) |
| 112 | bob, _ := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ |
| 113 | Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash, |
| 114 | }) |
| 115 | if err := orgs.AddMember(ctx, deps, org.ID, bob.ID, creator.ID, "member"); err != nil { |
| 116 | t.Fatalf("add org member: %v", err) |
| 117 | } |
| 118 | team, _ := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{ |
| 119 | OrgID: org.ID, Slug: "eng", CreatedByUserID: creator.ID, |
| 120 | }) |
| 121 | if err := orgs.AddTeamMember(ctx, deps, team.ID, bob.ID, creator.ID, "member"); err != nil { |
| 122 | t.Fatalf("add team member: %v", err) |
| 123 | } |
| 124 | if err := orgs.GrantTeamRepoAccess(ctx, deps, team.ID, repo.ID, creator.ID, "write"); err != nil { |
| 125 | t.Fatalf("grant: %v", err) |
| 126 | } |
| 127 | |
| 128 | ref := policy.NewRepoRefFromRepo(repo) |
| 129 | bobActor := policy.UserActor(bob.ID, "bob", false, false) |
| 130 | pdeps := policy.Deps{Pool: pool} |
| 131 | |
| 132 | if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); !got.Allow { |
| 133 | t.Fatalf("team-granted write should allow: %+v", got) |
| 134 | } |
| 135 | |
| 136 | // Demote to read → write must now deny. |
| 137 | if err := orgs.GrantTeamRepoAccess(ctx, deps, team.ID, repo.ID, creator.ID, "read"); err != nil { |
| 138 | t.Fatalf("demote: %v", err) |
| 139 | } |
| 140 | if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); got.Allow { |
| 141 | t.Fatal("after demote to read, write should deny") |
| 142 | } |
| 143 | // Reads still allow under read role. |
| 144 | if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoRead, ref); !got.Allow { |
| 145 | t.Fatalf("read should still allow at read role: %+v", got) |
| 146 | } |
| 147 | |
| 148 | // Revoke entirely → reads on a public repo still allow (visibility), |
| 149 | // but for the test of "team membership lost access" we use a |
| 150 | // private repo branch: |
| 151 | if _, err := pool.Exec(ctx, `UPDATE repos SET visibility='private' WHERE id=$1`, repo.ID); err != nil { |
| 152 | t.Fatalf("flip private: %v", err) |
| 153 | } |
| 154 | // Cache from the previous Can() call may still be hot — start a |
| 155 | // fresh request scope by using a new context. |
| 156 | if err := orgs.RevokeTeamRepoAccess(ctx, deps, team.ID, repo.ID); err != nil { |
| 157 | t.Fatalf("revoke: %v", err) |
| 158 | } |
| 159 | freshCtx := context.Background() |
| 160 | if got := policy.Can(freshCtx, pdeps, bobActor, policy.ActionRepoRead, |
| 161 | policy.NewRepoRefFromRepo(reposdbReload(t, pool, repo.ID))); got.Allow { |
| 162 | t.Fatalf("after revoke, private read should deny: %+v", got) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // TestTeamParent_Inheritance pins the one-level parent inheritance |
| 167 | // rule: a child-team member inherits the parent team's repo grants. |
| 168 | func TestTeamParent_Inheritance(t *testing.T) { |
| 169 | pool := dbtest.NewTestDB(t) |
| 170 | ctx := context.Background() |
| 171 | creator, _ := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ |
| 172 | Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash, |
| 173 | }) |
| 174 | deps := orgs.Deps{Pool: pool} |
| 175 | org, _ := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: creator.ID}) |
| 176 | repo, _ := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{ |
| 177 | OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true}, |
| 178 | Name: "demo", DefaultBranch: "trunk", Visibility: reposdb.RepoVisibilityPublic, |
| 179 | }) |
| 180 | bob, _ := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ |
| 181 | Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash, |
| 182 | }) |
| 183 | _ = orgs.AddMember(ctx, deps, org.ID, bob.ID, creator.ID, "member") |
| 184 | |
| 185 | parent, _ := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{ |
| 186 | OrgID: org.ID, Slug: "engineering", CreatedByUserID: creator.ID, |
| 187 | }) |
| 188 | child, _ := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{ |
| 189 | OrgID: org.ID, Slug: "eng-mobile", ParentTeamID: parent.ID, |
| 190 | CreatedByUserID: creator.ID, |
| 191 | }) |
| 192 | // Bob is in the CHILD team only. Grant write on the PARENT team. |
| 193 | _ = orgs.AddTeamMember(ctx, deps, child.ID, bob.ID, creator.ID, "member") |
| 194 | if err := orgs.GrantTeamRepoAccess(ctx, deps, parent.ID, repo.ID, creator.ID, "write"); err != nil { |
| 195 | t.Fatalf("parent grant: %v", err) |
| 196 | } |
| 197 | |
| 198 | ref := policy.NewRepoRefFromRepo(repo) |
| 199 | bobActor := policy.UserActor(bob.ID, "bob", false, false) |
| 200 | pdeps := policy.Deps{Pool: pool} |
| 201 | if got := policy.Can(ctx, pdeps, bobActor, policy.ActionRepoWrite, ref); !got.Allow { |
| 202 | t.Fatalf("child team should inherit parent's write grant: %+v", got) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // reposdbReload re-fetches a repo row so a follow-up policy.Can sees |
| 207 | // fresh visibility after a raw UPDATE. |
| 208 | func reposdbReload(t *testing.T, pool *pgxpool.Pool, id int64) reposdb.Repo { |
| 209 | t.Helper() |
| 210 | row, err := reposdb.New().GetRepoByID(context.Background(), pool, id) |
| 211 | if err != nil { |
| 212 | t.Fatalf("reload repo: %v", err) |
| 213 | } |
| 214 | return row |
| 215 | } |
| 216 | |
| 217 | // TestOrgSuspended_BlocksWrites pins the S30 contract: when an org |
| 218 | // is suspended, every write action against an org-owned repo is |
| 219 | // denied — even for the org owner. Reads still allow. |
| 220 | func TestOrgSuspended_BlocksWrites(t *testing.T) { |
| 221 | pool := dbtest.NewTestDB(t) |
| 222 | ctx := context.Background() |
| 223 | |
| 224 | creator, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ |
| 225 | Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash, |
| 226 | }) |
| 227 | if err != nil { |
| 228 | t.Fatalf("create user: %v", err) |
| 229 | } |
| 230 | deps := orgs.Deps{Pool: pool} |
| 231 | org, err := orgs.Create(ctx, deps, orgs.CreateParams{ |
| 232 | Slug: "acme", DisplayName: "Acme", CreatedByUserID: creator.ID, |
| 233 | }) |
| 234 | if err != nil { |
| 235 | t.Fatalf("create org: %v", err) |
| 236 | } |
| 237 | repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{ |
| 238 | OwnerUserID: pgtype.Int8{Valid: false}, |
| 239 | OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true}, |
| 240 | Name: "demo", |
| 241 | DefaultBranch: "trunk", |
| 242 | Visibility: reposdb.RepoVisibilityPublic, |
| 243 | }) |
| 244 | if err != nil { |
| 245 | t.Fatalf("create repo: %v", err) |
| 246 | } |
| 247 | // Suspend the org via raw UPDATE (mirrors what the admin |
| 248 | // surface will eventually do). |
| 249 | if _, err := pool.Exec(ctx, `UPDATE orgs SET suspended_at=now() WHERE id=$1`, org.ID); err != nil { |
| 250 | t.Fatalf("suspend org: %v", err) |
| 251 | } |
| 252 | |
| 253 | ref := policy.NewRepoRefFromRepo(repo) |
| 254 | actor := policy.UserActor(creator.ID, "alice", false, false) |
| 255 | pdeps := policy.Deps{Pool: pool} |
| 256 | |
| 257 | // Reads still allowed. |
| 258 | if got := policy.Can(ctx, pdeps, actor, policy.ActionRepoRead, ref); !got.Allow { |
| 259 | t.Fatalf("reads on suspended-org repo should allow: %+v", got) |
| 260 | } |
| 261 | // Writes denied with the typed code. |
| 262 | got := policy.Can(ctx, pdeps, actor, policy.ActionRepoWrite, ref) |
| 263 | if got.Allow { |
| 264 | t.Fatal("write on suspended-org repo should deny") |
| 265 | } |
| 266 | if got.Code != policy.DenyOrgSuspended { |
| 267 | t.Fatalf("want DenyOrgSuspended code, got %v (reason=%q)", got.Code, got.Reason) |
| 268 | } |
| 269 | } |
| 270 |