Skip to content

feat(core,server): element-id ACL — grants that follow a tree-move#291

Merged
vieiralucas merged 3 commits into
mainfrom
feat/element-id-acl
Jul 15, 2026
Merged

feat(core,server): element-id ACL — grants that follow a tree-move#291
vieiralucas merged 3 commits into
mainfrom
feat/element-id-acl

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Jul 15, 2026

Copy link
Copy Markdown
Member

Doc-ACL grants keyed by a stable ElementId that resolve to the element's current path at check time, so a grant moves atomically with the element across a tree-move (Kleppmann XmlMove).

Closes the two path-only-ACL holes tree-move opens:

  • exfil-by-move — a restricted element can no longer be freed by dragging it to an unrestricted location; the deny follows it.
  • stranded-restriction — an element's restriction no longer strands on its old slot to govern whatever slides in.

Motivating shapes: a Notion page dragged between parents, a Kanban card across columns, a folder reparent — movable content carrying its own permission.

Core:

  • AclScope { Path(Vec<u8>) | Element(ElementId) } on AclTuple and OpKind::AclGrant.
  • Evaluator (pure + authority tiers) resolves scope→path first, then composes in the existing deny-overrides / inheritance / provenance lattice unchanged; the delegation/attenuation walk resolves the scope before rooting.
  • Element→path resolution is an injected resolver (&dyn Fn(ElementId)->Option<Vec<u8>>) — core hosts no element index. An unresolvable id is inert / fail-closed (roots nothing, governs nothing, denies nothing); a Path scope never consults the resolver.
  • Hand-rolled scope codec (tags 0=Path, 1=Element), total decode; snapshot + zone/read projections resolve scopes.
  • AclCursor::grant_element / grant_scoped.

Server:

  • Injects the room's element-context index (index::element_paths) as the resolver at every doc-ACL seam — write gate, per-op read redaction (fan-out + catch-up), snapshot projection — so element scopes resolve to the element's current path consistently. Snapshot projection derives its resolver from the decoded snapshot it projects, so it cannot drift.

Security properties are test-demonstrated over the redaction seam with real XmlMove (move-safe / no-strand / no-exfil), plus whole-doc reader sees all, op-join↔snapshot-join convergence, and a no-ACL move regression. An unresolvable-element tuple redacts by root read on both catch-up seams so they converge.

Pre-release: back-compat / dual-decode / STATE_VERSION-bump findings are invalid — the format is broken freely, STATE_VERSION 10→11, a stale version rejects hard.


Summary by cubic

Add element-scoped ACL grants keyed by ElementId that resolve to the element’s current path, so permissions move with content across XmlMove. This closes path-only holes (no exfil-by-move, no stranded restrictions) and keeps op and snapshot catch-ups consistent.

  • New Features

    • Introduced AclScope { Path | Element } on AclTuple and OpKind::AclGrant; added AclCursor::grant_element and grant_scoped.
    • Evaluator resolves scope→path via an injected resolver; unresolvable ids are inert (fail-closed). Existing deny-overrides and delegation logic unchanged.
    • Added scope codec (tags 0 path, 1 element); bumped STATE_VERSION to 11. Projections resolve scopes; unresolvable element tuples redact by root to mirror op gating.
    • Server injects the room’s element index as the resolver at write gate, per-op fan-out, and snapshot projection; grant/revoke gating follows the element’s current path.
  • Migration

    • Replace AclTuple.path with AclTuple.scope (AclScope::Path or AclScope::Element); when emitting grants, prefer AclCursor::grant_element for movable content.
    • evaluate, decide_capability, and effective_roles now take a resolver; pass &|_| None if only path scopes are used.
    • Snapshot format is now version 11; older snapshots are rejected.

Written for commit 491f167. Summary will update on new commits.

Review in cubic

AclScope { Path | Element } on AclTuple + OpKind::AclGrant. An Element
scope resolves to the element's current path at evaluation via an injected
id->path resolver, so a grant follows the element across an XmlMove; an
unresolvable id is inert (fail-closed). Path scopes are unchanged.

- evaluator (pure + authority tiers) resolves scope->path first, then
  composes in the existing deny-overrides / inheritance / provenance
  lattice; the provenance walk resolves scope before rooting.
- hand-rolled scope codec (tags 0=Path, 1=Element), total decode.
- STATE_VERSION 10 -> 11; snapshot + zone/read projections resolve scopes.
- AclCursor::grant_element / grant_scoped.
- acl_element.rs: move-safe grant, no stranded restriction, no exfil-by-move,
  unresolvable=inert, element scope in rooting/revocation/bounded-deny, codec.
…xt index

Inject the room's element_paths index as the id->path resolver at every
doc-ACL enforcement seam — the write gate, per-op read redaction (fan-out
+ catch-up), and snapshot projection — so an element-scoped grant resolves
to the element's current path and the verdict follows the element across a
tree-move. Snapshot projection derives the resolver from the decoded doc it
projects, so it cannot drift. Unresolvable id -> inert (fail-closed).

acl_redaction.rs: the three security properties over the redaction seam with
real XmlMove (move-safe / no-strand / no-exfil), a whole-doc reader sees all,
op-join<->snapshot-join convergence for an element grant, no-ACL move regression.
… element scopes

A snapshot projection dropped an element-scoped ACL tuple whose element no
longer resolves, while the op-stream gates the same grant/revoke at root — so
an op-served root reader kept the tuple and a snapshot-served one lost it,
diverging the two catch-up seams. Redact an unresolvable-element tuple by root
read in project_read_paths, mirroring op_read_path's root fallback. Regression
test drives both seams (root reader keeps, non-root reader drops).

Also from the high-effort review:
- element_resolver delegates to scope_path (one id->path encode, not two).
- write gate + subscribe build the element index lazily (skip the tree walk on
  a room with no doc-ACL records).
- document that an element-scoped grant/revoke gates at the element's current
  path, so its audience follows the element across a move.
@vieiralucas
vieiralucas requested a review from Copilot July 15, 2026 15:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vieiralucas
vieiralucas merged commit b231ea1 into main Jul 15, 2026
10 checks passed
@vieiralucas
vieiralucas deleted the feat/element-id-acl branch July 15, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants