tenseleyflow/shithub / 3c50abc

Browse files

S30: org soft-delete + 14-day grace + hard-delete cascade in lifecycle:sweep

Authored by espadonne
SHA
3c50abc067b39766e27f9aebae27d7304bedec62
Parents
0566c90
Tree
082c979

6 changed files

StatusFile+-
A internal/orgs/hard_delete.go 150 0
M internal/orgs/orgs_test.go 53 0
M internal/orgs/queries/orgs.sql 18 0
M internal/orgs/sqlc/orgs.sql.go 66 0
M internal/orgs/sqlc/querier.go 10 0
M internal/worker/jobs/lifecycle_sweep.go 21 0
internal/orgs/hard_delete.goadded
@@ -0,0 +1,150 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
14
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
15
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
17
+)
18
+
19
+// pgInt8 wraps an int64 as the pgtype.Int8 the sqlc-generated query
20
+// expects. Local helper so the call sites stay one-liners.
21
+func pgInt8(v int64) pgtype.Int8 { return pgtype.Int8{Int64: v, Valid: v != 0} }
22
+
23
+// HardDeleteDeps is the augmented Deps shape the org hard-delete
24
+// orchestrator needs. RepoFS lets us tear down the bare repos on
25
+// disk; the lifecycle.HardDelete cascade reuses that same path so
26
+// org-owned repos go through the same teardown user-owned ones do.
27
+type HardDeleteDeps struct {
28
+	Deps
29
+	RepoFS *storage.RepoFS
30
+	Audit  *audit.Recorder
31
+}
32
+
33
+// SoftDelete sets `orgs.deleted_at = now()`, kicking off the 14-day
34
+// grace window. The principals trigger drops the slug row so the
35
+// name becomes available to a new claimant immediately — restoring
36
+// before grace re-creates the row. Pre-flight check: the org must
37
+// not already be soft-deleted.
38
+func SoftDelete(ctx context.Context, deps Deps, orgID, actorUserID int64) error {
39
+	q := orgsdb.New()
40
+	row, err := q.GetOrgByID(ctx, deps.Pool, orgID)
41
+	if err != nil {
42
+		if errors.Is(err, pgx.ErrNoRows) {
43
+			return ErrOrgNotFound
44
+		}
45
+		return err
46
+	}
47
+	if row.DeletedAt.Valid {
48
+		return ErrDeleted
49
+	}
50
+	if err := q.SoftDeleteOrg(ctx, deps.Pool, orgID); err != nil {
51
+		return fmt.Errorf("soft delete: %w", err)
52
+	}
53
+	if deps.Audit != nil {
54
+		// Borrow the repo soft-delete action shape until the audit
55
+		// catalog grows a dedicated `org_soft_deleted` entry; the
56
+		// kind=org metadata makes the org row identifiable in the log.
57
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
58
+			audit.ActionRepoSoftDeleted,
59
+			audit.TargetUser, orgID,
60
+			map[string]any{"slug": string(row.Slug), "kind": "org"})
61
+	}
62
+	return nil
63
+}
64
+
65
+// Restore clears `deleted_at` for an org still inside its 14-day
66
+// grace window. Caller (web handler) checks that the actor is an
67
+// org owner BEFORE calling. Returns ErrOrgNotFound when the org's
68
+// past-grace + already-purged or never existed.
69
+func Restore(ctx context.Context, deps Deps, orgID, actorUserID int64) error {
70
+	q := orgsdb.New()
71
+	row, err := q.GetOrgByID(ctx, deps.Pool, orgID)
72
+	if err != nil {
73
+		if errors.Is(err, pgx.ErrNoRows) {
74
+			return ErrOrgNotFound
75
+		}
76
+		return err
77
+	}
78
+	if !row.DeletedAt.Valid {
79
+		// Not deleted; nothing to restore. Idempotent.
80
+		return nil
81
+	}
82
+	// Slug-collision check: a new user/org may have claimed the slug
83
+	// during the grace window (the principals row was dropped on
84
+	// soft-delete). Refuse to restore if so — the operator can rename
85
+	// the conflicting principal and retry.
86
+	var taken bool
87
+	if err := deps.Pool.QueryRow(ctx,
88
+		`SELECT EXISTS(SELECT 1 FROM principals WHERE slug = $1)`,
89
+		row.Slug,
90
+	).Scan(&taken); err != nil {
91
+		return err
92
+	}
93
+	if taken {
94
+		return ErrSlugTaken
95
+	}
96
+	if err := q.RestoreOrg(ctx, deps.Pool, orgID); err != nil {
97
+		return fmt.Errorf("restore: %w", err)
98
+	}
99
+	_ = actorUserID // audit entry deferred
100
+	return nil
101
+}
102
+
103
+// HardDelete is the cascade run by the past-grace sweep. For each
104
+// org-owned repo (including already-soft-deleted ones) it runs the
105
+// existing lifecycle.HardDelete tear-down (bare-repo on disk + DB
106
+// rows). Then drops org_invitations + org_members + the orgs row.
107
+// The principals trigger removes the slug entry as part of the
108
+// orgs DELETE.
109
+//
110
+// Best-effort: a per-repo failure is logged but doesn't stop the
111
+// sweep — leaving zombie repos in the DB is preferable to leaving
112
+// the org row in a half-deleted state where its slug is permanently
113
+// pinned. Operators can retry via the manual sweep job.
114
+func HardDelete(ctx context.Context, hd HardDeleteDeps, orgID int64) error {
115
+	q := orgsdb.New()
116
+	repoIDs, err := q.ListOrgRepoIDs(ctx, hd.Pool, pgInt8(orgID))
117
+	if err != nil {
118
+		return fmt.Errorf("list org repos: %w", err)
119
+	}
120
+	ldeps := lifecycle.Deps{
121
+		Pool: hd.Pool, RepoFS: hd.RepoFS, Audit: hd.Audit, Logger: hd.Logger,
122
+	}
123
+	for _, rid := range repoIDs {
124
+		if err := lifecycle.HardDelete(ctx, ldeps, 0, rid); err != nil && hd.Logger != nil {
125
+			hd.Logger.WarnContext(ctx, "orgs: cascade repo hard-delete failed",
126
+				"org_id", orgID, "repo_id", rid, "error", err)
127
+		}
128
+	}
129
+	// org_members + org_invitations cascade via FK ON DELETE CASCADE
130
+	// already; the principals trigger drops the slug row inside the
131
+	// same tx. Audit row goes in before the row vanishes so the log
132
+	// retains the slug.
133
+	row, _ := q.GetOrgByID(ctx, hd.Pool, orgID)
134
+	if hd.Audit != nil {
135
+		_ = hd.Audit.Record(ctx, hd.Pool, 0,
136
+			audit.ActionRepoHardDeleted,
137
+			audit.TargetUser, orgID,
138
+			map[string]any{"slug": string(row.Slug), "kind": "org"})
139
+	}
140
+	if err := q.HardDeleteOrgRow(ctx, hd.Pool, orgID); err != nil {
141
+		return fmt.Errorf("hard delete org row: %w", err)
142
+	}
143
+	return nil
144
+}
145
+
146
+// ListPastGraceOrgIDs is a thin wrapper exposing the sqlc query so
147
+// the worker bundle doesn't need to import orgsdb directly.
148
+func ListPastGraceOrgIDs(ctx context.Context, deps Deps) ([]int64, error) {
149
+	return orgsdb.New().ListOrgIDsPastSoftDeleteGrace(ctx, deps.Pool)
150
+}
internal/orgs/orgs_test.gomodified
@@ -244,3 +244,56 @@ func TestInvite_DuplicatePending(t *testing.T) {
244244
 // ensure pgtype is referenced even if a future refactor removes the
245245
 // only inline use.
246246
 var _ = pgtype.Int8{}
247
+
248
+// TestHardDelete_DropsOrgRowAndPrincipal exercises the cascade
249
+// without the worker scaffolding: soft-delete + manually expire
250
+// grace via raw UPDATE + HardDelete. Asserts the orgs row is gone,
251
+// the principals row is gone (slug freed), and the org-owned repo
252
+// is hard-deleted via the lifecycle cascade.
253
+func TestHardDelete_DropsOrgRowAndPrincipal(t *testing.T) {
254
+	pool, deps, alice := setup(t)
255
+	ctx := context.Background()
256
+	row, err := orgs.Create(ctx, deps, orgs.CreateParams{
257
+		Slug: "acme", DisplayName: "Acme", CreatedByUserID: alice,
258
+	})
259
+	if err != nil {
260
+		t.Fatalf("create org: %v", err)
261
+	}
262
+	// Soft-delete + force expiry by backdating deleted_at past grace.
263
+	if err := orgs.SoftDelete(ctx, deps, row.ID, alice); err != nil {
264
+		t.Fatalf("soft delete: %v", err)
265
+	}
266
+	if _, err := pool.Exec(ctx,
267
+		`UPDATE orgs SET deleted_at = now() - interval '15 days' WHERE id=$1`, row.ID); err != nil {
268
+		t.Fatalf("backdate: %v", err)
269
+	}
270
+	// Sweep should see this org as past-grace.
271
+	ids, err := orgs.ListPastGraceOrgIDs(ctx, deps)
272
+	if err != nil {
273
+		t.Fatalf("ListPastGraceOrgIDs: %v", err)
274
+	}
275
+	found := false
276
+	for _, id := range ids {
277
+		if id == row.ID {
278
+			found = true
279
+		}
280
+	}
281
+	if !found {
282
+		t.Fatalf("past-grace list missing org id %d (got %v)", row.ID, ids)
283
+	}
284
+	// Cascade. RepoFS nil is fine here — no repos under this org.
285
+	if err := orgs.HardDelete(ctx, orgs.HardDeleteDeps{Deps: deps}, row.ID); err != nil {
286
+		t.Fatalf("HardDelete: %v", err)
287
+	}
288
+	// Org row gone, slug freed in principals.
289
+	var stillExists bool
290
+	_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM orgs WHERE id=$1)`, row.ID).Scan(&stillExists)
291
+	if stillExists {
292
+		t.Fatal("org row should be gone")
293
+	}
294
+	var slugTaken bool
295
+	_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM principals WHERE slug='acme')`).Scan(&slugTaken)
296
+	if slugTaken {
297
+		t.Fatal("principals slug 'acme' should be freed")
298
+	}
299
+}
internal/orgs/queries/orgs.sqlmodified
@@ -54,6 +54,24 @@ UPDATE orgs SET deleted_at = now(), updated_at = now() WHERE id = $1;
5454
 -- name: RestoreOrg :exec
5555
 UPDATE orgs SET deleted_at = NULL, updated_at = now() WHERE id = $1;
5656
 
57
+-- name: ListOrgIDsPastSoftDeleteGrace :many
58
+-- Sweep input for the lifecycle worker: every soft-deleted org whose
59
+-- 14-day grace window has elapsed. The interval is intentionally a
60
+-- DB literal (not a parameter) so the policy lives next to the data.
61
+SELECT id FROM orgs
62
+WHERE deleted_at IS NOT NULL
63
+  AND deleted_at < (now() - interval '14 days');
64
+
65
+-- name: ListOrgRepoIDs :many
66
+-- All repo IDs (including soft-deleted) belonging to an org. Used by
67
+-- the org hard-delete cascade to fan out per-repo destruction.
68
+SELECT id FROM repos WHERE owner_org_id = $1;
69
+
70
+-- name: HardDeleteOrgRow :exec
71
+-- Final row removal after the cascade finished. The principals
72
+-- trigger drops the matching principals row in the same tx.
73
+DELETE FROM orgs WHERE id = $1;
74
+
5775
 -- ─── principals (read-only from this domain) ───────────────────────
5876
 
5977
 -- name: ResolvePrincipal :one
internal/orgs/sqlc/orgs.sql.gomodified
@@ -152,6 +152,72 @@ func (q *Queries) GetOrgBySlugIncludingDeleted(ctx context.Context, db DBTX, slu
152152
 	return i, err
153153
 }
154154
 
155
+const hardDeleteOrgRow = `-- name: HardDeleteOrgRow :exec
156
+DELETE FROM orgs WHERE id = $1
157
+`
158
+
159
+// Final row removal after the cascade finished. The principals
160
+// trigger drops the matching principals row in the same tx.
161
+func (q *Queries) HardDeleteOrgRow(ctx context.Context, db DBTX, id int64) error {
162
+	_, err := db.Exec(ctx, hardDeleteOrgRow, id)
163
+	return err
164
+}
165
+
166
+const listOrgIDsPastSoftDeleteGrace = `-- name: ListOrgIDsPastSoftDeleteGrace :many
167
+SELECT id FROM orgs
168
+WHERE deleted_at IS NOT NULL
169
+  AND deleted_at < (now() - interval '14 days')
170
+`
171
+
172
+// Sweep input for the lifecycle worker: every soft-deleted org whose
173
+// 14-day grace window has elapsed. The interval is intentionally a
174
+// DB literal (not a parameter) so the policy lives next to the data.
175
+func (q *Queries) ListOrgIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error) {
176
+	rows, err := db.Query(ctx, listOrgIDsPastSoftDeleteGrace)
177
+	if err != nil {
178
+		return nil, err
179
+	}
180
+	defer rows.Close()
181
+	items := []int64{}
182
+	for rows.Next() {
183
+		var id int64
184
+		if err := rows.Scan(&id); err != nil {
185
+			return nil, err
186
+		}
187
+		items = append(items, id)
188
+	}
189
+	if err := rows.Err(); err != nil {
190
+		return nil, err
191
+	}
192
+	return items, nil
193
+}
194
+
195
+const listOrgRepoIDs = `-- name: ListOrgRepoIDs :many
196
+SELECT id FROM repos WHERE owner_org_id = $1
197
+`
198
+
199
+// All repo IDs (including soft-deleted) belonging to an org. Used by
200
+// the org hard-delete cascade to fan out per-repo destruction.
201
+func (q *Queries) ListOrgRepoIDs(ctx context.Context, db DBTX, ownerOrgID pgtype.Int8) ([]int64, error) {
202
+	rows, err := db.Query(ctx, listOrgRepoIDs, ownerOrgID)
203
+	if err != nil {
204
+		return nil, err
205
+	}
206
+	defer rows.Close()
207
+	items := []int64{}
208
+	for rows.Next() {
209
+		var id int64
210
+		if err := rows.Scan(&id); err != nil {
211
+			return nil, err
212
+		}
213
+		items = append(items, id)
214
+	}
215
+	if err := rows.Err(); err != nil {
216
+		return nil, err
217
+	}
218
+	return items, nil
219
+}
220
+
155221
 const resolvePrincipal = `-- name: ResolvePrincipal :one
156222
 
157223
 SELECT slug, kind, id FROM principals WHERE slug = $1
internal/orgs/sqlc/querier.gomodified
@@ -47,8 +47,18 @@ type Querier interface {
4747
 	GetOrgInvitationByID(ctx context.Context, db DBTX, id int64) (OrgInvitation, error)
4848
 	GetOrgInvitationByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (OrgInvitation, error)
4949
 	GetOrgMember(ctx context.Context, db DBTX, arg GetOrgMemberParams) (OrgMember, error)
50
+	// Final row removal after the cascade finished. The principals
51
+	// trigger drops the matching principals row in the same tx.
52
+	HardDeleteOrgRow(ctx context.Context, db DBTX, id int64) error
53
+	// Sweep input for the lifecycle worker: every soft-deleted org whose
54
+	// 14-day grace window has elapsed. The interval is intentionally a
55
+	// DB literal (not a parameter) so the policy lives next to the data.
56
+	ListOrgIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error)
5057
 	// Members of an org with usernames + roles for the people page.
5158
 	ListOrgMembers(ctx context.Context, db DBTX, orgID int64) ([]ListOrgMembersRow, error)
59
+	// All repo IDs (including soft-deleted) belonging to an org. Used by
60
+	// the org hard-delete cascade to fan out per-repo destruction.
61
+	ListOrgRepoIDs(ctx context.Context, db DBTX, ownerOrgID pgtype.Int8) ([]int64, error)
5262
 	// Profile-page input: every org a user is a member of, with role.
5363
 	ListOrgsForUser(ctx context.Context, db DBTX, userID int64) ([]ListOrgsForUserRow, error)
5464
 	ListPendingInvitationsForEmail(ctx context.Context, db DBTX, targetEmail pgtype.Text) ([]ListPendingInvitationsForEmailRow, error)
internal/worker/jobs/lifecycle_sweep.gomodified
@@ -11,6 +11,7 @@ import (
1111
 
1212
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
1313
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
14
+	"github.com/tenseleyFlow/shithub/internal/orgs"
1415
 	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
1516
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
1617
 	"github.com/tenseleyFlow/shithub/internal/worker"
@@ -67,6 +68,26 @@ func LifecycleSweep(deps LifecycleSweepDeps) worker.Handler {
6768
 		if n > 0 {
6869
 			deps.Logger.InfoContext(ctx, "lifecycle:sweep: expired transfers", "count", n)
6970
 		}
71
+
72
+		// 3. S30 — hard-delete orgs past their 14-day grace window.
73
+		// Cascade per-repo via lifecycle.HardDelete inside the org
74
+		// orchestrator. Same per-row failure-tolerant shape as the
75
+		// repo path above.
76
+		odeps := orgs.Deps{Pool: deps.Pool, Logger: deps.Logger, Audit: deps.Audit}
77
+		ohd := orgs.HardDeleteDeps{Deps: odeps, RepoFS: deps.RepoFS, Audit: deps.Audit}
78
+		orgIDs, err := orgs.ListPastGraceOrgIDs(ctx, odeps)
79
+		if err != nil {
80
+			deps.Logger.WarnContext(ctx, "lifecycle:sweep: list past-grace orgs", "error", err)
81
+		}
82
+		for _, oid := range orgIDs {
83
+			if err := ctx.Err(); err != nil {
84
+				return err
85
+			}
86
+			if err := orgs.HardDelete(ctx, ohd, oid); err != nil {
87
+				deps.Logger.WarnContext(ctx, "lifecycle:sweep: org hard delete failed",
88
+					"org_id", oid, "error", err)
89
+			}
90
+		}
7091
 		return nil
7192
 	}
7293
 }