mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-30 00:19:30 +00:00
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:
parent
95f6d91017
commit
8918ef5a03
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user