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
41 changes: 36 additions & 5 deletions crates/escurel-index/src/acl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<'_>,
Expand All @@ -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.
Expand Down
136 changes: 136 additions & 0 deletions crates/escurel-index/tests/write_acl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading