Skip to content

Commit 17e8c75

Browse files
authored
Merge pull request #6 from PredicateSystems/tests
authority client with delegation tests
2 parents 355118e + 70992d5 commit 17e8c75

31 files changed

Lines changed: 1330 additions & 86 deletions

.github/workflows/phase1-ci-and-release.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ jobs:
2626
python-version: "3.11"
2727

2828
- name: Install dependencies
29-
run: python -m pip install --upgrade pip pre-commit pytest
29+
run: |
30+
python -m pip install --upgrade pip pre-commit pytest
31+
python -m pip install -e predicate_contracts -e predicate_authority
3032
3133
- name: Verify package release order
3234
run: python scripts/verify_release_order.py

.github/workflows/tests.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ jobs:
2424
python-version: ${{ matrix.python-version }}
2525

2626
- name: Install test dependencies
27-
run: python -m pip install --upgrade pip pytest
27+
run: |
28+
python -m pip install --upgrade pip pytest
29+
python -m pip install -e predicate_contracts -e predicate_authority
2830
2931
- name: Run tests
3032
run: python -m pytest -q

docs/authorityd-operations.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ PYTHONPATH=. predicate-authorityd \
6363
--control-plane-fail-open
6464
```
6565

66+
### Signing key safety note (required until mandate `v2` claims)
67+
68+
Until mandate `v2` introduces explicit `iss`/`aud` claims and asymmetric signing defaults,
69+
each deployment instance must use a unique signing key to reduce cross-instance replay risk.
70+
71+
Recommended startup pattern:
72+
73+
```bash
74+
export PREDICATE_AUTHORITY_SIGNING_KEY="<unique-random-per-instance>"
75+
76+
PYTHONPATH=. predicate-authorityd \
77+
--host 127.0.0.1 \
78+
--port 8787 \
79+
--mode local_only \
80+
--policy-file examples/authorityd/policy.json \
81+
--mandate-signing-key-env PREDICATE_AUTHORITY_SIGNING_KEY
82+
```
83+
6684
When enabled, daemon bootstrap auto-attaches `ControlPlaneTraceEmitter` so each
6785
authority decision pushes:
6886

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
rules:
2+
- name: allow-orders-create
3+
effect: allow
4+
principals:
5+
- agent:checkout
6+
actions:
7+
- http.post
8+
resources:
9+
- https://api.vendor.com/orders
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import sys
6+
from pathlib import Path
7+
8+
9+
def _ensure_repo_root_on_syspath() -> None:
10+
repo_root = Path(__file__).resolve().parents[1]
11+
root = str(repo_root)
12+
if root not in sys.path:
13+
sys.path.insert(0, root)
14+
15+
16+
def _build_request() -> object:
17+
_ensure_repo_root_on_syspath()
18+
from predicate_contracts import ( # pylint: disable=import-error
19+
ActionRequest,
20+
ActionSpec,
21+
PrincipalRef,
22+
StateEvidence,
23+
VerificationEvidence,
24+
)
25+
26+
return ActionRequest(
27+
principal=PrincipalRef(principal_id="agent:checkout"),
28+
action_spec=ActionSpec(
29+
action="http.post",
30+
resource="https://api.vendor.com/orders",
31+
intent="submit customer order",
32+
),
33+
state_evidence=StateEvidence(source="sdk-python", state_hash="sha256:example"),
34+
verification_evidence=VerificationEvidence(),
35+
)
36+
37+
38+
def run(policy_file: str, secret_key: str) -> dict[str, object]:
39+
_ensure_repo_root_on_syspath()
40+
from predicate_authority import AuthorityClient # pylint: disable=import-error
41+
42+
context = AuthorityClient.from_policy_file(
43+
policy_file=policy_file,
44+
secret_key=secret_key,
45+
ttl_seconds=120,
46+
)
47+
client = context.client
48+
decision = client.authorize(_build_request())
49+
token_verified = False
50+
if decision.mandate is not None:
51+
token_verified = client.verify_token(decision.mandate.token) is not None
52+
return {
53+
"policy_file": policy_file,
54+
"allowed": decision.allowed,
55+
"reason": decision.reason.value,
56+
"token_issued": decision.mandate is not None,
57+
"token_verified": token_verified,
58+
}
59+
60+
61+
def main() -> None:
62+
parser = argparse.ArgumentParser(description="Local AuthorityClient example using YAML policy.")
63+
parser.add_argument(
64+
"--policy-file",
65+
default="examples/authority_client_local_policy.yaml",
66+
help="Path to local YAML policy file.",
67+
)
68+
parser.add_argument(
69+
"--secret-key",
70+
default="dev-secret",
71+
help="Signing key used for local mandates.",
72+
)
73+
args = parser.parse_args()
74+
payload = run(policy_file=args.policy_file, secret_key=args.secret_key)
75+
print(json.dumps(payload, indent=2, sort_keys=True))
76+
77+
78+
if __name__ == "__main__":
79+
main()

examples/delegation/delegate.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import importlib.util
5+
import json
6+
import sys
7+
from collections.abc import Callable
8+
from pathlib import Path
9+
from typing import Any, cast
10+
11+
12+
def _ensure_repo_root_on_syspath() -> None:
13+
repo_root = Path(__file__).resolve().parents[2]
14+
root = str(repo_root)
15+
if root not in sys.path:
16+
sys.path.insert(0, root)
17+
18+
19+
def _build_request() -> object:
20+
_ensure_repo_root_on_syspath()
21+
from predicate_contracts import ( # pylint: disable=import-error
22+
ActionRequest,
23+
ActionSpec,
24+
PrincipalRef,
25+
StateEvidence,
26+
VerificationEvidence,
27+
)
28+
29+
return ActionRequest(
30+
principal=PrincipalRef(principal_id="agent:root"),
31+
action_spec=ActionSpec(
32+
action="task.delegate",
33+
resource="worker:queue/main",
34+
intent="delegate processing to worker agent",
35+
),
36+
state_evidence=StateEvidence(source="delegate.py", state_hash="sha256:delegate"),
37+
verification_evidence=VerificationEvidence(),
38+
)
39+
40+
41+
def _run_worker(
42+
worker_script: str,
43+
token: str,
44+
secret_key: str,
45+
revocation_file: str,
46+
policy_file: str,
47+
) -> dict[str, object]:
48+
worker_run = _load_worker_runner(worker_script)
49+
payload = worker_run(
50+
token=token,
51+
secret_key=secret_key,
52+
revocation_file=revocation_file,
53+
policy_file=policy_file,
54+
)
55+
if not isinstance(payload, dict):
56+
raise RuntimeError("worker payload must be an object")
57+
return cast(dict[str, object], payload)
58+
59+
60+
def _load_worker_runner(worker_script: str) -> Callable[..., Any]:
61+
worker_path = Path(worker_script).resolve()
62+
spec = importlib.util.spec_from_file_location("delegation_worker_runtime", worker_path)
63+
if spec is None or spec.loader is None:
64+
raise RuntimeError(f"Unable to load worker module from path: {worker_script}")
65+
module = importlib.util.module_from_spec(spec)
66+
spec.loader.exec_module(module)
67+
run_callable = getattr(module, "run", None)
68+
if not callable(run_callable):
69+
raise RuntimeError("Worker module must expose callable run(...) function.")
70+
return cast(Callable[..., Any], run_callable)
71+
72+
73+
def run(
74+
policy_file: str,
75+
worker_script: str,
76+
revocation_file: str,
77+
secret_key: str,
78+
) -> dict[str, object]:
79+
_ensure_repo_root_on_syspath()
80+
from predicate_authority import AuthorityClient # pylint: disable=import-error
81+
82+
context = AuthorityClient.from_policy_file(
83+
policy_file=policy_file,
84+
secret_key=secret_key,
85+
ttl_seconds=120,
86+
)
87+
client = context.client
88+
89+
decision = client.authorize(_build_request())
90+
if not decision.allowed or decision.mandate is None:
91+
return {
92+
"root_allowed": False,
93+
"worker_allowed_before_revoke": False,
94+
"worker_allowed_after_revoke": False,
95+
}
96+
97+
token = decision.mandate.token
98+
Path(revocation_file).write_text(
99+
json.dumps({"revoked_principal_ids": []}, indent=2),
100+
encoding="utf-8",
101+
)
102+
before = _run_worker(worker_script, token, secret_key, revocation_file, policy_file)
103+
104+
client.revoke_principal("agent:root")
105+
Path(revocation_file).write_text(
106+
json.dumps({"revoked_principal_ids": ["agent:root"]}, indent=2),
107+
encoding="utf-8",
108+
)
109+
after = _run_worker(worker_script, token, secret_key, revocation_file, policy_file)
110+
111+
return {
112+
"root_allowed": True,
113+
"root_delegation_depth": decision.mandate.claims.delegation_depth,
114+
"root_chain_hash": decision.mandate.claims.delegation_chain_hash,
115+
"worker_allowed_before_revoke": bool(before.get("allowed", False)),
116+
"worker_allowed_after_revoke": bool(after.get("allowed", False)),
117+
"worker_delegation_depth_before_revoke": before.get("delegation_depth"),
118+
"worker_chain_verified_before_revoke": bool(before.get("chain_verified", False)),
119+
"before_reason": before.get("reason"),
120+
"after_reason": after.get("reason"),
121+
}
122+
123+
124+
def main() -> None:
125+
parser = argparse.ArgumentParser(
126+
description="Delegation simulation for local authority runtime."
127+
)
128+
parser.add_argument(
129+
"--policy-file",
130+
default="examples/delegation/policy.yaml",
131+
help="Path to policy file for the root delegating agent.",
132+
)
133+
parser.add_argument(
134+
"--worker-script",
135+
default="examples/delegation/worker.py",
136+
help="Path to worker.py.",
137+
)
138+
parser.add_argument(
139+
"--revocation-file",
140+
default="examples/delegation/revocations.json",
141+
help="Path to revocation state shared with worker.",
142+
)
143+
parser.add_argument("--secret-key", default="dev-secret")
144+
args = parser.parse_args()
145+
payload = run(
146+
policy_file=args.policy_file,
147+
worker_script=args.worker_script,
148+
revocation_file=args.revocation_file,
149+
secret_key=args.secret_key,
150+
)
151+
print(json.dumps(payload, indent=2, sort_keys=True))
152+
153+
154+
if __name__ == "__main__":
155+
main()

examples/delegation/policy.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
rules:
2+
- name: allow-delegate-task
3+
effect: allow
4+
principals:
5+
- agent:root
6+
actions:
7+
- task.delegate
8+
resources:
9+
- worker:queue/*
10+
max_delegation_depth: 1
11+
- name: allow-worker-execute
12+
effect: allow
13+
principals:
14+
- agent:worker
15+
actions:
16+
- job.execute
17+
resources:
18+
- queue://jobs/*
19+
max_delegation_depth: 1

0 commit comments

Comments
 (0)