Go · 10519 bytes Raw Blame History
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