Decision: how does a Space get deleted? (lifecycle end-of-life for spaces) #268

Closed
opened 2026-08-17 21:38:58 +00:00 by Cordy · 3 comments
Owner

Context

Dogfood finding (2026-08-17, v0.6.41): attempting to delete /spaces/test from the file listing's 3-dot menu failed with Delete failed: invalid path. Root cause: by design — the scope driver refuses to delete /, /home, and /spaces/<name> roots (internal/storage/scope/scope_test.go asserts exactly this). A space is a managed container, not a folder.

v0.6.41 shipped the honest stopgap: the UI now says "Spaces are managed containers and cannot be deleted from the file listing." — but there is currently no way at all to end a space's life. Spaces accumulate forever. This issue decides how deletion should actually work, for the Spaces admin topic (#admin/spaces), not the file listing.

What has to be decided

  1. Who may delete a space — instance admin only? space owner (app-owned spaces have one)? owner-initiated + admin-confirmed?
  2. What happens to the data — hard-delete the /spaces/<name>/ prefix, or soft-delete with a restore window?
  3. The two custody models differ:
    • App-owned spaces (openbao dogfood): Cairn owns membership (state-backend) and data → full lifecycle is ours to implement.
    • IdP-managed spaces (enc dogfood): the group lives in Keycloak. Deleting the group in the IdP already "removes" the space from the UI but orphans the data silently in the bucket. Decide: does Cairn delete the IdP group (needs the admin client, which not every instance has)? Or is the flow "delete group in IdP first, then Cairn offers to clean up the orphaned /spaces/<name>/ data"? An admin panel that lists orphaned space prefixes may be needed regardless.
  4. Safety interlocks — legal holds covering any path under the space MUST block deletion (same refuse-on-conflict stance as #138); the group encryption key and any peering recvSpaces allow-list entries referencing the space need cleanup; audit event.

How competitors handle this

Product Who can delete Flow Data safety net
ownCloud Infinite Scale (Spaces) Space manager / space admin role (strict split between content access and space management) Two-phase: DELETE /drives/{id} disables the space; a second explicit call permanently deletes; restore possible while disabled Disabled state is the undo window. Known sharp edge we should avoid: removing the last manager leaves an unmanageable orphan space admins couldn't delete (ocis#4105/#4196). With IdP-managed spaces (OCIS_CLAIM_MANAGED_SPACES_ENABLED) management moves entirely to the IdP — the closest analogue to our Keycloak mode
Google Shared Drives Members with manager rights only if the drive is empty (delete greyed out otherwise); Workspace admins can delete with content Empty-first requirement pushes intent-confirmation onto the user Deleted drives stay restorable by admins for 30 days; trashed files 30 days + 25 more admin-side
SharePoint / Teams sites Site/tenant admins Single delete, heavy on restore instead Deleted sites restorable for 93 days; connected Microsoft 365 Group assets only 30 — the mismatch is a recurring footgun
Dropbox team folders Admins only Archive first, then delete — permanent delete only possible from the archived state Archive is the undo window; after permanent delete even Dropbox Support cannot recover
Seafile libraries Owner deletes; admin can restore Single delete into a deleted-libraries list No automatic purge — deleted libraries linger until an admin runs cleanup (the "grows forever" failure mode on the other end)
Nextcloud Group/Team folders Instance admin (groupfolders admin panel) Single delete Trash support for group-folder contents is historically buggy (files skipping trash, unbounded trash growth) — cautionary tale for bolting trash on afterwards

The industry consensus is clear: two-phase deletion (disable/archive → delete) gated to an admin role, with a restore window. Nobody reputable does single-click permanent deletion of a shared container; Google's empty-first rule is the outlier alternative.

Candidate designs

Option A — oCIS/Dropbox-style two-phase (recommended starting point):
#admin/spaces gains per-space actions: Deactivate (space disappears from members' UI, data untouched, admin list shows it greyed with a Reactivate action) → Delete (only offered on a deactivated space; type-the-name confirm; refuses while any legal hold covers the space; audited). Data goes to a .cairn-state-adjacent tombstone or straight to hard-delete — sub-decision. IdP-managed instances: Deactivate/Delete acts on Cairn's side only (data + key + peering references); the panel shows "group still exists in your IdP" / "orphaned data, group gone" states.

Option B — Google-style empty-first: delete allowed (admin only) when the space contains no objects; otherwise the button explains what's still inside. Simplest to build, pushes the data question to the user, no restore window needed. Weak for big cleanups.

Option C — full trash integration: deleting a space moves its prefix into the existing trash subsystem with the standard retention window. Most consistent with file semantics, but heaviest: trash was designed for files, and Nextcloud's group-folder trash history shows how this path accumulates bugs.

Orthogonal follow-up regardless of option: an orphaned space data report for IdP-managed instances (prefixes under /spaces/ whose group no longer resolves), since IdP-side group deletion bypasses any flow we build.

Sources

## Context Dogfood finding (2026-08-17, v0.6.41): attempting to delete `/spaces/test` from the file listing's 3-dot menu failed with `Delete failed: invalid path`. Root cause: **by design** — the scope driver refuses to delete `/`, `/home`, and `/spaces/<name>` roots (`internal/storage/scope/scope_test.go` asserts exactly this). A space is a managed container, not a folder. v0.6.41 shipped the honest stopgap: the UI now says "Spaces are managed containers and cannot be deleted from the file listing." — but there is currently **no way at all** to end a space's life. Spaces accumulate forever. This issue decides how deletion should actually work, for the Spaces admin topic (`#admin/spaces`), not the file listing. ## What has to be decided 1. **Who** may delete a space — instance admin only? space owner (app-owned spaces have one)? owner-initiated + admin-confirmed? 2. **What happens to the data** — hard-delete the `/spaces/<name>/` prefix, or soft-delete with a restore window? 3. **The two custody models differ:** - **App-owned spaces** (openbao dogfood): Cairn owns membership (state-backend) *and* data → full lifecycle is ours to implement. - **IdP-managed spaces** (enc dogfood): the group lives in Keycloak. Deleting the group in the IdP already "removes" the space from the UI but **orphans the data silently** in the bucket. Decide: does Cairn delete the IdP group (needs the admin client, which not every instance has)? Or is the flow "delete group in IdP first, then Cairn offers to clean up the orphaned `/spaces/<name>/` data"? An admin panel that *lists orphaned space prefixes* may be needed regardless. 4. **Safety interlocks** — legal holds covering any path under the space MUST block deletion (same refuse-on-conflict stance as #138); the group encryption key and any peering `recvSpaces` allow-list entries referencing the space need cleanup; audit event. ## How competitors handle this | Product | Who can delete | Flow | Data safety net | |---|---|---|---| | **ownCloud Infinite Scale (Spaces)** | Space manager / space admin role (strict split between content access and space management) | **Two-phase:** `DELETE /drives/{id}` *disables* the space; a second explicit call permanently deletes; restore possible while disabled | Disabled state is the undo window. Known sharp edge we should avoid: removing the last manager leaves an **unmanageable orphan space** admins couldn't delete (ocis#4105/#4196). With IdP-managed spaces (`OCIS_CLAIM_MANAGED_SPACES_ENABLED`) management moves entirely to the IdP — the closest analogue to our Keycloak mode | | **Google Shared Drives** | Members with manager rights only if the drive is **empty** (delete greyed out otherwise); Workspace admins can delete with content | Empty-first requirement pushes intent-confirmation onto the user | Deleted drives stay restorable by admins for **30 days**; trashed files 30 days + 25 more admin-side | | **SharePoint / Teams sites** | Site/tenant admins | Single delete, heavy on restore instead | Deleted sites restorable for **93 days**; connected Microsoft 365 Group assets only 30 — the mismatch is a recurring footgun | | **Dropbox team folders** | Admins only | **Archive first, then delete** — permanent delete only possible from the archived state | Archive is the undo window; after permanent delete even Dropbox Support cannot recover | | **Seafile libraries** | Owner deletes; admin can restore | Single delete into a deleted-libraries list | No automatic purge — deleted libraries linger until an admin runs cleanup (the "grows forever" failure mode on the *other* end) | | **Nextcloud Group/Team folders** | Instance admin (groupfolders admin panel) | Single delete | Trash support for group-folder *contents* is historically buggy (files skipping trash, unbounded trash growth) — cautionary tale for bolting trash on afterwards | **The industry consensus is clear: two-phase deletion (disable/archive → delete) gated to an admin role, with a restore window.** Nobody reputable does single-click permanent deletion of a shared container; Google's empty-first rule is the outlier alternative. ## Candidate designs **Option A — oCIS/Dropbox-style two-phase (recommended starting point):** `#admin/spaces` gains per-space actions: **Deactivate** (space disappears from members' UI, data untouched, admin list shows it greyed with a Reactivate action) → **Delete** (only offered on a deactivated space; type-the-name confirm; refuses while any legal hold covers the space; audited). Data goes to a `.cairn-state`-adjacent tombstone or straight to hard-delete — sub-decision. IdP-managed instances: Deactivate/Delete acts on Cairn's side only (data + key + peering references); the panel shows "group still exists in your IdP" / "orphaned data, group gone" states. **Option B — Google-style empty-first:** delete allowed (admin only) when the space contains no objects; otherwise the button explains what's still inside. Simplest to build, pushes the data question to the user, no restore window needed. Weak for big cleanups. **Option C — full trash integration:** deleting a space moves its prefix into the existing trash subsystem with the standard retention window. Most consistent with file semantics, but heaviest: trash was designed for files, and Nextcloud's group-folder trash history shows how this path accumulates bugs. **Orthogonal follow-up regardless of option:** an *orphaned space data* report for IdP-managed instances (prefixes under `/spaces/` whose group no longer resolves), since IdP-side group deletion bypasses any flow we build. ## Sources - [oCIS Spaces API (disable via DELETE, restore/permanent-delete via PATCH)](https://owncloud.dev/apis/http/graph/spaces/) - [ocis#4105 — removal of last Space Manager leaves unmanageable space](https://github.com/owncloud/ocis/issues/4105) / [ocis#4196 — admin permission to delete orphaned spaces](https://github.com/owncloud/ocis/issues/4196) - [oCIS storage-users service (IdP-managed spaces, purging disabled spaces)](https://owncloud.dev/services/storage-users/) - [Google Workspace: delete or restore a shared drive](https://knowledge.workspace.google.com/admin/drive/delete-or-restore-a-shared-drive-and-its-files-for-your-users) - [How to delete a Google Shared Drive (empty-first behavior)](https://hellocomtec.com/collab/knowledge-base/article/how-to-delete-a-google-shared-drive-that-you-did-not-create) - [Microsoft Learn: restore deleted SharePoint sites (93 days)](https://learn.microsoft.com/en-us/sharepoint/restore-deleted-site-collection) - [Dropbox team folder manager (archive → delete, admin-only restore)](https://help.dropbox.com/organize/team-folder-manager) - [Seafile: deleting a library](https://help.seafile.com/file_folder_managing/deleting_a_library/) / [deleted libraries not cleaned automatically](https://forum.seafile.com/t/deleted-libraries-don-t-get-cleaned-automatically/11403) - [Nextcloud group-folders trash pain points](https://help.nextcloud.com/t/files-in-team-folders-do-not-go-to-trashbin-when-deleted-as-shown-in-the-ui-and-cannot-be-restored/233462)
Author
Owner

Deep-dive outcome (2026-08-18): custody comparison, the IdP-group inversion, and decisions

What the code actually says

Read internal/spaces (route A, #94), internal/spacestore (ADR 0001), internal/auth (claims pipeline), internal/storage/encrypt (group keys), internal/peering (recvSpaces):

  • Authorization never needed the admin client. auth.User.Groups is filled from the OIDC groups claim (or LDAP memberOf); the scope decorator surfaces /spaces/<g> from the user's own claim; auth.adminGroups gates admin the same way. Access to IdP-group spaces is already admin-client-free.
  • The admin client is load-bearing for exactly five things: Cairn creating the Keycloak group pair (<name> + <name>-ro) in route A, member add/remove writes, member enumeration, the candidates picker (#226), and WithLiveGroups freshness (#96).
  • spacestore already is a complete app-owned space object (owner + members, encrypted in the state-backend, "no IdP write, works under every auth mode and key custody").
  • Group keys are lazily provisioned per group name in custody (groupKeysFor).

Previously unfiled pitfalls found

  1. spacestore.Delete is metadata-only and live today (#211 API): deleting an app-owned space removes the membership object but leaves the whole /spaces/<name>/ prefix orphaned in the bucket and the group key alive in custody. The #268 problem is not just "no way to delete" — the one deletion path that exists deletes the wrong half.
  2. Name-reuse resurrection: keys are per-name, so a recreated same-name space can read any leftover ciphertext (missed purge, trash, backups).
  3. Stale peering allow-lists: recvSpaces entries reference spaces by name; after deletion they would silently authorize delivery into a future same-name space.
  4. Route-A group-pair fragility: an IdP admin deleting one group of the pair leaves a half-space; the -ro suffix can collide with genuine IdP groups. (Also: route A requires manage-groups/manage-users — a heavy grant IdP admins reasonably refuse.)
  5. Trash interplay: space contents already in trash at deletion time need a defined fate.

The inversion (challenged as proposed by Nikola)

"Do spaces need to create IdP groups?" — No. Creation existed only so Cairn could self-service route-A spaces; access always flowed from claims. Cairn writing groups into the IdP is implementing identity in someone else's system; consuming claims is the "identity is consumed, not implemented" thesis done properly.

"Can IdP groups feed / update member lists?" — Yes, without an admin client: a space binds IdP group names; holders of the claim are members, evaluated live per request. The "list" updates itself functionally at token refresh (the known #96 staleness trade, unchanged); with a read-only admin client present, WithLiveGroups + enumeration upgrade freshness and roster display.

Decisions (Nikola, 2026-08-18)

  1. Unified model, route A kept as opt-in. A space is always a Cairn object (spacestore) with owner, explicit members, and optional idpGroups bindings (binding carries a role: write / read). Instances that configure a manage-capable admin client may still opt into route-A style IdP writes; the default requires no admin client for any space lifecycle.
  2. Union membership: effective members = explicit members ∪ claim-holders of bound groups; roster UI distinguishes the two origins. Owner rules unchanged (owner is write, undeletable from membership).
  3. Deletion = Deactivate → Delete, contents to trash with retention. Deactivate: reversible, hides the space and refuses access (the app-owned flag is authoritative and beats a valid claim). Delete: type-the-name confirm, refuses while any legal hold covers the prefix, moves contents through the trash subsystem with its retention window, scrubs peering recvSpaces entries, audits with a roster snapshot (post-delete the roster is otherwise unreconstructable).
  4. Name tombstone: a deleted space's name is blocked from reuse for a window. Synthesis with (3): tombstone window = trash retention window, and the group key retires at final purge — the name is reusable exactly when nothing restorable remains, closing both the resurrection and allow-list hazards without a crypto change.

Custody-model comparison under the unified design

Concern keycloak-profile (enc) openbao app-owned today
Space lifecycle unified object — identical identical identical (gains the real deletion pipeline)
Membership source claims (+ optional route-A writes, admin client present) claims + explicit explicit (+ bindings now possible)
Group key retire on purge Keycloak attribute removal KV delete same custody call
Migration existing IdP-managed spaces become objects bound to their same-named groups (behavior-preserving); route-A owner read from the group attribute none needed none needed

Follow-on (to cut as implementation issues when scheduled)

  • Unification epic: space object gains idpGroups bindings + deactivated flag; scope decorator consults the space object; route-A writes behind the opt-in.
  • #268 implementation: Deactivate/Delete in #admin/spaces + owner surface, trash integration, tombstone registry, hold interlock, peering scrub, audit snapshot; fix the existing metadata-only spacestore.Delete as part of it.
  • Docs: sharp-edge note that backups outlive tombstones (a restored backup + expired tombstone can still resurrect — operator responsibility, same class as the #138 backup story).
## Deep-dive outcome (2026-08-18): custody comparison, the IdP-group inversion, and decisions ### What the code actually says Read `internal/spaces` (route A, #94), `internal/spacestore` (ADR 0001), `internal/auth` (claims pipeline), `internal/storage/encrypt` (group keys), `internal/peering` (recvSpaces): - **Authorization never needed the admin client.** `auth.User.Groups` is filled from the OIDC groups claim (or LDAP `memberOf`); the scope decorator surfaces `/spaces/<g>` from the *user's own* claim; `auth.adminGroups` gates admin the same way. Access to IdP-group spaces is already admin-client-free. - The admin client is load-bearing for exactly five things: **Cairn creating the Keycloak group pair** (`<name>` + `<name>-ro`) in route A, member add/remove **writes**, member **enumeration**, the candidates picker (#226), and `WithLiveGroups` freshness (#96). - `spacestore` already is a complete app-owned space object (owner + members, encrypted in the state-backend, "no IdP write, works under every auth mode and key custody"). - Group keys are lazily provisioned **per group name** in custody (`groupKeysFor`). ### Previously unfiled pitfalls found 1. **`spacestore.Delete` is metadata-only and live today** (#211 API): deleting an app-owned space removes the membership object but leaves the whole `/spaces/<name>/` prefix orphaned in the bucket and the group key alive in custody. The #268 problem is not just "no way to delete" — the one deletion path that exists deletes the wrong half. 2. **Name-reuse resurrection**: keys are per-name, so a recreated same-name space can read any leftover ciphertext (missed purge, trash, backups). 3. **Stale peering allow-lists**: `recvSpaces` entries reference spaces by name; after deletion they would silently authorize delivery into a *future* same-name space. 4. **Route-A group-pair fragility**: an IdP admin deleting one group of the pair leaves a half-space; the `-ro` suffix can collide with genuine IdP groups. (Also: route A requires `manage-groups`/`manage-users` — a heavy grant IdP admins reasonably refuse.) 5. **Trash interplay**: space contents already in trash at deletion time need a defined fate. ### The inversion (challenged as proposed by Nikola) "Do spaces need to create IdP groups?" — **No.** Creation existed only so Cairn could self-service route-A spaces; access always flowed from claims. Cairn writing groups into the IdP is *implementing* identity in someone else's system; consuming claims is the "identity is consumed, not implemented" thesis done properly. "Can IdP groups feed / update member lists?" — **Yes, without an admin client**: a space *binds* IdP group names; holders of the claim are members, evaluated live per request. The "list" updates itself functionally at token refresh (the known #96 staleness trade, unchanged); with a read-only admin client present, `WithLiveGroups` + enumeration upgrade freshness and roster display. ### Decisions (Nikola, 2026-08-18) 1. **Unified model, route A kept as opt-in.** A space is always a Cairn object (spacestore) with owner, explicit members, and optional `idpGroups` bindings (binding carries a role: write / read). Instances that configure a manage-capable admin client may still opt into route-A style IdP writes; the default requires **no admin client for any space lifecycle**. 2. **Union membership**: effective members = explicit members ∪ claim-holders of bound groups; roster UI distinguishes the two origins. Owner rules unchanged (owner is write, undeletable from membership). 3. **Deletion = Deactivate → Delete, contents to trash with retention.** Deactivate: reversible, hides the space and refuses access (the app-owned flag is authoritative and beats a valid claim). Delete: type-the-name confirm, **refuses while any legal hold covers the prefix**, moves contents through the trash subsystem with its retention window, scrubs peering `recvSpaces` entries, audits with a roster snapshot (post-delete the roster is otherwise unreconstructable). 4. **Name tombstone**: a deleted space's name is blocked from reuse for a window. Synthesis with (3): **tombstone window = trash retention window**, and the group key retires at final purge — the name is reusable exactly when nothing restorable remains, closing both the resurrection and allow-list hazards without a crypto change. ### Custody-model comparison under the unified design | Concern | keycloak-profile (enc) | openbao | app-owned today | |---|---|---|---| | Space lifecycle | unified object — identical | identical | identical (gains the real deletion pipeline) | | Membership source | claims (+ optional route-A writes, admin client present) | claims + explicit | explicit (+ bindings now possible) | | Group key retire on purge | Keycloak attribute removal | KV delete | same custody call | | Migration | existing IdP-managed spaces become objects bound to their same-named groups (behavior-preserving); route-A owner read from the group attribute | none needed | none needed | ### Follow-on (to cut as implementation issues when scheduled) - Unification epic: space object gains `idpGroups` bindings + deactivated flag; scope decorator consults the space object; route-A writes behind the opt-in. - #268 implementation: Deactivate/Delete in `#admin/spaces` + owner surface, trash integration, tombstone registry, hold interlock, peering scrub, audit snapshot; **fix the existing metadata-only `spacestore.Delete` as part of it**. - Docs: sharp-edge note that backups outlive tombstones (a restored backup + expired tombstone can still resurrect — operator responsibility, same class as the #138 backup story).
Author
Owner
@hyrsh
Author
Owner

Closing: every decision in this issue is now recorded and implemented. Unified app-owned space object with the #268 outcomes shipped across the epic — Deactivate → Delete with contents to the trash retention window (#286, v0.6.54), name tombstone = trash retention window (spacestore tombstones with expiry), trash surface as first-class admin topic (#278/#279/#280/#281, v0.6.48–v0.6.51), and route A demoted to opt-in (lifecycle routes answer 501 on IdP-managed spaces). Group-key retirement at final purge remains noted in #286's known-edges for the eventual custody follow-up. Dogfood verification of the lifecycle itself continues in #286, which stays open.

Closing: every decision in this issue is now recorded and implemented. Unified app-owned space object with the #268 outcomes shipped across the epic — Deactivate → Delete with contents to the trash retention window (#286, v0.6.54), name tombstone = trash retention window (spacestore tombstones with expiry), trash surface as first-class admin topic (#278/#279/#280/#281, v0.6.48–v0.6.51), and route A demoted to opt-in (lifecycle routes answer 501 on IdP-managed spaces). Group-key retirement at final purge remains noted in #286's known-edges for the eventual custody follow-up. Dogfood verification of the lifecycle itself continues in #286, which stays open.
Cordy closed this issue 2026-08-20 05:12:40 +00:00
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#268
No description provided.