Skip to content

Commit f504fd4

Browse files
authored
Merge pull request #12 from PredicateSystems/parity_close
Parity close
2 parents f67c2a9 + 04e29b7 commit f504fd4

14 files changed

Lines changed: 1151 additions & 66 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ predicate-authority revoke intent --host 127.0.0.1 --port 8787 --hash <intent_ha
205205
predicate-authorityd --host 127.0.0.1 --port 8787 --mode local_only --policy-file examples/authorityd/policy.json
206206
```
207207

208+
Mandate cache behavior:
209+
210+
- default is ephemeral in-memory mandate/revocation cache,
211+
- set `--mandate-store-file <path>` to enable optional local persistence and restart recovery.
212+
208213
### Identity mode options (`predicate-authorityd`)
209214

210215
- `--identity-mode local`: deterministic local bridge (default).

docs/authorityd-operations.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ PYTHONPATH=. predicate-authorityd \
4343
--credential-store-file ./.predicate-authorityd/credentials.json
4444
```
4545

46+
By design, mandate/revocation cache is in-memory (ephemeral) unless you explicitly
47+
enable persistence with `--mandate-store-file`.
48+
49+
### Optional: enable persisted mandate/revocation cache (parity extension)
50+
51+
Use this only when restart-recovery for local revocations/mandate lineage is required.
52+
If omitted, default behavior remains ephemeral.
53+
54+
```bash
55+
PYTHONPATH=. predicate-authorityd \
56+
--host 127.0.0.1 \
57+
--port 8787 \
58+
--mode local_only \
59+
--policy-file examples/authorityd/policy.json \
60+
--mandate-store-file ./.predicate-authorityd/mandates.json
61+
```
62+
4663
### Optional: enable control-plane shipping
4764

4865
To automatically ship proof events and usage records to
@@ -506,6 +523,7 @@ Example response:
506523
{
507524
"mode": "local_only",
508525
"policy_hot_reload_enabled": true,
526+
"mandate_store_persistence_enabled": false,
509527
"revoked_principal_count": 0,
510528
"revoked_intent_count": 0,
511529
"revoked_mandate_count": 0,

predicate_authority/client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,15 @@ def authorize(
113113
reason=AuthorizationReason.INVALID_MANDATE,
114114
violated_rule="revocation_cache",
115115
)
116+
if decision.allowed and decision.mandate is not None:
117+
self._revocation_cache.register_mandate(decision.mandate)
116118
return decision
117119

118120
def verify_token(self, token: str) -> SignedMandate | None:
119121
mandate = self._mandate_signer.verify(token)
120122
if mandate is None:
121123
return None
124+
self._revocation_cache.register_mandate(mandate)
122125
if self._revocation_cache.is_mandate_revoked(mandate):
123126
return None
124127
return mandate
@@ -142,5 +145,5 @@ def verify_delegation_chain(
142145
def revoke_principal(self, principal_id: str) -> None:
143146
self._revocation_cache.revoke_principal(principal_id)
144147

145-
def revoke_mandate(self, mandate_id: str) -> None:
146-
self._revocation_cache.revoke_mandate_id(mandate_id)
148+
def revoke_mandate(self, mandate_id: str, cascade: bool = False) -> int:
149+
return self._revocation_cache.revoke_mandate_id(mandate_id, cascade=cascade)

predicate_authority/control_plane.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import hashlib
4+
import hmac
45
import http.client
56
import json
67
import time
@@ -27,6 +28,7 @@ class ControlPlaneClientConfig:
2728
sync_poll_interval_ms: int = 200
2829
sync_project_id: str | None = None
2930
sync_environment: str | None = None
31+
replay_signing_secret: str | None = None
3032

3133

3234
@dataclass(frozen=True)
@@ -212,10 +214,11 @@ def poll_authority_updates(
212214
return AuthoritySyncSnapshot.from_payload(payload)
213215

214216
def _post_json(self, path: str, payload: Mapping[str, object]) -> bool:
217+
replay_headers = self._build_replay_headers(path)
215218
attempts = self.config.max_retries + 1
216219
for attempt in range(attempts):
217220
try:
218-
self._post_json_once(path, payload)
221+
self._post_json_once(path, payload, replay_headers=replay_headers)
219222
return True
220223
except Exception as exc:
221224
is_last_attempt = attempt == attempts - 1
@@ -240,12 +243,19 @@ def _get_json(self, path: str) -> Mapping[str, object]:
240243
time.sleep(self.config.backoff_initial_s * (2**attempt))
241244
return {}
242245

243-
def _post_json_once(self, path: str, payload: Mapping[str, object]) -> None:
246+
def _post_json_once(
247+
self,
248+
path: str,
249+
payload: Mapping[str, object],
250+
*,
251+
replay_headers: Mapping[str, str],
252+
) -> None:
244253
target_path = path if path.startswith("/") else f"/{path}"
245254
connection = self._new_connection()
246255
headers = {"Content-Type": "application/json"}
247256
if self.config.auth_token:
248257
headers["Authorization"] = f"Bearer {self.config.auth_token}"
258+
headers.update(replay_headers)
249259
body = json.dumps(payload)
250260
try:
251261
connection.request("POST", target_path, body=body, headers=headers)
@@ -280,6 +290,26 @@ def _new_connection(self) -> http.client.HTTPConnection:
280290
return http.client.HTTPSConnection(self._base.netloc, timeout=self.config.timeout_s)
281291
return http.client.HTTPConnection(self._base.netloc, timeout=self.config.timeout_s)
282292

293+
def _build_replay_headers(self, path: str) -> dict[str, str]:
294+
timestamp = str(int(time.time()))
295+
nonce = hashlib.sha256(
296+
f"{self.config.tenant_id}|{path}|{time.time_ns()}".encode()
297+
).hexdigest()[:32]
298+
headers = {
299+
"X-PA-Nonce": nonce,
300+
"X-PA-Timestamp": timestamp,
301+
"X-PA-Idempotency-Token": hashlib.sha256(
302+
f"{nonce}|{timestamp}|{path}".encode()
303+
).hexdigest()[:32],
304+
}
305+
if self.config.replay_signing_secret is not None:
306+
message = f"{nonce}:{timestamp}:POST:{path}".encode()
307+
signature = hmac.new(
308+
self.config.replay_signing_secret.encode("utf-8"), message, hashlib.sha256
309+
).hexdigest()
310+
headers["X-PA-Signature"] = signature
311+
return headers
312+
283313

284314
@dataclass
285315
class ControlPlaneTraceEmitter:

predicate_authority/daemon.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -370,11 +370,23 @@ def _handle_revoke_intent(self) -> None:
370370
def _handle_revoke_mandate(self) -> None:
371371
payload = self._read_json_body()
372372
mandate_id = payload.get("mandate_id")
373+
cascade_raw = payload.get("cascade")
374+
cascade = bool(cascade_raw) if isinstance(cascade_raw, bool) else False
373375
if not isinstance(mandate_id, str) or mandate_id.strip() == "":
374376
self._send_json(400, {"error": "mandate_id is required"})
375377
return
376-
self.server.daemon_ref.revoke_mandate(mandate_id.strip()) # type: ignore[attr-defined]
377-
self._send_json(200, {"ok": True, "mandate_id": mandate_id.strip()})
378+
revoked_count = self.server.daemon_ref.revoke_mandate( # type: ignore[attr-defined]
379+
mandate_id.strip(), cascade=cascade
380+
)
381+
self._send_json(
382+
200,
383+
{
384+
"ok": True,
385+
"mandate_id": mandate_id.strip(),
386+
"cascade": cascade,
387+
"revoked_count": int(revoked_count),
388+
},
389+
)
378390

379391
def _handle_identity_task(self) -> None:
380392
payload = self._read_json_body()
@@ -585,8 +597,8 @@ def revoke_principal(self, principal_id: str) -> None:
585597
def revoke_intent(self, intent_hash: str) -> None:
586598
self._sidecar.revoke_intent_hash(intent_hash)
587599

588-
def revoke_mandate(self, mandate_id: str) -> None:
589-
self._sidecar.revoke_mandate_id(mandate_id)
600+
def revoke_mandate(self, mandate_id: str, cascade: bool = False) -> int:
601+
return self._sidecar.revoke_mandate_id(mandate_id, cascade=cascade)
590602

591603
def max_request_body_bytes(self) -> int:
592604
return max(0, int(self._config.max_request_body_bytes))
@@ -826,15 +838,16 @@ def _apply_sync_snapshot(self, snapshot: AuthoritySyncSnapshot) -> None:
826838
elif item.type == "intent" and item.intent_hash is not None:
827839
self._sidecar.revoke_intent_hash(item.intent_hash)
828840
elif item.type == "tags":
829-
# Tag revocation support is modeled in control-plane API but not yet represented in
830-
# sidecar's revocation cache keys.
831-
continue
841+
tags = {tag.strip().lower() for tag in item.tags if tag.strip() != ""}
842+
if "global_kill_switch" in tags:
843+
self._sidecar.activate_global_kill_switch()
832844

833845

834846
def _build_default_sidecar(
835847
mode: AuthorityMode,
836848
policy_file: str | None,
837849
credential_store_file: str,
850+
mandate_store_file: str | None = None,
838851
control_plane_config: ControlPlaneBootstrapConfig | None = None,
839852
local_identity_config: LocalIdentityBootstrapConfig | None = None,
840853
identity_bridge: ExchangeTokenBridge | None = None,
@@ -912,7 +925,7 @@ def _build_default_sidecar(
912925
proof_ledger=proof_ledger,
913926
identity_bridge=identity_bridge or IdentityBridge(),
914927
credential_store=LocalCredentialStore(credential_store_file),
915-
revocation_cache=LocalRevocationCache(),
928+
revocation_cache=LocalRevocationCache(store_file_path=mandate_store_file),
916929
policy_engine=policy_engine,
917930
local_identity_registry=local_identity_registry,
918931
)
@@ -1070,6 +1083,14 @@ def main() -> None:
10701083
"--credential-store-file",
10711084
default=str(Path.home() / ".predicate-authorityd" / "credentials.json"),
10721085
)
1086+
parser.add_argument(
1087+
"--mandate-store-file",
1088+
default=None,
1089+
help=(
1090+
"Optional path for persisted local revocation/mandate cache. "
1091+
"If omitted, mandate cache remains in-memory (ephemeral default)."
1092+
),
1093+
)
10731094
parser.add_argument(
10741095
"--local-identity-enabled",
10751096
action="store_true",
@@ -1307,6 +1328,7 @@ def main() -> None:
13071328
mode=mode,
13081329
policy_file=args.policy_file,
13091330
credential_store_file=args.credential_store_file,
1331+
mandate_store_file=args.mandate_store_file,
13101332
control_plane_config=control_plane_bootstrap,
13111333
local_identity_config=local_identity_bootstrap,
13121334
identity_bridge=identity_bridge,

0 commit comments

Comments
 (0)