Skip to content

Commit 95f0c31

Browse files
committed
authority client with delegation tests
1 parent 4bec022 commit 95f0c31

18 files changed

Lines changed: 987 additions & 16 deletions
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

examples/delegation/worker.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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[2]
11+
root = str(repo_root)
12+
if root not in sys.path:
13+
sys.path.insert(0, root)
14+
15+
16+
def _load_revocations(path: str) -> list[str]:
17+
file_path = Path(path)
18+
if not file_path.exists():
19+
return []
20+
payload = json.loads(file_path.read_text(encoding="utf-8"))
21+
if not isinstance(payload, dict):
22+
return []
23+
revoked = payload.get("revoked_principal_ids", [])
24+
if not isinstance(revoked, list):
25+
return []
26+
return [str(item) for item in revoked]
27+
28+
29+
def _build_worker_request() -> object:
30+
_ensure_repo_root_on_syspath()
31+
from predicate_contracts import ( # pylint: disable=import-error
32+
ActionRequest,
33+
ActionSpec,
34+
PrincipalRef,
35+
StateEvidence,
36+
VerificationEvidence,
37+
)
38+
39+
return ActionRequest(
40+
principal=PrincipalRef(principal_id="agent:worker"),
41+
action_spec=ActionSpec(
42+
action="job.execute",
43+
resource="queue://jobs/high-priority",
44+
intent="execute delegated job",
45+
),
46+
state_evidence=StateEvidence(source="worker.py", state_hash="sha256:worker"),
47+
verification_evidence=VerificationEvidence(),
48+
)
49+
50+
51+
def run(
52+
token: str,
53+
secret_key: str,
54+
revocation_file: str,
55+
policy_file: str,
56+
) -> dict[str, object]:
57+
_ensure_repo_root_on_syspath()
58+
from predicate_authority import AuthorityClient # pylint: disable=import-error
59+
60+
context = AuthorityClient.from_policy_file(
61+
policy_file=policy_file,
62+
secret_key=secret_key,
63+
ttl_seconds=120,
64+
)
65+
client = context.client
66+
revoked_principal_ids = _load_revocations(revocation_file)
67+
for principal_id in revoked_principal_ids:
68+
client.revoke_principal(principal_id)
69+
70+
parent_mandate = client.verify_token(token)
71+
if parent_mandate is None:
72+
if "agent:root" in revoked_principal_ids:
73+
return {"allowed": False, "reason": "revoked_root_token"}
74+
return {"allowed": False, "reason": "invalid_or_expired_token"}
75+
76+
decision = client.authorize(
77+
_build_worker_request(),
78+
parent_mandate=parent_mandate,
79+
)
80+
if not decision.allowed or decision.mandate is None:
81+
denied_reason = (
82+
"revoked_root_token"
83+
if parent_mandate.claims.principal_id in revoked_principal_ids
84+
else "denied"
85+
)
86+
return {"allowed": False, "reason": denied_reason}
87+
88+
chain_ok = client.verify_delegation_chain(
89+
token=decision.mandate.token,
90+
parent_token=token,
91+
)
92+
return {
93+
"allowed": True,
94+
"reason": "ok",
95+
"principal_id": decision.mandate.claims.principal_id,
96+
"delegated_by": decision.mandate.claims.delegated_by,
97+
"delegation_depth": decision.mandate.claims.delegation_depth,
98+
"chain_hash": decision.mandate.claims.delegation_chain_hash,
99+
"chain_verified": chain_ok,
100+
}
101+
102+
103+
def main() -> None:
104+
parser = argparse.ArgumentParser(description="Worker process for delegation simulation.")
105+
parser.add_argument("--token", required=True)
106+
parser.add_argument("--secret-key", default="dev-secret")
107+
parser.add_argument("--revocation-file", required=True)
108+
parser.add_argument("--policy-file", required=True)
109+
args = parser.parse_args()
110+
payload = run(
111+
token=args.token,
112+
secret_key=args.secret_key,
113+
revocation_file=args.revocation_file,
114+
policy_file=args.policy_file,
115+
)
116+
print(json.dumps(payload, indent=2, sort_keys=True))
117+
118+
119+
if __name__ == "__main__":
120+
main()

0 commit comments

Comments
 (0)