diff --git a/crates/escurel-index/src/acl.rs b/crates/escurel-index/src/acl.rs index 27acc37..99387a9 100644 --- a/crates/escurel-index/src/acl.rs +++ b/crates/escurel-index/src/acl.rs @@ -123,24 +123,25 @@ impl Indexer { Some((_, owner_field)) => owner_field, None => None, }; - // No hijack: the caller must own the EXISTING page (when overwriting). - if let Some(existing) = existing_fm - && self + match existing_fm { + // OVERWRITE/DELETE: the caller must own the EXISTING page (no + // hijack). Owning it, they may rewrite it however they like — + // 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) - { - return Ok(false); - } - // No transfer/create-for-another: the caller must own the INCOMING - // owner too (and an unresolved owner denies). - match self - .resolve_owner_subject(owner_field.as_deref(), incoming_fm) - .await? - { - Some(owner) => Ok(owner == caller.subject), - None => Ok(false), + == Some(caller.subject)), + // 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. + None => Ok(self + .resolve_owner_subject(owner_field.as_deref(), incoming_fm) + .await? + .as_deref() + == Some(caller.subject)), } } diff --git a/crates/escurel-index/tests/write_acl.rs b/crates/escurel-index/tests/write_acl.rs index 33e0a7c..72396d1 100644 --- a/crates/escurel-index/tests/write_acl.rs +++ b/crates/escurel-index/tests/write_acl.rs @@ -185,6 +185,47 @@ async fn admin_writes_any_owner_private_instance() { ); } +#[tokio::test] +async fn owner_may_tombstone_own_instance() { + // Self-deletion (`/delete-my-data`) repoints the owner wikilink at a + // deleted placeholder (unresolvable). The OWNER of the existing page may + // still write it — owning the existing page authorises the write, not the + // incoming owner. (Would be denied if the incoming owner were re-checked.) + let h = fresh_harness(); + seed( + &h, + &[ + SKILL_MEMBER, + SKILL_EVENT_PROFILE, + INST_ALICE, + INST_ALICE_PROFILE, + ], + ) + .await; + let existing = fm(&h, "event_profile", "alice-ki-gipfel").await; + let tombstone = serde_json::json!({ + "type": "instance", + "skill": "event_profile", + "id": "alice-ki-gipfel", + "member": "[[community_member::geloescht]]", + "event": "ki-gipfel", + }); + assert!( + h.indexer + .may_write_instance(&member(ALICE), "event_profile", Some(&existing), &tombstone) + .await + .unwrap(), + "owner may tombstone/release their own record" + ); + assert!( + !h.indexer + .may_write_instance(&member(BOB), "event_profile", Some(&existing), &tombstone) + .await + .unwrap(), + "a non-owner still cannot (existing owner is alice)" + ); +} + #[tokio::test] async fn owner_resolved_through_wikilink_for_write() { let h = fresh_harness(); diff --git a/crates/escurel-test-support/src/lib.rs b/crates/escurel-test-support/src/lib.rs index 236797f..1acf33a 100644 --- a/crates/escurel-test-support/src/lib.rs +++ b/crates/escurel-test-support/src/lib.rs @@ -51,6 +51,9 @@ pub use auth::{AuthMode, Role}; pub use fixtures::{FixtureBuilder, MarkdownBody, TenantFixture}; pub use mcp_client::{McpError, McpTestClient}; pub use process::{ConfigOverrides, EscurelProcess, Opts}; +// Re-export so consumers can set `ConfigOverrides.write_acl` in their own +// integration tests without depending on `escurel-server` directly. +pub use escurel_server::WriteAclMode; // Re-export the request/response types the test author needs so // they can mirror the `Client` surface without a second