diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index e7c7e755..a2634b2d 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -136,6 +136,11 @@ classDiagram <> +SignPut(ctx, key, size, ttl) } + class Deleter { + <> + +Delete(ctx, key) + } + note for Deleter "internal/remote — the optional delete capability in the PutSigner mold, implemented by localBackend, s3Backend and gcsBackend. Deleting a missing key is not an error. httpBackend deliberately does NOT implement it: sync clients never delete remote objects — only the hub purges, and only its own root" note for Backend "internal/remote — impls: localBackend (file://), s3Backend, gcsBackend, httpBackend (https:// hub), Prefixed wrapper" note for Backend "Key handling is fallible now: Prefixed.key and localBackend.path RETURN AN ERROR (safeKey / store.UnderRoot) rather than concatenating, so a `..` key cannot walk out of a project's prefix or out of a file:// root — and Prefixed.List re-checks the STRIPPED key on the way out, since the prefix it removes is the only thing that was ever validated. The httpBackend client is origin-bound: the device token is keyed to settings.Server, SameOrigin is the one rule, refuseOffOriginRedirect is its CheckRedirect, a presign target must be https on a trusted origin (directTargetOK), and List drops keys failing journal.SafePath and clamps a negative Size. gcs SignPut now signs Content-Length too. Object carries Modified (S3 LastModified, GCS Updated, file mtime; zero where the backend has none) — RemoteSource.verify reads it to decide when a blob can no longer be rewritten by a presigned URL" @@ -197,7 +202,7 @@ classDiagram <> +Role(org, email) +Get +OrgsFor +ListInvites +ValidInvite +ManageURL - +Create +Rename +AddMember +SetRole +RemoveMember + +Create +Rename +Delete +AddMember +SetRole +RemoveMember +CreateInvite +RevokeInvite +Redeem } class LocalDirectory { @@ -208,6 +213,7 @@ classDiagram -byID, invites -seniority func() []string +EvictMember(org, email) + +Delete(orgID) org row first, then its invites +SetSeniority(f) -heir(o) promotes an owner -refresh re-reads the store before every decision @@ -226,11 +232,20 @@ classDiagram class OrgInvite { +Token +Org +Creator +Expires +Uses } + class deleteCascade { + <> + deleteProject: tombstone, shares, cached volume, storage purge + handleOrgDelete: Dir.Delete first, then each project + audit log line + PostHog capture per delete + } + note for deleteCascade "Deleting a project TOMBSTONES the registry row FIRST — Project.Deleted/DeletedBy stay behind as the audit record, queryable via GET /api/projects?deleted=1 (same permission resolver as the live list, so a tombstone is visible to exactly whoever could see the project alive; hub admins additionally see tombstones of deleted orgs) — then revokes its share links, evicts the cached volume, and purges the storage prefix through remote.Deleter — best effort, logged, with a HasPrefix guard so a p-abc/ purge can never touch p-abcd/. Every live-project read path (Get, List, GetOrCreate name match, Update) skips tombstones, so the name is immediately reusable and no content route answers for one. Each delete also writes an `audit:` log line and a PostHog project_deleted / org_deleted event through Server.capture (a no-op unless analytics is configured). Org delete drops the org row before touching storage, so ErrManagedElsewhere from an external directory refuses the whole thing while everything is still intact. ProjectRepo.Delete (the hard remove) is now uncalled — a future tombstone purge would be its caller" class ProjectDB { -repo ProjectRepo -byID +Get +Create +Update +Rename +List + +Delete tombstones, never removes + +GetDeleted +ListDeleted +SetCreator +SetDefault +SetTemplate +SetPerm +ClearPerm -refresh re-reads the store on reads AND mutators @@ -242,6 +257,7 @@ classDiagram +Template string +Default string +Perms map email→level + +Deleted time, +DeletedBy email — tombstone } class seedTemplate { <> @@ -445,6 +461,12 @@ classDiagram DirectUploader <|.. RemoteSource RemoteSource o-- Backend : Prefixed(Root, projectID) Backend <|-- PutSigner : optional capability + Backend <|-- Deleter : optional capability + Server *-- deleteCascade : DELETE project / org routes + deleteCascade ..> ProjectDB : Delete + deleteCascade ..> ShareDB : Revoke + deleteCascade ..> Directory : Delete + deleteCascade ..> Deleter : purge prefix AuthProvider <|.. BuiltinAuth AccountApprover <|.. BuiltinAuth diff --git a/internal/remote/gcs.go b/internal/remote/gcs.go index af0ad4ed..fc16841a 100644 --- a/internal/remote/gcs.go +++ b/internal/remote/gcs.go @@ -112,4 +112,12 @@ func (b *gcsBackend) Exists(ctx context.Context, key string) (bool, error) { return false, err } +func (b *gcsBackend) Delete(ctx context.Context, key string) error { + err := b.bucket.Object(b.key(key)).Delete(ctx) + if errors.Is(err, gcs.ErrObjectNotExist) { + return nil + } + return err +} + func (b *gcsBackend) Close() error { return b.client.Close() } diff --git a/internal/remote/local.go b/internal/remote/local.go index 788a8068..30f44fe8 100644 --- a/internal/remote/local.go +++ b/internal/remote/local.go @@ -122,4 +122,22 @@ func (b *localBackend) Exists(_ context.Context, key string) (bool, error) { return false, err } +func (b *localBackend) Delete(_ context.Context, key string) error { + p, err := b.path(key) + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + // Prune now-empty parent directories so purging a prefix leaves no husk; + // os.Remove refuses a non-empty directory, which is the stop condition. + for dir := filepath.Dir(p); dir != b.root; dir = filepath.Dir(dir) { + if os.Remove(dir) != nil { + break + } + } + return nil +} + func (b *localBackend) Close() error { return nil } diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 63d218f4..392e68c3 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -55,6 +55,14 @@ type PutSigner interface { SignPut(ctx context.Context, key string, size int64, ttl time.Duration) (*SignedPut, error) } +// Deleter is the optional delete capability, in the PutSigner mold: the hub +// uses it to purge a deleted project's objects from storage. Deleting a key +// that does not exist is not an error. Sync clients never delete remote +// objects — blobs and journals are append-only from a device's point of view. +type Deleter interface { + Delete(ctx context.Context, key string) error +} + type Backend interface { Put(ctx context.Context, key string, r io.Reader, size int64) error Get(ctx context.Context, key string) (io.ReadCloser, error) diff --git a/internal/remote/s3.go b/internal/remote/s3.go index 0936d8f2..538d0e55 100644 --- a/internal/remote/s3.go +++ b/internal/remote/s3.go @@ -127,4 +127,14 @@ func (b *s3Backend) Exists(ctx context.Context, key string) (bool, error) { return false, err } +// Delete removes one object. S3's DeleteObject is idempotent — deleting a +// missing key succeeds — which matches the Deleter contract. +func (b *s3Backend) Delete(ctx context.Context, key string) error { + _, err := b.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(b.bucket), + Key: aws.String(b.key(key)), + }) + return err +} + func (b *s3Backend) Close() error { return nil } diff --git a/internal/webapp/admin.go b/internal/webapp/admin.go index 34ade2db..db2eaaf1 100644 --- a/internal/webapp/admin.go +++ b/internal/webapp/admin.go @@ -1,9 +1,15 @@ package webapp import ( + "context" "encoding/json" + "fmt" "io" + "log" "net/http" + "strings" + + "github.com/runbear-io/beardrive/internal/remote" ) // Administration surfaces: project lifecycle (rename/delete by the owning @@ -38,20 +44,113 @@ func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true}) } -// handleProjectDelete removes a project from the registry. Project admins -// only. Storage (blobs, journals) is intentionally left in place. +// handleProjectDelete removes a project from the registry and purges its +// storage prefix (blobs, journals). Project admins only. func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("project") if _, ok := s.project(w, r, id, PermAdmin); !ok { return } - if err := s.Projects.Delete(id); err != nil { + if err := s.deleteProject(r.Context(), id, s.requestUser(r).Email); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"ok": true}) } +// deleteProject tombstones the project (the registry row stays, marked +// deleted-by-whom-when — the audit record, queryable via /api/projects +// ?deleted=1), revokes its share links, drops its cached volume, and purges +// its objects from the storage root. The tombstone write is the operation; +// the purge is best effort — a storage error after the row is tombstoned +// leaves orphaned objects (the pre-purge status quo), never a half-deleted +// project. `by` is the deleting account's email. +func (s *Server) deleteProject(ctx context.Context, id, by string) error { + p, _ := s.Projects.Get(id) + if err := s.Projects.Delete(id, by); err != nil { + return err + } + log.Printf("audit: project deleted id=%s org=%s by=%s", id, p.Org, by) + s.capture(by, "project_deleted", map[string]any{"project": id, "org": p.Org}) + if s.Shares != nil { + for _, sh := range s.Shares.List(id) { + s.Shares.Revoke(sh.Token) + } + } + s.volsMu.Lock() + delete(s.vols, id) + s.volsMu.Unlock() + if err := s.purgeStorage(ctx, id); err != nil { + log.Printf("project %s deleted but storage purge failed (objects remain): %v", id, err) + } + return nil +} + +// purgeStorage deletes every object under the project's storage prefix. A +// Root without the delete capability keeps the old behavior: the id is +// retired, the objects stay for out-of-band cleanup. +func (s *Server) purgeStorage(ctx context.Context, id string) error { + d, ok := s.Root.(remote.Deleter) + if !ok { + return nil + } + objs, err := s.Root.List(ctx, id+"/") + if err != nil { + return err + } + var firstErr error + for _, o := range objs { + // List answers by string prefix, so "p-abc/" can surface a sibling + // like "p-abcd/x" on some backends — never delete outside the id. + if !strings.HasPrefix(o.Key, id+"/") { + continue + } + if err := d.Delete(ctx, o.Key); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// handleOrgDelete deletes an organization: every project it owns (registry +// and storage, via deleteProject) and then the org itself. Owners only. The +// org row goes first — it settles that this directory owns its orgs at all +// (ErrManagedElsewhere) before anything irreversible touches storage. +func (s *Server) handleOrgDelete(w http.ResponseWriter, r *http.Request) { + orgID := r.PathValue("org") + by, ok := s.requireOwner(w, r, orgID) + if !ok { + return + } + if err := s.Dir.Delete(orgID); err != nil { + s.writeDirErr(w, orgID, err) + return + } + deleted := 0 + var failed []string + if s.Projects != nil { + for _, p := range s.Projects.List() { + if p.Org != orgID { + continue + } + if err := s.deleteProject(r.Context(), p.ID, by); err != nil { + log.Printf("org %s deleted but project %s was not: %v", orgID, p.ID, err) + failed = append(failed, p.ID) + continue + } + deleted++ + } + } + log.Printf("audit: org deleted id=%s by=%s projects=%d", orgID, by, deleted) + s.capture(by, "org_deleted", map[string]any{"org": orgID, "projects": deleted}) + if len(failed) > 0 { + http.Error(w, fmt.Sprintf("organization deleted, but these projects were not: %s", + strings.Join(failed, ", ")), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + // handleOrgShares lists every live public share across the org's projects, // so an owner can audit "what have we made public?" in one place. Any org // member may view; only owners revoke (via the existing per-share endpoint). diff --git a/internal/webapp/db.go b/internal/webapp/db.go index 15a73d1b..28c8b235 100644 --- a/internal/webapp/db.go +++ b/internal/webapp/db.go @@ -197,7 +197,7 @@ func checkToken(t authToken) error { return storable(t.Hash, t.User, t.Device) } func checkProject(p Project) error { if err := storable(p.ID, p.Name, p.Org, p.Description, p.Icon, - p.Creator, p.Template, p.Default); err != nil { + p.Creator, p.Template, p.Default, p.DeletedBy); err != nil { return err } return storableMap(p.Perms) diff --git a/internal/webapp/db_conformance_test.go b/internal/webapp/db_conformance_test.go index e57e14f4..76fe7b5a 100644 --- a/internal/webapp/db_conformance_test.go +++ b/internal/webapp/db_conformance_test.go @@ -147,7 +147,7 @@ func TestMetaStoreConformance(t *testing.T) { if err := projects.SetPerm(p2.ID, "doomed@x.io", PermAdmin); err != nil { t.Fatal(err) } - if err := projects.Delete(p2.ID); err != nil { + if err := projects.Delete(p2.ID, "boss@x.io"); err != nil { t.Fatal(err) } // per-project permissions ride along with the project record @@ -292,6 +292,12 @@ func TestMetaStoreConformance(t *testing.T) { if _, ok := projects2.Get(p2.ID); ok { t.Fatal("deleted project (and its grants) came back after reload") } + // The tombstone itself survives the reload: the audit record of + // who deleted what, when. + ts, ok := projects2.GetDeleted(p2.ID) + if !ok || ts.DeletedBy != "boss@x.io" || ts.Deleted.IsZero() { + t.Fatalf("tombstone lost across reload: %+v (ok=%v)", ts, ok) + } orgs2, _ := NewOrgDB(st2.Orgs()) ro, ok := orgs2.Get(org.ID) diff --git a/internal/webapp/db_sql.go b/internal/webapp/db_sql.go index 633aff73..cb5660b5 100644 --- a/internal/webapp/db_sql.go +++ b/internal/webapp/db_sql.go @@ -295,8 +295,11 @@ func (s *sqlMetaStore) migrate() error { "creator": `TEXT NOT NULL DEFAULT ''`, "default_level": `TEXT NOT NULL DEFAULT ''`, "template": `TEXT NOT NULL DEFAULT ''`, + "deleted": `TEXT NOT NULL DEFAULT ''`, + "deleted_by": `TEXT NOT NULL DEFAULT ''`, }, map[string]string{ "default_level": "it silently re-opens every restricted project to its whole organization", + "deleted": "it resurrects every deleted project as live, with its storage already purged", }); err != nil { return err } @@ -507,7 +510,7 @@ func (r *sqlProjectRepo) Version() (string, error) { return r.s.version(regProje func (r *sqlProjectRepo) Load() ([]Project, error) { rows, err := r.s.db.Query( - `SELECT id, name, org, created, description, icon, creator, default_level, template FROM projects`) + `SELECT id, name, org, created, description, icon, creator, default_level, template, deleted, deleted_by FROM projects`) if err != nil { return nil, err } @@ -515,13 +518,13 @@ func (r *sqlProjectRepo) Load() ([]Project, error) { var order []string for rows.Next() { var p Project - var created string + var created, deleted string if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created, - &p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template); err != nil { + &p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template, &deleted, &p.DeletedBy); err != nil { rows.Close() return nil, err } - p.Created = tdec(created) + p.Created, p.Deleted = tdec(created), tdec(deleted) byID[p.ID] = &p order = append(order, p.ID) } @@ -567,12 +570,14 @@ func (r *sqlProjectRepo) Put(p Project) error { } return r.s.inTx(regProjects, func(tx *sql.Tx) error { if _, err := tx.Exec(r.s.q( - `INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template) - VALUES (?,?,?,?,?,?,?,?,?) + `INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template,deleted,deleted_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created, description=excluded.description, icon=excluded.icon, - creator=excluded.creator, default_level=excluded.default_level, template=excluded.template`), - p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template); err != nil { + creator=excluded.creator, default_level=excluded.default_level, template=excluded.template, + deleted=excluded.deleted, deleted_by=excluded.deleted_by`), + p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template, + tenc(p.Deleted), p.DeletedBy); err != nil { return err } if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), p.ID); err != nil { @@ -594,12 +599,14 @@ func (r *sqlProjectRepo) PutMeta(p Project) error { if err := checkProject(p); err != nil { return err } - return r.w.exec(`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template) - VALUES (?,?,?,?,?,?,?,?,?) + return r.w.exec(`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template,deleted,deleted_by) + VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created, description=excluded.description, icon=excluded.icon, - creator=excluded.creator, default_level=excluded.default_level, template=excluded.template`, - p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template) + creator=excluded.creator, default_level=excluded.default_level, template=excluded.template, + deleted=excluded.deleted, deleted_by=excluded.deleted_by`, + p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template, + tenc(p.Deleted), p.DeletedBy) } // PutPerm writes one grant row. An empty level removes it. diff --git a/internal/webapp/directory.go b/internal/webapp/directory.go index 00d02987..28cf7196 100644 --- a/internal/webapp/directory.go +++ b/internal/webapp/directory.go @@ -39,6 +39,10 @@ type Directory interface { // ---- writes (ErrManagedElsewhere when the directory is read-only) ---- Create(name, ownerEmail string) (Org, error) Rename(orgID, name string) error + // Delete removes the org and its invites. Its projects are the hub's to + // cascade (registry rows and storage) — the directory knows nothing of + // project storage. + Delete(orgID string) error AddMember(orgID, email, role string) error SetRole(orgID, email, role string) error RemoveMember(orgID, email string) error diff --git a/internal/webapp/directory_test.go b/internal/webapp/directory_test.go index bc22e5f4..0e1ca9fe 100644 --- a/internal/webapp/directory_test.go +++ b/internal/webapp/directory_test.go @@ -23,6 +23,7 @@ type readOnlyDir struct{ Directory } func (readOnlyDir) Create(string, string) (Org, error) { return Org{}, ErrManagedElsewhere } func (readOnlyDir) Rename(string, string) error { return ErrManagedElsewhere } +func (readOnlyDir) Delete(string) error { return ErrManagedElsewhere } func (readOnlyDir) AddMember(string, string, string) error { return ErrManagedElsewhere } func (readOnlyDir) SetRole(string, string, string) error { return ErrManagedElsewhere } func (readOnlyDir) RemoveMember(string, string) error { return ErrManagedElsewhere } diff --git a/internal/webapp/lifecycle_test.go b/internal/webapp/lifecycle_test.go index 0861de17..dd8627df 100644 --- a/internal/webapp/lifecycle_test.go +++ b/internal/webapp/lifecycle_test.go @@ -3,6 +3,7 @@ package webapp import ( "encoding/json" "net/http" + "os" "path/filepath" "strings" "testing" @@ -111,12 +112,32 @@ func TestProjectLifecycle(t *testing.T) { t.Fatalf("rejected updates mutated the project: %+v", got) } - if err := db.Delete(p.ID); err != nil { + if err := db.Delete(p.ID, "boss@x.io"); err != nil { t.Fatal(err) } if _, ok := db.Get(p.ID); ok { t.Fatal("deleted project still present") } + // The tombstone is queryable and says who deleted it, when. + ts, ok := db.GetDeleted(p.ID) + if !ok || ts.DeletedBy != "boss@x.io" || ts.Deleted.IsZero() { + t.Fatalf("tombstone: %+v (ok=%v)", ts, ok) + } + if got := db.ListDeleted(); len(got) != 1 || got[0].ID != p.ID { + t.Fatalf("ListDeleted = %+v", got) + } + // Deleting twice is refused; mutating a tombstone is refused. + if err := db.Delete(p.ID, "boss@x.io"); err == nil { + t.Fatal("double delete must be refused") + } + if err := db.Rename(p.ID, "revived"); err == nil { + t.Fatal("renaming a tombstone must be refused") + } + // The name is free again: a new project by the old name gets a fresh id. + again, created, err := db.GetOrCreate("handbook", "o-1") + if err != nil || !created || again.ID == p.ID { + t.Fatalf("name reuse after delete: %+v created=%v err=%v", again, created, err) + } } // Owner-only guards on the HTTP surface: a plain member is refused, an owner @@ -146,6 +167,117 @@ func TestAdminEndpointsOwnerOnly(t *testing.T) { } } +// Deleting a project purges its storage prefix from the root; deleting an +// org cascades to its projects (registry and storage) and then the org row. +// Sibling projects and other orgs are untouched. +func TestDeletePurgesStorage(t *testing.T) { + srv, _, root := newHub(t, true, nil) + auth, err := OpenBuiltinAuth(filepath.Join(t.TempDir(), "auth.json"), true, nil) + if err != nil { + t.Fatal(err) + } + srv.Auth = auth + orgs, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json")) + if err != nil { + t.Fatal(err) + } + srv.Dir = LocalDirectory{OrgDB: orgs} + h := srv.Handler() + alice := signupAndSession(t, h, "alice@x.io", "Alice", "password1") + bob := signupAndSession(t, h, "bob@x.io", "Bob", "password1") + + create := func(name string, c *http.Cookie) Project { + t.Helper() + rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": name}, c) + if rec.Code != 200 { + t.Fatalf("create %s: %d %s", name, rec.Code, rec.Body) + } + var out struct { + Project Project `json:"project"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out.Project + } + seed := func(id string) string { + t.Helper() + blob := filepath.Join(root, id, "blobs", "aa") + if err := os.MkdirAll(filepath.Dir(blob), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(blob, []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + return filepath.Join(root, id) + } + + wiki, docs, bobs := create("wiki", alice), create("docs", alice), create("bobs", bob) + wikiDir, docsDir, bobsDir := seed(wiki.ID), seed(docs.ID), seed(bobs.ID) + + // Project delete purges exactly that project's prefix. + if rec := doAs(t, h, "DELETE", "/api/projects/"+wiki.ID, nil, alice); rec.Code != 200 { + t.Fatalf("project delete: %d %s", rec.Code, rec.Body) + } + if _, err := os.Stat(wikiDir); !os.IsNotExist(err) { + t.Fatalf("deleted project's storage still on disk: %v", err) + } + + // The tombstone is queryable: gone from the live list, present in + // ?deleted=1 for a member of its org, invisible to an outsider. + deletedList := func(c *http.Cookie) []Project { + t.Helper() + rec := doAs(t, h, "GET", "/api/projects?deleted=1", nil, c) + if rec.Code != 200 { + t.Fatalf("deleted list: %d %s", rec.Code, rec.Body) + } + var out struct { + Projects []Project `json:"projects"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out.Projects + } + rec := doAs(t, h, "GET", "/api/projects", nil, alice) + if strings.Contains(rec.Body.String(), wiki.ID) { + t.Fatalf("deleted project still in the live list: %s", rec.Body) + } + got := deletedList(alice) + if len(got) != 1 || got[0].ID != wiki.ID || got[0].DeletedBy != "alice@x.io" || got[0].Deleted.IsZero() { + t.Fatalf("alice's deleted list = %+v", got) + } + if got := deletedList(bob); len(got) != 0 { + t.Fatalf("bob sees another org's tombstones: %+v", got) + } + for _, dir := range []string{docsDir, bobsDir} { + if _, err := os.Stat(dir); err != nil { + t.Fatalf("sibling storage touched: %v", err) + } + } + + // Org delete: not for non-owners; for the owner it takes projects, + // storage, and the org row with it. + if rec := doAs(t, h, "DELETE", "/api/orgs/"+docs.Org, nil, bob); rec.Code != http.StatusForbidden { + t.Fatalf("non-owner org delete: %d", rec.Code) + } + if rec := doAs(t, h, "DELETE", "/api/orgs/"+docs.Org, nil, alice); rec.Code != 200 { + t.Fatalf("org delete: %d %s", rec.Code, rec.Body) + } + if _, err := os.Stat(docsDir); !os.IsNotExist(err) { + t.Fatalf("deleted org's project storage still on disk: %v", err) + } + if _, ok := srv.Projects.Get(docs.ID); ok { + t.Fatal("deleted org's project still registered") + } + if _, ok := orgs.Get(docs.Org); ok { + t.Fatal("deleted org still present") + } + if _, err := os.Stat(bobsDir); err != nil { + t.Fatalf("another org's storage touched: %v", err) + } +} + // PATCH /api/projects/{id} is a partial update: only the keys in the body // move, present-and-empty clears, and every validation failure is a 400. The // permission gate is unchanged — a non-member still gets what it got before. diff --git a/internal/webapp/orgs.go b/internal/webapp/orgs.go index 5fdc80a1..d5694f32 100644 --- a/internal/webapp/orgs.go +++ b/internal/webapp/orgs.go @@ -508,6 +508,29 @@ func (db *OrgDB) Rename(orgID, name string) error { return db.putOrg(o, next) } +// Delete removes the org and its invites. The org row goes first — an org +// the store refuses to drop stays whole, invites and all; an invite the +// store then refuses to drop is already dead, because every membership check +// walks through the (gone) org row. +func (db *OrgDB) Delete(orgID string) error { + db.mu.Lock() + defer db.mu.Unlock() + db.refresh() + if _, ok := db.byID[orgID]; !ok { + return fmt.Errorf("no such organization") + } + if err := db.repo.DeleteOrg(orgID); err != nil { + return err + } + delete(db.byID, orgID) + for token, inv := range db.invites { + if inv.Org == orgID { + db.retireLocked(token) + } + } + return nil +} + // ownerCount counts owners in an org. Callers hold mu. func (db *OrgDB) ownerCount(o Org) int { n := 0 diff --git a/internal/webapp/orgs_test.go b/internal/webapp/orgs_test.go index 786a1771..b707726e 100644 --- a/internal/webapp/orgs_test.go +++ b/internal/webapp/orgs_test.go @@ -344,6 +344,7 @@ func TestReadOnlyDirectoryRefusesWrites(t *testing.T) { body any }{ {"PATCH", "/api/orgs/" + pa.Org, map[string]string{"name": "nope"}}, + {"DELETE", "/api/orgs/" + pa.Org, nil}, {"PATCH", "/api/orgs/" + pa.Org + "/members/bob@x.io", map[string]string{"role": "owner"}}, {"DELETE", "/api/orgs/" + pa.Org + "/members/bob@x.io", nil}, {"POST", "/api/orgs/" + pa.Org + "/invites", nil}, diff --git a/internal/webapp/projects.go b/internal/webapp/projects.go index 1198af5f..43b02b70 100644 --- a/internal/webapp/projects.go +++ b/internal/webapp/projects.go @@ -38,6 +38,14 @@ type Project struct { Default string `json:"default,omitempty"` // Perms are the explicit grants, lowercase email → level. Perms map[string]string `json:"perms,omitempty"` + // Deleted marks a tombstone: the project was deleted (storage purged, + // shares revoked) but the row stays — who deleted what, when, remains + // queryable, and that row IS the audit record of the deletion. Zero for + // live projects. Every live-project read path skips tombstones, so the + // name is immediately reusable; a new project by the old name gets a + // fresh id and a fresh storage prefix. + Deleted time.Time `json:"deleted,omitzero"` + DeletedBy string `json:"deleted_by,omitempty"` // account email } // level is the project's effective default level for org members. @@ -227,16 +235,33 @@ func (db *ProjectDB) refresh() { db.byID = next } -// list returns projects sorted by name. Callers hold mu. +// list returns live projects sorted by name. Callers hold mu. func (db *ProjectDB) list() []Project { out := make([]Project, 0, len(db.byID)) for _, p := range db.byID { - out = append(out, p.clone()) + if p.Deleted.IsZero() { + out = append(out, p.clone()) + } } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } +// ListDeleted returns the tombstones, most recently deleted first. +func (db *ProjectDB) ListDeleted() []Project { + db.mu.Lock() + defer db.mu.Unlock() + db.refresh() + var out []Project + for _, p := range db.byID { + if !p.Deleted.IsZero() { + out = append(out, p.clone()) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Deleted.After(out[j].Deleted) }) + return out +} + func (db *ProjectDB) List() []Project { db.mu.Lock() defer db.mu.Unlock() @@ -249,7 +274,19 @@ func (db *ProjectDB) Get(id string) (Project, bool) { defer db.mu.Unlock() db.refresh() p, ok := db.byID[id] - if !ok { + if !ok || !p.Deleted.IsZero() { + return Project{}, false + } + return p.clone(), true +} + +// GetDeleted returns a tombstone by id. +func (db *ProjectDB) GetDeleted(id string) (Project, bool) { + db.mu.Lock() + defer db.mu.Unlock() + db.refresh() + p, ok := db.byID[id] + if !ok || p.Deleted.IsZero() { return Project{}, false } return p.clone(), true @@ -267,7 +304,7 @@ func (db *ProjectDB) GetOrCreate(name, org string) (Project, bool, error) { defer db.mu.Unlock() db.refresh() for _, p := range db.byID { - if p.Name == name && p.Org == org { + if p.Name == name && p.Org == org && p.Deleted.IsZero() { return p.clone(), false, nil } } @@ -312,13 +349,13 @@ func (db *ProjectDB) Update(id string, name, description, icon *string) error { defer db.mu.Unlock() db.refresh() p, ok := db.byID[id] - if !ok { + if !ok || !p.Deleted.IsZero() { return fmt.Errorf("no such project %q", id) } next := p if name != nil { for _, other := range db.byID { - if other.ID != id && other.Name == newName && other.Org == p.Org { + if other.ID != id && other.Name == newName && other.Org == p.Org && other.Deleted.IsZero() { return fmt.Errorf("a project named %q already exists in this organization", newName) } } @@ -338,18 +375,27 @@ func (db *ProjectDB) Rename(id, name string) error { return db.Update(id, &name, nil, nil) } -// Delete removes a project from the registry. Its storage prefix (blobs, -// journals) is left in the object store — the id is retired, not scrubbed — -// so the caller decides whether to reclaim that space out of band. -func (db *ProjectDB) Delete(id string) error { +// Delete tombstones a project: the row stays in the registry with Deleted +// and DeletedBy set — the durable audit record of who deleted what, when — +// and every live-project read path stops answering for it. `by` is the +// deleting account's email. The grant set stays on the tombstone: no content +// route can reach it (Get filters tombstones), and the deleted LISTING runs +// the same permission resolver, so a tombstone is visible to exactly whoever +// could see the project alive — never wider. ProjectRepo.Delete (the hard +// remove) is no longer called from here; a purge of old tombstones would be +// its caller. +func (db *ProjectDB) Delete(id, by string) error { db.mu.Lock() defer db.mu.Unlock() db.refresh() - if _, ok := db.byID[id]; !ok { + p, ok := db.byID[id] + if !ok || !p.Deleted.IsZero() { return fmt.Errorf("no such project %q", id) } - delete(db.byID, id) - return db.repo.Delete(id) + next := p + next.Deleted = time.Now().UTC() + next.DeletedBy = normEmail(by) + return db.put(p, next) } // SetCreator records who created a project (and is its first admin). diff --git a/internal/webapp/sec_fixes13_test.go b/internal/webapp/sec_fixes13_test.go index f4495101..550a5209 100644 --- a/internal/webapp/sec_fixes13_test.go +++ b/internal/webapp/sec_fixes13_test.go @@ -100,7 +100,7 @@ func TestSec_Perms_ASecondHubProcessCannotResurrectADeletedProject(t *testing.T) // The operator deletes the project on process A. This is the whole // operation: "this project, and everything it published, is gone." - if err := a.Delete(p.ID); err != nil { + if err := a.Delete(p.ID, "op@x.io"); err != nil { t.Fatal(err) } if _, ok := a.Get(p.ID); ok { diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 1c324f51..ef8c1854 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -847,6 +847,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/orgs", s.handleOrgList) mux.HandleFunc("PATCH /api/orgs/{org}", s.handleOrgRename) + mux.HandleFunc("DELETE /api/orgs/{org}", s.handleOrgDelete) mux.HandleFunc("POST /api/orgs/{org}/invites", s.handleInviteCreate) mux.HandleFunc("GET /api/orgs/{org}/invites", s.handleInviteList) mux.HandleFunc("DELETE /api/orgs/{org}/invites/{token}", s.handleInviteRevoke) @@ -1041,12 +1042,24 @@ func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { http.Error(w, "this server does not host projects", http.StatusNotFound) return } + // ?deleted=1 lists tombstones instead: who deleted what, when. The same + // permission resolver gates each row — a tombstone is visible to exactly + // whoever could see the project alive — plus hub admins, who are the only + // audience left once a whole org (and with it every membership the + // resolver could consult) is gone. + rows, admin := s.Projects.List, false + if r.URL.Query().Get("deleted") != "" { + rows = s.Projects.ListDeleted + // The bypass exists ONLY here: a live project's visibility is its + // members', hub admin or not. + admin = s.requestUser(r).Admin + } // Each row carries the caller's own level, so the frontend can hide write // affordances without a second fetch per project on every render. visible := []projectView{} - for _, p := range s.Projects.List() { + for _, p := range rows() { perm := s.projectPermOf(r, p) - if !atLeast(perm, PermRead) { + if !atLeast(perm, PermRead) && !admin { continue } visible = append(visible, projectJSON(p, perm))