From ed5e40f27a2547addc4cc7e89ad0ab70d0fe68c2 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Mon, 15 Jun 2026 14:56:56 +0200 Subject: [PATCH] feat(acl): owner may reclaim an orphaned/erased instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `may_write_instance` under `ESCUREL_WRITE_ACL=enforce` only let a caller overwrite an existing page if they resolved as its owner. When an instance's owner resolved to `None` — a `/delete-my-data` tombstone whose owner field was blanked, or an owner wikilink repointed at a deleted placeholder — the page became ownerless, so only admin could overwrite it. That permanently locked the rightful owner out of RE-CREATING their own instance (a member who erased their data could never re-onboard). Add a reclaim path on the OVERWRITE case, ONLY for owner-scoped skills (`owner_field.is_some()`): if the existing owner resolves to `None` (orphaned) AND the incoming content's owner resolves to the caller, allow the write. A live instance owned by someone else stays protected (existing owner `Some(other)` still denies), and public / no-`owner_field` instances stay admin-write-only (the `owner_field` guard keeps existing-None + incoming-None denied). CREATE path and admin bypass unchanged; reads and chat ACLs untouched. Tests (no mocks — real DuckDB + FsStore): owner reclaims their own orphaned wikilink instance and their own field-dropped direct-owner instance; a different non-admin still cannot overwrite a live instance owned by another; a public instance stays admin-write-only. Co-Authored-By: Claude Fable 5 --- crates/escurel-index/src/acl.rs | 41 ++++++- crates/escurel-index/tests/write_acl.rs | 136 ++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 5 deletions(-) diff --git a/crates/escurel-index/src/acl.rs b/crates/escurel-index/src/acl.rs index 3ff0d02..57d5cca 100644 --- a/crates/escurel-index/src/acl.rs +++ b/crates/escurel-index/src/acl.rs @@ -112,6 +112,13 @@ impl Indexer { /// and the incoming frontmatter. Admin always passes; otherwise the /// caller must be the resolved owner of the existing page (if any) AND /// of the incoming content; an unresolved owner fails closed. + /// + /// One refinement on the overwrite path: an owner-scoped instance + /// (`owner_field.is_some()`) whose existing owner is now unresolvable + /// (`None` — an orphaned/erased tombstone) may be RECLAIMED by a caller + /// who owns the incoming content, so the rightful owner can re-create + /// their own erased instance. Public / no-`owner_field` instances are + /// never reclaimable and stay admin-write-only. pub async fn may_write_instance( &self, caller: &AclCaller<'_>, @@ -135,11 +142,35 @@ impl Indexer { // including releasing or tombstoning it (e.g. `/delete-my-data` // repoints the owner wikilink at a deleted placeholder), so the // incoming owner is NOT re-checked here. - Some(existing) => Ok(self - .resolve_owner_subject(owner_field.as_deref(), existing) - .await? - .as_deref() - == Some(caller.subject)), + Some(existing) => { + let existing_owner = self + .resolve_owner_subject(owner_field.as_deref(), existing) + .await?; + if existing_owner.as_deref() == Some(caller.subject) { + return Ok(true); + } + // RECLAIM: an owner-scoped instance whose owner is now + // unresolvable (`None`) is ORPHANED — e.g. a + // `/delete-my-data` tombstone whose owner field was blanked + // or repointed at a deleted placeholder. Without this, only + // admin could overwrite it, permanently locking the rightful + // owner out of RE-CREATING their own erased instance (a member + // who erased their data could never re-onboard). Let a caller + // reclaim an orphaned instance by writing fresh content owned + // by THEMSELVES. Guarded by `owner_field.is_some()` so public + // / no-`owner_field` skills (existing owner also `None`) stay + // admin-write-only — they are never reclaimable. A LIVE + // instance owned by someone else (existing owner `Some(other)`) + // is unaffected and stays protected. + if owner_field.is_some() && existing_owner.is_none() { + return Ok(self + .resolve_owner_subject(owner_field.as_deref(), incoming_fm) + .await? + .as_deref() + == Some(caller.subject)); + } + Ok(false) + } // CREATE: the caller must own the INCOMING content (no // create-for-/transfer-to another subject). An unresolved owner // (public / no `owner_field`) denies → admin-create only. diff --git a/crates/escurel-index/tests/write_acl.rs b/crates/escurel-index/tests/write_acl.rs index 72396d1..f63caa3 100644 --- a/crates/escurel-index/tests/write_acl.rs +++ b/crates/escurel-index/tests/write_acl.rs @@ -226,6 +226,142 @@ async fn owner_may_tombstone_own_instance() { ); } +#[tokio::test] +async fn owner_reclaims_own_orphaned_instance() { + // The full lifecycle of `/delete-my-data` then re-onboard: + // 1. alice owns her event_profile (resolves to alice via wikilink); + // 2. she tombstones it — the owner wikilink now points at a deleted + // placeholder, so the existing owner resolves to None (orphaned); + // 3. alice re-creates it, writing fresh content owned by HERSELF. + // Step 3 must be ALLOWED: an owner-scoped instance whose existing owner + // is unresolvable may be reclaimed by the incoming owner. (Before this + // refinement, an orphaned instance was admin-write-only and alice could + // never re-onboard.) + let h = fresh_harness(); + seed( + &h, + &[ + SKILL_MEMBER, + SKILL_EVENT_PROFILE, + INST_ALICE, + INST_ALICE_PROFILE, + ], + ) + .await; + + // The orphaned/erased existing page: owner wikilink → deleted placeholder. + let orphaned = serde_json::json!({ + "type": "instance", + "skill": "event_profile", + "id": "alice-ki-gipfel", + "member": "[[community_member::geloescht]]", + "event": "ki-gipfel", + }); + // Sanity: the existing owner truly resolves to None. + assert!( + !h.indexer + .may_write_instance(&member(BOB), "event_profile", Some(&orphaned), &orphaned) + .await + .unwrap(), + "an orphaned instance is not writable by an arbitrary subject" + ); + + // alice re-creates it, pointing the owner back at her own member record. + let reclaimed = fm(&h, "event_profile", "alice-ki-gipfel").await; // owner → alice + assert!( + h.indexer + .may_write_instance(&member(ALICE), "event_profile", Some(&orphaned), &reclaimed) + .await + .unwrap(), + "alice may RECLAIM her own orphaned/erased instance by re-creating it" + ); + // A different non-admin still cannot reclaim it for themselves. + assert!( + !h.indexer + .may_write_instance(&member(BOB), "event_profile", Some(&orphaned), &reclaimed) + .await + .unwrap(), + "bob cannot reclaim alice's orphaned instance as alice" + ); +} + +#[tokio::test] +async fn owner_reclaims_own_blanked_direct_owner_instance() { + // Same reclaim, but for a direct-`owner_field` skill (community_member, + // owner_field = credential): the tombstone drops the credential field, so + // the existing owner is unresolvable (None). alice re-creates with her + // credential. + let h = fresh_harness(); + seed(&h, &[SKILL_MEMBER, INST_ALICE]).await; + + let blanked = serde_json::json!({ + "type": "instance", + "skill": "community_member", + "id": "alice", + }); + let reclaimed = fm(&h, "community_member", "alice").await; // credential → whatsapp:111 + assert!( + h.indexer + .may_write_instance( + &member(ALICE), + "community_member", + Some(&blanked), + &reclaimed + ) + .await + .unwrap(), + "alice may reclaim her own blanked/erased member record" + ); + assert!( + !h.indexer + .may_write_instance(&member(BOB), "community_member", Some(&blanked), &reclaimed) + .await + .unwrap(), + "bob may not reclaim alice's erased record as alice" + ); +} + +#[tokio::test] +async fn live_instance_owned_by_other_stays_protected() { + // The reclaim path must NOT weaken protection of a LIVE instance: its + // existing owner resolves to Some(alice), so a different subject is + // denied regardless of who the incoming content names. + let h = fresh_harness(); + seed(&h, &[SKILL_MEMBER, INST_ALICE]).await; + let alice = fm(&h, "community_member", "alice").await; + // bob attempts to overwrite, even claiming it for himself. + let bobs = serde_json::json!({ + "type": "instance", + "skill": "community_member", + "id": "alice", + "credential": BOB, + }); + assert!( + !h.indexer + .may_write_instance(&member(BOB), "community_member", Some(&alice), &bobs) + .await + .unwrap(), + "a LIVE instance owned by alice stays protected from bob" + ); +} + +#[tokio::test] +async fn public_orphaned_instance_stays_admin_write_only() { + // The reclaim guard is `owner_field.is_some()`: a public / no-`owner_field` + // skill has existing owner None AND incoming owner None, which must NOT be + // mistaken for a reclaimable orphan. It stays admin-write-only. + let h = fresh_harness(); + seed(&h, &[SKILL_TALK, INST_TALK]).await; + let talk = fm(&h, "talk", "keynote").await; + assert!( + !h.indexer + .may_write_instance(&member(BOB), "talk", Some(&talk), &talk) + .await + .unwrap(), + "a public instance (no owner_field) is never reclaimable — admin only" + ); +} + #[tokio::test] async fn owner_resolved_through_wikilink_for_write() { let h = fresh_harness();