fix(annuaire): grants honorés SEULEMENT si issued_by==self (souveraineté vs grants fédérés d'un pair) + valide scope centers_effective

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-25 11:06:34 +02:00
parent 95f6d91017
commit 8918ef5a03
7 changed files with 239 additions and 36 deletions

View File

@ -135,12 +135,21 @@ def route_config(
content_hash verification, is dropped into "proposals" and never
applied.
self_did is accepted for interface symmetry with the apply_pending/
apply_blob call sites (self-publish loop guard). It is not used to
filter here: grant ownership is exclusive by construction, so a
self-authored CONFIG_PUBLISH is only ever a layer source when this node
itself holds the grant for that (scope, layer) which is already the
correct outcome.
self_did is the SOVEREIGNTY FILTER: only grants this box itself issued
(GRANT_ISSUE.payload["issued_by"] == self_did) are honored as delegated
authority here (grants.active_grants/can_push). Grant ops federate like
any other journal entry a mesh peer can author a well-formed,
correctly-signed GRANT_ISSUE naming its own center as owner of some
(scope, layer), and it syncs into this node's journal via dir_sync.
Without this filter, a peer's self-issued grant would be indistinguishable
from this box's own delegation, letting that peer push firewall/dns/waf/
etc config that gets applied here. A CONFIG_PUBLISH whose only supporting
grant was issued by someone other than self_did is therefore routed to
"proposals" (reason "no-grant"), never applied same fail-closed posture
as an ungranted publisher. self_did may be None (best-effort resolution
failure, e.g. no box key on disk) active_grants()'s own docstring
covers that no-filter fallback; it is only meant for that edge case, not
a way to intentionally skip the filter.
apply (default True, unchanged existing behavior): when False, this is a
read-only dry pass the verified layer texts for every routable scope
@ -157,7 +166,7 @@ def route_config(
Returns:
{"applied": [...], "proposals": [...]}
"""
grant_map = active_grants(entries) # {(scope, layer): grant_payload}
grant_map = active_grants(entries, self_did) # {(scope, layer): grant_payload}, self-issued only
# Newest verified (scope, layer) candidate text, keyed by (scope, layer).
candidates: Dict[Tuple[str, str], Tuple[int, str]] = {}
@ -187,7 +196,7 @@ def route_config(
proposals.append({**proposal, "reason": "malformed-blob"})
continue
if not can_push(entries, publisher, scope, layer):
if not can_push(entries, publisher, scope, layer, self_did):
proposals.append({**proposal, "reason": "no-grant"})
continue

View File

@ -48,14 +48,33 @@ def _payload(entry: Entry) -> Dict[str, Any]:
return payload or {}
def active_grants(entries: List[Mapping[str, Any]]) -> Dict[GrantKey, Dict[str, Any]]:
"""Resolve the currently-active grants from a journal entry list.
def active_grants(
entries: List[Mapping[str, Any]], self_did: Optional[str] = None
) -> Dict[GrantKey, Dict[str, Any]]:
"""Resolve the currently-active, SOVEREIGN grants from a journal entry list.
Walks entries in order, recording every GRANT_ISSUE by grant_id, then
dropping any grant_id that a later GRANT_REVOKE names. Returns
{(scope, layer): grant_payload} for whatever survives last GRANT_ISSUE
wins if two issues ever named the same (scope, layer) (write-path
validation via validate_issue is what actually prevents that).
Sovereignty filter: a GRANT_ISSUE only confers real delegated authority
when it was issued by the box itself (payload["issued_by"] == self_did).
Grant ops federate like any other journal entry (export_entries/
import_entries via dir_sync) a mesh peer can author a well-formed,
correctly-signed GRANT_ISSUE naming ITS OWN center as owner of some
(scope, layer) and it will sync into every node's journal. Without this
filter, config_router/owner/can_push would treat that foreign grant as
local authority and apply the peer's config — a sovereignty break.
When *self_did* is given, only grants with payload["issued_by"] ==
self_did are kept; grants issued by anyone else are silently ignored
(as if absent). When *self_did* is None (default), NO filter is applied
this is the pre-existing, unfiltered behavior, retained ONLY for
low-level unit tests that construct journals without a notion of "self".
Every real call site (config_router.route_config, api/main.py,
sbx-centersctl) MUST pass self_did explicitly.
"""
issued: Dict[str, Dict[str, Any]] = {}
revoked_ids: set = set()
@ -64,6 +83,8 @@ def active_grants(entries: List[Mapping[str, Any]]) -> Dict[GrantKey, Dict[str,
op = _op(entry)
payload = _payload(entry)
if op == "grant_issue":
if self_did is not None and payload.get("issued_by") != self_did:
continue
issued[payload["grant_id"]] = payload
elif op == "grant_revoke":
revoked_ids.add(payload["grant_id"])
@ -76,29 +97,52 @@ def active_grants(entries: List[Mapping[str, Any]]) -> Dict[GrantKey, Dict[str,
return result
def owner(entries: List[Mapping[str, Any]], scope: str, layer: str) -> Optional[str]:
"""Return the center_did that currently owns (scope, layer), or None."""
grant = active_grants(entries).get((scope, layer))
def owner(
entries: List[Mapping[str, Any]], scope: str, layer: str, self_did: Optional[str] = None
) -> Optional[str]:
"""Return the center_did that currently owns (scope, layer), or None.
Only a grant issued by *self_did* counts as ownership see
active_grants()'s sovereignty filter docstring.
"""
grant = active_grants(entries, self_did).get((scope, layer))
return grant["center_did"] if grant else None
def can_push(entries: List[Mapping[str, Any]], center_did: str, scope: str, layer: str) -> bool:
"""True iff *center_did* is the exact, exclusive owner of (scope, layer)."""
return owner(entries, scope, layer) == center_did
def can_push(
entries: List[Mapping[str, Any]],
center_did: str,
scope: str,
layer: str,
self_did: Optional[str] = None,
) -> bool:
"""True iff *center_did* is the exact, exclusive, SELF-GRANTED owner of
(scope, layer) a grant issued by anyone other than *self_did* never
makes this True. See active_grants()'s sovereignty filter docstring.
"""
return owner(entries, scope, layer, self_did) == center_did
def validate_issue(entries: List[Mapping[str, Any]], scope: str, layer: str) -> Optional[str]:
def validate_issue(
entries: List[Mapping[str, Any]], scope: str, layer: str, self_did: Optional[str] = None
) -> Optional[str]:
"""Return a rejection reason for a prospective GRANT_ISSUE, or None if OK.
Order matters (first hit wins):
1. layer == "local" -> "layer-local-not-delegatable"
2. scope in NON_DELEGATABLE -> "scope-not-delegatable"
3. (scope, layer) already owned -> "already-owned"
The "already-owned" check is scoped to *self_did*'s own grants: a
federated grant a mesh peer issued for itself does not block this box
from issuing its own (sovereign) grant for the same (scope, layer) see
active_grants()'s sovereignty filter docstring. Callers issuing a real
grant MUST pass self_did == the issuing box_did.
"""
if layer == "local":
return "layer-local-not-delegatable"
if scope in NON_DELEGATABLE:
return "scope-not-delegatable"
if (scope, layer) in active_grants(entries):
if (scope, layer) in active_grants(entries, self_did):
return "already-owned"
return None

View File

@ -1118,7 +1118,11 @@ def grant_issue(
- scope in NON_DELEGATABLE -> rejected ("scope-not-delegatable")
- (scope, layer) already owned by an active grant -> rejected ("already-owned")
Ownership is exclusive: validate_issue enforces at most one active grant
per (scope, layer) pair.
per (scope, layer) pair. self_did=box_did is passed to validate_issue so
"already-owned" is judged against THIS box's own sovereign grants only —
a federated grant a mesh peer issued for its own center never blocks
(or counts toward) this box's grant state (see grants.py sovereignty
filter).
Journal contract: payload stored WITHOUT sig/signer_did; sig over
canonical_bytes(payload). Same idiom as invite()/propose().
@ -1138,7 +1142,7 @@ def grant_issue(
Raises:
ValueError: if validate_issue rejects the request (see reasons above).
"""
reason = grants.validate_issue(list(journal.iter_entries()), scope, layer)
reason = grants.validate_issue(list(journal.iter_entries()), scope, layer, self_did=box_did)
if reason is not None:
raise ValueError(reason)

View File

@ -894,11 +894,14 @@ def _self_did_best_effort() -> Optional[str]:
async def list_centers():
"""Enrolled centers + their delegated (scope, layer) capabilities (public read).
Derived from annuaire.grants.active_grants(journal) one entry per
center_did that currently holds 1 active grant.
Derived from annuaire.grants.active_grants(journal, self_did) one
entry per center_did that currently holds 1 active grant ISSUED BY THIS
BOX. Grant ops federate over the mesh, so a peer's self-issued grant for
its own center could otherwise appear here as if this box had delegated
it self_did (best-effort) keeps this matrix sovereignty-scoped.
"""
from annuaire.grants import active_grants # noqa: PLC0415
active = active_grants(list(get_journal().iter_entries()))
active = active_grants(list(get_journal().iter_entries()), _self_did_best_effort())
by_center: Dict[str, List[Dict[str, Any]]] = {}
for (scope, layer), grant in active.items():
center_did = grant.get("center_did")
@ -917,9 +920,13 @@ async def list_centers():
@app.get("/centers/ownership")
async def centers_ownership():
"""(scope, layer) -> owning center matrix (public read), via grants.active_grants."""
"""(scope, layer) -> owning center matrix (public read), via grants.active_grants.
Sovereignty-scoped: only shows grants ISSUED BY THIS BOX (self_did,
best-effort) see list_centers() docstring above.
"""
from annuaire.grants import active_grants # noqa: PLC0415
active = active_grants(list(get_journal().iter_entries()))
active = active_grants(list(get_journal().iter_entries()), _self_did_best_effort())
matrix = [
{
"scope": scope,
@ -959,7 +966,17 @@ async def centers_effective(scope: str):
on disk, if any the two vantage points an operator needs to decide
whether a local override still diverges from / is still needed on top of
the granted centers' layers.
*scope* is a public path parameter and must be validated as a safe bare
filename component (same guard as config_apply._valid_scope) BEFORE it is
ever used to build a Path an unvalidated scope containing "/" or ".."
could otherwise be joined into CONFIG_LOCAL_DIR and read an arbitrary
file off disk.
"""
from annuaire.config_apply import _valid_scope # noqa: PLC0415
if not _valid_scope(scope):
return {"scope": scope, "status": "invalid-scope"}
from annuaire.config_router import route_config # noqa: PLC0415
entries = list(get_journal().iter_entries())
result = route_config(

View File

@ -138,13 +138,25 @@ def _box_identity():
# commands
# --------------------------------------------------------------------------- #
def _self_did_or_none():
"""Best-effort box_did — None if the box key is absent (never dies here;
read/preview paths must survive a missing key, see route_config's
self_did docstring)."""
if not os.path.exists(KEY_PATH):
return None
_, box_did = _box_identity()
return box_did
def cmd_grant(args) -> int:
from annuaire.verbs import grant_issue # noqa: PLC0415
if _is_dryrun():
from annuaire import grants # noqa: PLC0415
j = _journal()
reason = grants.validate_issue(list(j.iter_entries()), args.scope, args.layer)
reason = grants.validate_issue(
list(j.iter_entries()), args.scope, args.layer, self_did=_self_did_or_none()
)
return _dryrun_report(
"grant",
center_did=args.center_did, scope=args.scope, layer=args.layer,
@ -183,7 +195,7 @@ def cmd_list(args) -> int:
from annuaire import grants # noqa: PLC0415
j = _journal()
active = grants.active_grants(list(j.iter_entries()))
active = grants.active_grants(list(j.iter_entries()), _self_did_or_none())
matrix = [
{"scope": scope, "layer": layer, **payload}
for (scope, layer), payload in sorted(active.items())
@ -201,21 +213,22 @@ def cmd_route(args) -> int:
from annuaire import grants # noqa: PLC0415
j = _journal()
grant_map = grants.active_grants(list(j.iter_entries()))
grant_map = grants.active_grants(list(j.iter_entries()), _self_did_or_none())
scopes = {scope for (scope, _layer) in grant_map.keys()}
local_root = Path(CONFIG_LOCAL_DIR)
if local_root.is_dir():
scopes.update(f.stem for f in local_root.glob("*.toml"))
return _dryrun_report("route", scopes=sorted(scopes), target_dir=CONFIG_TARGET_DIR)
# self_did is best-effort (interface symmetry with apply_pending's
# self-publish loop guard) — route_config works fine without it, and the
# box key may legitimately be absent on a node that only ever routes
# centers' delegated config (see route_config's docstring in
# annuaire/config_router.py).
self_did = None
if os.path.exists(KEY_PATH):
_, self_did = _box_identity()
# self_did is the sovereignty filter route_config/grants.active_grants
# apply — best-effort (interface symmetry with apply_pending's
# self-publish loop guard): the box key may legitimately be absent on a
# node that only ever routes centers' delegated config (see
# route_config's docstring in annuaire/config_router.py), in which case
# ONLY grants issued by this exact box_did are honored as delegated
# authority; a mesh peer's self-issued grant for its own center is
# never treated as this box's own delegation.
self_did = _self_did_or_none()
j = _journal()
result = route_config(

View File

@ -300,3 +300,77 @@ def test_route_config_hash_mismatch_is_proposed_not_applied(tmp_path):
assert result["proposals"][0]["reason"] == "hash-mismatch"
assert result["proposals"][0]["publisher"] == a_did
assert result["proposals"][0]["scope"] == "waf"
# ---------------------------------------------------------------------------
# Sovereignty guard — a GRANT_ISSUE federated in from a mesh peer (issued_by
# != self_did) must never be honored as this box's own delegated authority
# (CRITICAL finding, souveraineté rompue). This test MUST FAIL before the
# grants.py/config_router.py self_did filter and PASS after.
# ---------------------------------------------------------------------------
def test_route_config_foreign_issued_grant_is_not_honored_sovereignty(tmp_path):
"""A well-formed, plausible-looking GRANT_ISSUE that synced in from the
mesh (dir_sync import_entries) issued_by a PEER box, not this one
must not confer delegated authority here. Without the sovereignty
filter, X's correctly-signed, hash-correct CONFIG_PUBLISH would be
applied to firewall.toml; with it, X holds no grant THIS box ever
issued, so the push is a proposal (reason "no-grant") and nothing is
written to disk.
"""
journal = Journal(str(tmp_path / "log.db"))
x_priv, x_pub, x_did = _actor() # center X — receives the FOREIGN grant
_box_priv, _box_pub, box_did = _actor() # this box — the sovereign local identity
pair_did = "did:plc:" + "f" * 32 # a mesh peer box — NOT this box
genesis(journal, x_priv) # X's pubkey resolvable for CONFIG_PUBLISH sig check
# A GRANT_ISSUE as it would land here via mesh federation: naming X as
# owner of firewall/baseline, but issued_by a PEER box's did, not ours.
# It never goes through annuaire.verbs.grant_issue() (which always sets
# issued_by=box_did of the signer) — it represents an entry synced in
# from elsewhere, exactly like the tampered CONFIG_PUBLISH LogEntry built
# by hand above.
foreign_grant_entry = LogEntry(
height=0,
op=Op.GRANT_ISSUE,
prev_hash=GENESIS_HASH,
payload_type="Grant",
payload={
"grant_id": "g-foreign-1",
"center_did": x_did,
"capability": "config",
"scope": "firewall",
"layer": "baseline",
"issued_by": pair_did,
},
author=pair_did,
sig="deadbeef" * 8,
entry_hash="c" * 64,
)
# X, believing (correctly, on the PEER's own journal) that it holds the
# grant, signs and publishes firewall/baseline exactly as a legitimately
# granted center would — correct signature, correct content_hash.
text = "x = 1\n"
_publish_config_at(journal, x_priv, x_did, scope="firewall", layer="baseline",
version=1, text=text)
entries = list(journal.iter_entries()) + [foreign_grant_entry]
target_dir = tmp_path / "target"
local_dir = tmp_path / "local"
local_dir.mkdir()
result = route_config(entries, str(target_dir), self_did=box_did, local_dir=str(local_dir))
assert result["applied"] == []
assert not (target_dir / "firewall.toml").exists()
assert len(result["proposals"]) == 1
assert result["proposals"][0]["reason"] == "no-grant"
assert result["proposals"][0]["publisher"] == x_did
assert result["proposals"][0]["scope"] == "firewall"
from annuaire import grants # noqa: PLC0415
assert grants.owner(entries, "firewall", "baseline", self_did=box_did) is None

View File

@ -80,3 +80,45 @@ def test_accepts_mixed_dict_and_logentry_entries():
_log_entry(1, "grant_revoke", {"grant_id": "g1", "issued_by": B}),
]
assert grants.owner(e, "firewall", "baseline") is None
# ---------------------------------------------------------------------------
# Sovereignty filter (self_did) — a grant is only real delegated authority
# when THIS box issued it. Grant ops federate (dir_sync), so a mesh peer's
# self-issued grant for its own center must never be honored as local
# authority. See grants.active_grants()'s sovereignty-filter docstring.
# ---------------------------------------------------------------------------
BOX = "did:plc:" + "0" * 32 # this box's own sovereign did
PAIR = "did:plc:" + "9" * 32 # a mesh peer's did — NOT this box
def test_owner_filters_by_self_did_ignores_foreign_grant():
e = [_issue("g1", A, "firewall", "baseline")] # issued_by=B (test fixture's default)
# No self_did (compat, low-level tests): unfiltered, as before.
assert grants.owner(e, "firewall", "baseline") == A
# self_did given but grant issued_by B != self_did -> not honored.
assert grants.owner(e, "firewall", "baseline", self_did=BOX) is None
assert grants.can_push(e, A, "firewall", "baseline", self_did=BOX) is False
# Only honored when self_did matches the grant's issued_by.
assert grants.owner(e, "firewall", "baseline", self_did=B) == A
assert grants.can_push(e, A, "firewall", "baseline", self_did=B) is True
def test_validate_issue_already_owned_is_scoped_to_self_did():
"""A foreign (peer-issued) grant on (scope, layer) must not block this
box's own GRANT_ISSUE for that same (scope, layer) — but this box's OWN
prior grant still does."""
foreign = {"op": "grant_issue", "payload": {
"grant_id": "g1", "center_did": A, "capability": "config",
"scope": "firewall", "layer": "baseline", "issued_by": PAIR,
}}
assert grants.validate_issue([foreign], "firewall", "baseline", self_did=BOX) is None
own = {"op": "grant_issue", "payload": {
"grant_id": "g2", "center_did": A, "capability": "config",
"scope": "firewall", "layer": "baseline", "issued_by": BOX,
}}
assert grants.validate_issue(
[foreign, own], "firewall", "baseline", self_did=BOX
) == "already-owned"