tenseleyflow/shithub / 0566c90

Browse files

S30: org suspension policy gate — block writes on suspended-org repos

Authored by espadonne
SHA
0566c9023de3c4ef14e2f11f17dc04258cd32f52
Parents
d95d5f2
Tree
b39593c

2 changed files

StatusFile+-
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) {
9090
 		t.Fatalf("plain org member should NOT have implicit read on private repo: %+v", got)
9191
 	}
9292
 }
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 (
3434
 	DenyRoleTooLow    // logged-in but role insufficient
3535
 	DenyAnonymous     // login required (e.g. star/fork)
3636
 	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
3742
 )
3843
 
3944
 // 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)
125130
 		return deny(DenyArchived, "repo archived")
126131
 	}
127132
 
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
+
128151
 	// 8. Map action → minimum required role; check.
129152
 	want := minRoleFor(action)
130153
 	if want != RoleNone && !RoleAtLeast(role, want) {