S30: org suspension policy gate — block writes on suspended-org repos
- SHA
0566c9023de3c4ef14e2f11f17dc04258cd32f52- Parents
-
d95d5f2 - Tree
b39593c
0566c90
0566c9023de3c4ef14e2f11f17dc04258cd32f52d95d5f2
b39593c| Status | File | + | - |
|---|---|---|---|
| M |
internal/auth/policy/org_owner_test.go
|
54 | 0 |
| M |
internal/auth/policy/policy.go
|
23 | 0 |
internal/auth/policy/org_owner_test.gomodified@@ -90,3 +90,57 @@ func TestOrgOwner_ImplicitAdmin(t *testing.T) { | ||
| 90 | 90 | t.Fatalf("plain org member should NOT have implicit read on private repo: %+v", got) |
| 91 | 91 | } |
| 92 | 92 | } |
| 93 | + | |
| 94 | +// TestOrgSuspended_BlocksWrites pins the S30 contract: when an org | |
| 95 | +// is suspended, every write action against an org-owned repo is | |
| 96 | +// denied — even for the org owner. Reads still allow. | |
| 97 | +func TestOrgSuspended_BlocksWrites(t *testing.T) { | |
| 98 | + pool := dbtest.NewTestDB(t) | |
| 99 | + ctx := context.Background() | |
| 100 | + | |
| 101 | + creator, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{ | |
| 102 | + Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash, | |
| 103 | + }) | |
| 104 | + if err != nil { | |
| 105 | + t.Fatalf("create user: %v", err) | |
| 106 | + } | |
| 107 | + deps := orgs.Deps{Pool: pool} | |
| 108 | + org, err := orgs.Create(ctx, deps, orgs.CreateParams{ | |
| 109 | + Slug: "acme", DisplayName: "Acme", CreatedByUserID: creator.ID, | |
| 110 | + }) | |
| 111 | + if err != nil { | |
| 112 | + t.Fatalf("create org: %v", err) | |
| 113 | + } | |
| 114 | + repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{ | |
| 115 | + OwnerUserID: pgtype.Int8{Valid: false}, | |
| 116 | + OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true}, | |
| 117 | + Name: "demo", | |
| 118 | + DefaultBranch: "trunk", | |
| 119 | + Visibility: reposdb.RepoVisibilityPublic, | |
| 120 | + }) | |
| 121 | + if err != nil { | |
| 122 | + t.Fatalf("create repo: %v", err) | |
| 123 | + } | |
| 124 | + // Suspend the org via raw UPDATE (mirrors what the admin | |
| 125 | + // surface will eventually do). | |
| 126 | + if _, err := pool.Exec(ctx, `UPDATE orgs SET suspended_at=now() WHERE id=$1`, org.ID); err != nil { | |
| 127 | + t.Fatalf("suspend org: %v", err) | |
| 128 | + } | |
| 129 | + | |
| 130 | + ref := policy.NewRepoRefFromRepo(repo) | |
| 131 | + actor := policy.UserActor(creator.ID, "alice", false, false) | |
| 132 | + pdeps := policy.Deps{Pool: pool} | |
| 133 | + | |
| 134 | + // Reads still allowed. | |
| 135 | + if got := policy.Can(ctx, pdeps, actor, policy.ActionRepoRead, ref); !got.Allow { | |
| 136 | + t.Fatalf("reads on suspended-org repo should allow: %+v", got) | |
| 137 | + } | |
| 138 | + // Writes denied with the typed code. | |
| 139 | + got := policy.Can(ctx, pdeps, actor, policy.ActionRepoWrite, ref) | |
| 140 | + if got.Allow { | |
| 141 | + t.Fatal("write on suspended-org repo should deny") | |
| 142 | + } | |
| 143 | + if got.Code != policy.DenyOrgSuspended { | |
| 144 | + t.Fatalf("want DenyOrgSuspended code, got %v (reason=%q)", got.Code, got.Reason) | |
| 145 | + } | |
| 146 | +} | |
internal/auth/policy/policy.gomodified@@ -34,6 +34,11 @@ const ( | ||
| 34 | 34 | DenyRoleTooLow // logged-in but role insufficient |
| 35 | 35 | DenyAnonymous // login required (e.g. star/fork) |
| 36 | 36 | DenyDBError |
| 37 | + // DenyOrgSuspended is returned for write actions on a repo whose | |
| 38 | + // owning org is currently suspended. Reads stay allowed (the spec | |
| 39 | + // preserves visibility into suspended-org content); writes flip | |
| 40 | + // off uniformly. | |
| 41 | + DenyOrgSuspended | |
| 37 | 42 | ) |
| 38 | 43 | |
| 39 | 44 | // Decision is the verdict from Can. Allow is the only field handlers |
@@ -125,6 +130,24 @@ func Can(ctx context.Context, d Deps, actor Actor, action Action, repo RepoRef) | ||
| 125 | 130 | return deny(DenyArchived, "repo archived") |
| 126 | 131 | } |
| 127 | 132 | |
| 133 | + // 7b. Org suspension (S30): writes against any repo owned by a | |
| 134 | + // suspended org are denied uniformly. Reads stay allowed (the | |
| 135 | + // org's contributions to the broader graph aren't erased). | |
| 136 | + // The check is gated on a write action AND a non-zero | |
| 137 | + // OwnerOrgID so user-owned repos pay nothing for it. | |
| 138 | + if repo.OwnerOrgID != 0 && isWriteAction(action) { | |
| 139 | + if d.Pool != nil { | |
| 140 | + var suspended bool | |
| 141 | + err := d.Pool.QueryRow(ctx, | |
| 142 | + `SELECT suspended_at IS NOT NULL FROM orgs WHERE id = $1`, | |
| 143 | + repo.OwnerOrgID, | |
| 144 | + ).Scan(&suspended) | |
| 145 | + if err == nil && suspended { | |
| 146 | + return deny(DenyOrgSuspended, "owning org suspended") | |
| 147 | + } | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 128 | 151 | // 8. Map action → minimum required role; check. |
| 129 | 152 | want := minRoleFor(action) |
| 130 | 153 | if want != RoleNone && !RoleAtLeast(role, want) { |