Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions crates/escurel-index/src/acl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}
}

Expand Down
41 changes: 41 additions & 0 deletions crates/escurel-index/tests/write_acl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions crates/escurel-test-support/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading