Grant-editing core for Zettelkasten revocation (angelo zettelkasten revoke).
This is the edit half of encryption-first Zettelkasten sharing, the sibling of
:mod:memory.sharing.grants_edit. Where :mod:zettelkasten.sharing.config
reads the owner-authored sharing/grants policy, this module rewrites
it to drop a recipient id — either everywhere (a firm-wide eviction) or within a
single project's closure (--project P). Re-publishing then rebuilds the
bundle units/ from scratch (see :func:zettelkasten.sharing.build_bundle),
so the new ciphertext simply excludes the revoked id. No crypto happens here.
Two layers, mirroring the memory sibling:
- :func:
revoke_from_config — a PURE transform: dict -> (dict, change). It
deep-copies its input (never mutates it) and records exactly what it touched in
a :class:RevocationChange so the CLI can print an honest report.
- :func:
revoke_in_store — a thin read/write wrapper that loads
.zettelkasten/config.yaml via :func:zettelkasten.config.read_config,
resolves the firm roster from the shared identity registry, computes a project
closure when scoped, calls the pure transform, and writes the result back via
:func:zettelkasten.config.write_config. It performs NO git operations.
The Zettelkasten grant vocabulary is PROJECT-CENTRIC (see
:mod:zettelkasten.sharing.config): grant/scope keys are a project name, a
source-box name, a per-note <box>/<note_id> (cross/<id> is an alias for
_cross/<id>), or a derived citation:<id> / review:<name> /
org:<id>.
Revocation rules:
- A recipient listed in a
grants[key] recipient list is dropped from it; a
key whose list becomes empty is removed.
- A
firm-scoped key shares with every firm member, so the revoked id cannot
simply be "removed" from firm. Instead the scope is DOWNGRADED to
bilateral with an explicit audience of sorted(firm_members) - {id} (a
firm scope is only touched when the revoked id is actually a firm member — a
non-member is already excluded, so nothing changes).
- A
bilateral scope simply drops the id from its audience; an audience
that becomes empty removes the scope key (equivalent to private).
- With
project set, only keys within that project's CLOSURE are touched
(see :func:revoke_in_store for how the closure is resolved from the store);
without it, every grants list and bilateral audience is edited.
RevocationError
Bases: RuntimeError
A revoke could not be applied safely and must not silently succeed.
Raised (rather than reporting a misleading no-op / success) when the
operation cannot be carried out correctly — e.g. firm-scoped keys are in
scope but the identity registry is empty/unavailable, so the firm audience
cannot be rebuilt and the id would be re-granted on the next publish.
Source code in zettelkasten/sharing/grants_edit.py
| class RevocationError(RuntimeError):
"""A revoke could not be applied safely and must not silently succeed.
Raised (rather than reporting a misleading no-op / success) when the
operation cannot be carried out correctly — e.g. firm-scoped keys are in
scope but the identity registry is empty/unavailable, so the firm audience
cannot be rebuilt and the id would be re-granted on the next publish.
"""
|
RevocationChange
dataclass
An honest record of what a revocation edited (for the CLI report).
grants_removed_from — grant keys the id was dropped from.
firm_converted — scope keys converted firm -> bilateral (the id
was a firm member and some members remain).
audiences_dropped_from — bilateral scope keys the id was dropped
from the audience of.
keys_removed — keys removed because their recipient list / audience
became empty (spans both grants keys and emptied sharing.scopes
keys; a key emptied by a drop appears in both its "…_from" list and here).
id_present — whether the id was found anywhere in the edit scope.
firm_scopes_in_scope — every firm scope key within the edit scope,
regardless of conversion. Used by :func:revoke_in_store to detect an
empty/unavailable registry (which would leave a firm key as firm and
silently re-grant the id on the next publish).
cross_project_shared — citation:/org: keys in the project
closure that were intentionally LEFT untouched because they are also
referenced/owned OUTSIDE the target project; editing them would revoke the
id from another project's grant. The id therefore retains access via these
keys until revoked globally or from the sharing project.
residual — for a scoped (--project) revoke, grant/scope keys
OUTSIDE the revoked project's closure that STILL grant the id (an explicit
grants list, a firm scope the id is a member of, or a bilateral
audience listing it). Because the rebuilt bundle unions ALL of a
recipient's grants, such a key keeps the id an EFFECTIVE bundle recipient
even after the in-project edit — so a scoped revoke that leaves residual
non-empty did NOT fully cut the id off. Distinct from
cross_project_shared (which is the in-closure shared keys deliberately
skipped); the two are additive. Analogous to memory's
residual_inherited.
residual_undetermined — for a scoped revoke, firm-scoped keys
OUTSIDE the closure whose residual status CANNOT be decided because the
identity registry is empty/unavailable (firm membership is unknown). Such a
key would re-grant the id on the next publish if the id IS a firm member,
so a clean cut-off must NOT be claimed while any remain. Kept separate from
residual (which lists keys that DEFINITELY still grant the id) so the
CLI can phrase it as "undetermined (registry unavailable)".
closure_incomplete — for a scoped (--project) revoke, True when
the project closure could not be fully computed because a note was
unparseable or its citations were unreadable. Those notes' keys are omitted
from the edit, so the id may still be granted on them — a clean cut-off must
NOT be claimed while this is set (the id may be UNDER-revoked).
changed — whether any edit was made (id_present with no possible
edit — e.g. a non-member id against a bare firm scope — leaves this
False).
Source code in zettelkasten/sharing/grants_edit.py
| @dataclass
class RevocationChange:
"""An honest record of what a revocation edited (for the CLI report).
* ``grants_removed_from`` — grant keys the id was dropped from.
* ``firm_converted`` — scope keys converted ``firm`` -> ``bilateral`` (the id
was a firm member and some members remain).
* ``audiences_dropped_from`` — ``bilateral`` scope keys the id was dropped
from the ``audience`` of.
* ``keys_removed`` — keys removed because their recipient list / audience
became empty (spans both ``grants`` keys and emptied ``sharing.scopes``
keys; a key emptied by a drop appears in both its "…_from" list and here).
* ``id_present`` — whether the id was found anywhere in the edit scope.
* ``firm_scopes_in_scope`` — every ``firm`` scope key within the edit scope,
regardless of conversion. Used by :func:`revoke_in_store` to detect an
empty/unavailable registry (which would leave a firm key as ``firm`` and
silently re-grant the id on the next publish).
* ``cross_project_shared`` — ``citation:``/``org:`` keys in the project
closure that were intentionally LEFT untouched because they are also
referenced/owned OUTSIDE the target project; editing them would revoke the
id from another project's grant. The id therefore retains access via these
keys until revoked globally or from the sharing project.
* ``residual`` — for a scoped (``--project``) revoke, grant/scope keys
OUTSIDE the revoked project's closure that STILL grant the id (an explicit
``grants`` list, a ``firm`` scope the id is a member of, or a ``bilateral``
audience listing it). Because the rebuilt bundle unions ALL of a
recipient's grants, such a key keeps the id an EFFECTIVE bundle recipient
even after the in-project edit — so a scoped revoke that leaves ``residual``
non-empty did NOT fully cut the id off. Distinct from
``cross_project_shared`` (which is the in-closure shared keys deliberately
skipped); the two are additive. Analogous to memory's
``residual_inherited``.
* ``residual_undetermined`` — for a scoped revoke, ``firm``-scoped keys
OUTSIDE the closure whose residual status CANNOT be decided because the
identity registry is empty/unavailable (firm membership is unknown). Such a
key would re-grant the id on the next publish if the id IS a firm member,
so a clean cut-off must NOT be claimed while any remain. Kept separate from
``residual`` (which lists keys that DEFINITELY still grant the id) so the
CLI can phrase it as "undetermined (registry unavailable)".
* ``closure_incomplete`` — for a scoped (``--project``) revoke, ``True`` when
the project closure could not be fully computed because a note was
unparseable or its citations were unreadable. Those notes' keys are omitted
from the edit, so the id may still be granted on them — a clean cut-off must
NOT be claimed while this is set (the id may be UNDER-revoked).
* ``changed`` — whether any edit was made (``id_present`` with no possible
edit — e.g. a non-member id against a bare ``firm`` scope — leaves this
``False``).
"""
revoked_id: str
project: str | None = None
id_present: bool = False
changed: bool = False
grants_removed_from: tuple[str, ...] = ()
firm_converted: tuple[str, ...] = ()
audiences_dropped_from: tuple[str, ...] = ()
keys_removed: tuple[str, ...] = ()
firm_scopes_in_scope: tuple[str, ...] = ()
cross_project_shared: tuple[str, ...] = ()
residual: tuple[str, ...] = ()
residual_undetermined: tuple[str, ...] = ()
closure_incomplete: bool = False
|
revoke_from_config
revoke_from_config(config: Mapping[str, Any] | None, revoked_id: str, *, firm_members: Iterable[str], project: str | None = None, project_keys: Iterable[str] | None = None) -> tuple[dict[str, Any], RevocationChange]
Drop revoked_id from a loaded config dict; return (new_config, change).
Pure: config is deep-copied and never mutated. firm_members is the
registered firm roster (used to expand a firm scope on downgrade).
When project is None the revoke is GLOBAL — every grants list and
every bilateral audience is edited, and every firm scope the id
belongs to is downgraded. When project is set the revoke is CONFINED to
that project's closure: only keys equal to project or present in
project_keys (both normalized with
:func:~zettelkasten.sharing.config.normalize_note_key) are touched. The
caller (:func:revoke_in_store) resolves project_keys from the store;
when omitted, only the bare project-name key is in scope.
Source code in zettelkasten/sharing/grants_edit.py
| def revoke_from_config(
config: Mapping[str, Any] | None,
revoked_id: str,
*,
firm_members: Iterable[str],
project: str | None = None,
project_keys: Iterable[str] | None = None,
) -> tuple[dict[str, Any], RevocationChange]:
"""Drop ``revoked_id`` from a loaded config dict; return ``(new_config, change)``.
Pure: ``config`` is deep-copied and never mutated. ``firm_members`` is the
registered firm roster (used to expand a ``firm`` scope on downgrade).
When ``project`` is ``None`` the revoke is GLOBAL — every ``grants`` list and
every ``bilateral`` audience is edited, and every ``firm`` scope the id
belongs to is downgraded. When ``project`` is set the revoke is CONFINED to
that project's closure: only keys equal to ``project`` or present in
``project_keys`` (both normalized with
:func:`~zettelkasten.sharing.config.normalize_note_key`) are touched. The
caller (:func:`revoke_in_store`) resolves ``project_keys`` from the store;
when omitted, only the bare project-name key is in scope.
"""
revoked_id = str(revoked_id).strip()
firm = sorted({str(m).strip() for m in firm_members if str(m).strip()})
new_config: dict[str, Any] = copy.deepcopy(dict(config)) if isinstance(config, Mapping) else {}
scoped = project is not None
project_name = str(project).strip() if scoped else None
change = RevocationChange(revoked_id=revoked_id, project=project_name)
if not revoked_id:
return new_config, change
closure: set[str] = set()
if scoped:
if project_name:
closure.add(project_name)
for k in (project_keys or ()):
ks = str(k).strip()
if ks:
closure.add(normalize_note_key(ks))
def in_scope(key: str) -> bool:
if not scoped:
return True
return normalize_note_key(key) in closure
id_present = False
grants_removed_from: list[str] = []
firm_converted: list[str] = []
audiences_dropped_from: list[str] = []
keys_removed: list[str] = []
firm_scopes_in_scope: list[str] = []
# ---- grants: drop the id from each in-scope recipient list ----
grants = new_config.get("grants")
if isinstance(grants, dict):
for key in list(grants.keys()):
key_s = str(key)
if not in_scope(key_s):
continue
ids = _as_id_list(grants[key])
if revoked_id not in ids:
continue
id_present = True
grants_removed_from.append(key_s)
remaining = [i for i in ids if i != revoked_id]
if remaining:
grants[key] = remaining
else:
del grants[key]
keys_removed.append(key_s)
# ---- sharing.scopes: downgrade firm, drop from bilateral audiences ----
sharing_block = new_config.get("sharing")
if isinstance(sharing_block, dict):
scopes = sharing_block.get("scopes")
if isinstance(scopes, dict):
for key in list(scopes.keys()):
key_s = str(key)
if not in_scope(key_s):
continue
kind, audience, extra = _scope_parts(scopes[key])
if kind == SCOPE_FIRM:
# Record every in-scope firm key so the store wrapper can
# detect an empty/unavailable registry (which would leave it
# as firm and silently re-grant the id on the next publish).
firm_scopes_in_scope.append(key_s)
# A firm scope only touches the id if the id is a firm member;
# a non-member is already outside a firm scope.
if revoked_id not in firm:
continue
id_present = True
remaining = [m for m in firm if m != revoked_id]
if remaining:
new_val: dict[str, Any] = {"scope": SCOPE_BILATERAL, "audience": remaining}
new_val.update(extra)
scopes[key] = new_val
firm_converted.append(key_s)
else:
del scopes[key]
keys_removed.append(key_s)
elif kind == SCOPE_BILATERAL:
if revoked_id not in audience:
continue
id_present = True
audiences_dropped_from.append(key_s)
remaining = [a for a in audience if a != revoked_id]
if remaining:
new_val = {"scope": SCOPE_BILATERAL, "audience": remaining}
new_val.update(extra)
scopes[key] = new_val
else:
del scopes[key]
keys_removed.append(key_s)
change.id_present = id_present
change.grants_removed_from = tuple(grants_removed_from)
change.firm_converted = tuple(firm_converted)
change.audiences_dropped_from = tuple(audiences_dropped_from)
change.keys_removed = tuple(keys_removed)
change.firm_scopes_in_scope = tuple(firm_scopes_in_scope)
change.changed = bool(
grants_removed_from or firm_converted or audiences_dropped_from or keys_removed
)
return new_config, change
|
revoke_in_store
revoke_in_store(revoked_id: str, *, project: str | None = None, registry: Any = None, store_root: Path | str | None = None) -> RevocationChange
Revoke revoked_id from the local .zettelkasten/config.yaml in place.
Reads the config via :func:zettelkasten.config.read_config, resolves the
firm roster from registry (the shared identity registry — loaded via
memory.sharing.load_registry() when omitted), computes the project
closure when project is given, applies :func:revoke_from_config, and
writes the result back via :func:zettelkasten.config.write_config (only
when something actually changed). Performs NO git operations.
store_root overrides the store location (the directory holding
config.yaml — i.e. the .zettelkasten dir); when omitted the default
:data:zettelkasten.graph.GRAPHS_DIR is used. Returns the
:class:RevocationChange for the caller to report.
Source code in zettelkasten/sharing/grants_edit.py
| def revoke_in_store(
revoked_id: str,
*,
project: str | None = None,
registry: Any = None,
store_root: Path | str | None = None,
) -> RevocationChange:
"""Revoke ``revoked_id`` from the local ``.zettelkasten/config.yaml`` in place.
Reads the config via :func:`zettelkasten.config.read_config`, resolves the
firm roster from ``registry`` (the shared identity registry — loaded via
``memory.sharing.load_registry()`` when omitted), computes the project
closure when ``project`` is given, applies :func:`revoke_from_config`, and
writes the result back via :func:`zettelkasten.config.write_config` (only
when something actually changed). Performs NO git operations.
``store_root`` overrides the store location (the directory holding
``config.yaml`` — i.e. the ``.zettelkasten`` dir); when omitted the default
:data:`zettelkasten.graph.GRAPHS_DIR` is used. Returns the
:class:`RevocationChange` for the caller to report.
"""
from zettelkasten import config as zk_config
from zettelkasten import graph as zk_graph
if registry is None:
from memory import sharing as mem_sharing
registry = mem_sharing.load_registry()
firm_members = registry.member_ids()
project_name = str(project).strip() if project is not None else None
previous_dir = zk_graph.GRAPHS_DIR
if store_root is not None:
zk_graph.GRAPHS_DIR = Path(store_root)
try:
current = zk_graph.GRAPHS_DIR
cfg = zk_config.read_config()
project_keys = None
full_closure: set[str] = set()
cross_shared: set[str] = set()
closure_incomplete = False
if project_name:
full_closure, closure_incomplete = _project_closure_keys(current, project_name)
# Do NOT edit citation:/org:/box/review keys shared with other
# projects — dropping the id there would revoke another project's
# grant too.
cross_shared = _cross_project_shared_keys(current, project_name)
project_keys = {k for k in full_closure if k not in cross_shared}
new_cfg, change = revoke_from_config(
cfg,
revoked_id,
firm_members=firm_members,
project=project_name,
project_keys=project_keys,
)
# Report the shared keys the id is STILL granted on (skipped above).
# Residual access can be preserved TWO ways on a shared key we left
# untouched: an explicit ``grants[key]`` recipient list, OR a
# ``sharing.scopes[key]`` audience (a ``firm`` scope the id is a member
# of, or a ``bilateral`` scope listing the id). Inspecting only ``grants``
# would miss the scopes case and let the CLI falsely claim a clean
# cut-off while the id still decrypts. Check both.
if cross_shared:
rid = str(revoked_id).strip()
firm_set = {str(m).strip() for m in firm_members if str(m).strip()}
raw_grants = cfg.get("grants") if isinstance(cfg, Mapping) else None
raw_sharing = cfg.get("sharing") if isinstance(cfg, Mapping) else None
raw_scopes = (
raw_sharing.get("scopes") if isinstance(raw_sharing, Mapping) else None
)
still: list[str] = []
for k in cross_shared:
retains = isinstance(raw_grants, Mapping) and rid in _as_id_list(
raw_grants.get(k)
)
if not retains and isinstance(raw_scopes, Mapping) and k in raw_scopes:
kind, audience, _extra = _scope_parts(raw_scopes.get(k))
if kind == SCOPE_FIRM:
retains = rid in firm_set
elif kind == SCOPE_BILATERAL:
retains = rid in audience
if retains:
still.append(k)
change.cross_project_shared = tuple(sorted(still))
# A scoped revoke only edits the target project's closure, so the id can
# remain an EFFECTIVE bundle recipient via grants/scopes OUTSIDE that
# closure (e.g. still granted on another project's keys). Detect that on
# the POST-edit config so the CLI does not falsely claim a clean cut-off.
# Exclude the in-closure shared keys already reported via
# ``cross_project_shared`` (they live inside the full closure anyway).
if project_name:
residual, residual_undetermined = _residual_keys_outside_closure(
new_cfg if change.changed else cfg,
revoked_id,
full_closure,
firm_members,
)
change.residual = tuple(residual)
change.residual_undetermined = tuple(residual_undetermined)
# An incomplete closure (unparseable note / unreadable citations)
# omitted keys from the edit, so the id may still be granted there —
# surface it so the CLI does not claim a clean cut-off.
change.closure_incomplete = closure_incomplete
# An empty/unavailable registry cannot rebuild a firm audience, so a firm
# key would be left as ``firm`` and silently re-grant the id on the next
# publish. Refuse loudly rather than reporting a misleading success/no-op.
if not firm_members and change.firm_scopes_in_scope:
raise RevocationError(
"cannot apply firm revocation: "
f"{len(change.firm_scopes_in_scope)} firm-scoped key(s) "
f"({', '.join(change.firm_scopes_in_scope)}) are in scope but the "
"identity registry is empty or unavailable, so the firm audience "
"cannot be rebuilt without the revoked id. Fix the registry "
"(.memory/identities.yaml) and retry — no config change was made."
)
if change.changed:
zk_config.write_config(new_cfg)
finally:
zk_graph.GRAPHS_DIR = previous_dir
return change
|