Skip to content

Commit f67c2a9

Browse files
authored
Merge pull request #11 from PredicateSystems/auth
auth endpoint
2 parents 47a565d + 85c580f commit f67c2a9

2 files changed

Lines changed: 279 additions & 1 deletion

File tree

predicate_authority/daemon.py

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,18 @@
4949
SidecarConfig,
5050
)
5151
from predicate_authority.sidecar_store import LocalCredentialStore
52-
from predicate_contracts import PolicyRule, TraceEmitter
52+
from predicate_contracts import (
53+
ActionRequest,
54+
ActionSpec,
55+
AuthorizationDecision,
56+
PolicyRule,
57+
PrincipalRef,
58+
StateEvidence,
59+
TraceEmitter,
60+
VerificationEvidence,
61+
VerificationSignal,
62+
VerificationStatus,
63+
)
5364

5465

5566
@dataclass(frozen=True)
@@ -185,6 +196,8 @@ def do_GET(self) -> None: # noqa: N802
185196
def do_POST(self) -> None: # noqa: N802
186197
parsed = urlparse(self.path)
187198
handlers: dict[str, Any] = {
199+
"/v1/authorize": self._handle_authorize,
200+
"/authorize": self._handle_authorize,
188201
"/policy/reload": self._handle_policy_reload,
189202
"/revoke/principal": self._handle_revoke_principal,
190203
"/revoke/intent": self._handle_revoke_intent,
@@ -201,6 +214,137 @@ def do_POST(self) -> None: # noqa: N802
201214
return
202215
handler()
203216

217+
def _handle_authorize(self) -> None:
218+
payload = self._read_json_body()
219+
try:
220+
request = self._build_action_request(payload)
221+
except ValueError as exc:
222+
self._send_json(400, {"error": str(exc)})
223+
return
224+
decision = self.server.daemon_ref.authorize_request(request) # type: ignore[attr-defined]
225+
response = {
226+
"allowed": decision.allowed,
227+
"reason": decision.reason.value,
228+
"mandate_id": (
229+
decision.mandate.claims.mandate_id if decision.mandate is not None else None
230+
),
231+
"violated_rule": decision.violated_rule,
232+
"missing_labels": list(decision.missing_labels),
233+
}
234+
self._send_json(200 if decision.allowed else 403, response)
235+
236+
def _build_action_request(self, payload: dict[str, Any]) -> ActionRequest:
237+
principal_id = payload.get("principal")
238+
if not isinstance(principal_id, str) or principal_id.strip() == "":
239+
raise ValueError("principal is required")
240+
action = payload.get("action")
241+
if not isinstance(action, str) or action.strip() == "":
242+
raise ValueError("action is required")
243+
resource = payload.get("resource")
244+
if not isinstance(resource, str) or resource.strip() == "":
245+
raise ValueError("resource is required")
246+
247+
intent_hash = payload.get("intent_hash")
248+
if isinstance(intent_hash, str) and intent_hash.strip() != "":
249+
intent = intent_hash.strip()
250+
else:
251+
intent = f"{action.strip()}:{resource.strip()}"
252+
253+
context_raw = payload.get("context")
254+
context = context_raw if isinstance(context_raw, dict) else {}
255+
tenant_id = context.get("tenant_id")
256+
session_id = context.get("session_id")
257+
state = self._parse_state_evidence(payload=payload, context=context, intent=intent)
258+
verification = self._parse_verification_evidence(payload=payload)
259+
260+
return ActionRequest(
261+
principal=PrincipalRef(
262+
principal_id=principal_id.strip(),
263+
tenant_id=tenant_id if isinstance(tenant_id, str) else None,
264+
session_id=session_id if isinstance(session_id, str) else None,
265+
),
266+
action_spec=ActionSpec(
267+
action=action.strip(),
268+
resource=resource.strip(),
269+
intent=intent,
270+
),
271+
state_evidence=state,
272+
verification_evidence=verification,
273+
)
274+
275+
def _parse_state_evidence(
276+
self,
277+
payload: dict[str, Any],
278+
context: dict[str, Any],
279+
intent: str,
280+
) -> StateEvidence:
281+
state_raw = payload.get("state_evidence")
282+
if not isinstance(state_raw, dict):
283+
state_raw = {}
284+
source = state_raw.get("source")
285+
state_hash = state_raw.get("state_hash")
286+
schema_version = state_raw.get("schema_version")
287+
confidence = state_raw.get("confidence")
288+
289+
source_value = (
290+
source.strip() if isinstance(source, str) and source.strip() != "" else "external"
291+
)
292+
if isinstance(state_hash, str) and state_hash.strip() != "":
293+
state_hash_value = state_hash.strip()
294+
else:
295+
context_state_hash = context.get("state_hash")
296+
if isinstance(context_state_hash, str) and context_state_hash.strip() != "":
297+
state_hash_value = context_state_hash.strip()
298+
else:
299+
state_hash_value = intent
300+
schema_value = (
301+
schema_version.strip()
302+
if isinstance(schema_version, str) and schema_version.strip() != ""
303+
else "v1"
304+
)
305+
confidence_value = float(confidence) if isinstance(confidence, (float, int)) else None
306+
return StateEvidence(
307+
source=source_value,
308+
state_hash=state_hash_value,
309+
schema_version=schema_value,
310+
confidence=confidence_value,
311+
)
312+
313+
def _parse_verification_evidence(self, payload: dict[str, Any]) -> VerificationEvidence:
314+
verification_raw = payload.get("verification_evidence")
315+
if not isinstance(verification_raw, dict):
316+
return VerificationEvidence()
317+
signals_raw = verification_raw.get("signals")
318+
if not isinstance(signals_raw, list):
319+
return VerificationEvidence()
320+
parsed_signals: list[VerificationSignal] = []
321+
for item in signals_raw:
322+
if not isinstance(item, dict):
323+
continue
324+
label = item.get("label")
325+
status = item.get("status")
326+
if not isinstance(label, str) or label.strip() == "":
327+
continue
328+
if not isinstance(status, str):
329+
continue
330+
try:
331+
parsed_status = VerificationStatus(status.strip())
332+
except ValueError:
333+
continue
334+
required_raw = item.get("required")
335+
required = bool(required_raw) if isinstance(required_raw, bool) else True
336+
reason_raw = item.get("reason")
337+
reason = reason_raw if isinstance(reason_raw, str) else None
338+
parsed_signals.append(
339+
VerificationSignal(
340+
label=label.strip(),
341+
status=parsed_status,
342+
required=required,
343+
reason=reason,
344+
)
345+
)
346+
return VerificationEvidence(signals=tuple(parsed_signals))
347+
204348
def _handle_policy_reload(self) -> None:
205349
reloaded = self.server.daemon_ref.reload_policy_now() # type: ignore[attr-defined]
206350
self._send_json(200, {"reloaded": reloaded})
@@ -447,6 +591,9 @@ def revoke_mandate(self, mandate_id: str) -> None:
447591
def max_request_body_bytes(self) -> int:
448592
return max(0, int(self._config.max_request_body_bytes))
449593

594+
def authorize_request(self, request: ActionRequest) -> AuthorizationDecision:
595+
return self._sidecar.issue_mandate(request)
596+
450597
def issue_task_identity(
451598
self,
452599
principal_id: str,

tests/test_daemon_phase2.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,24 @@ def _post_json(url: str, body: dict[str, object] | None = None) -> dict[str, obj
116116
return loaded
117117

118118

119+
def _post_json_with_status(
120+
url: str, body: dict[str, object] | None = None
121+
) -> tuple[int, dict[str, object]]:
122+
parsed = urlsplit(url)
123+
path = parsed.path or "/"
124+
payload = json.dumps(body or {})
125+
connection = http.client.HTTPConnection(parsed.netloc, timeout=2.0)
126+
try:
127+
connection.request("POST", path, body=payload, headers={"Content-Type": "application/json"})
128+
response = connection.getresponse()
129+
content = response.read().decode("utf-8")
130+
loaded = json.loads(content) if content else {}
131+
finally:
132+
connection.close()
133+
assert isinstance(loaded, dict)
134+
return int(response.status), loaded
135+
136+
119137
def _fetch_text(url: str) -> str:
120138
parsed = urlsplit(url)
121139
path = parsed.path or "/"
@@ -310,6 +328,119 @@ def test_daemon_supports_policy_reload_and_revoke_endpoints(tmp_path: Path) -> N
310328
daemon.stop()
311329

312330

331+
def test_daemon_authorize_endpoint_allows_and_returns_mandate_metadata(tmp_path: Path) -> None:
332+
policy_file = tmp_path / "policy.json"
333+
policy_file.write_text(
334+
json.dumps(
335+
{
336+
"rules": [
337+
{
338+
"name": "allow-any-http",
339+
"effect": "allow",
340+
"principals": ["agent:*"],
341+
"actions": ["http.*"],
342+
"resources": ["https://*/*"],
343+
}
344+
]
345+
}
346+
),
347+
encoding="utf-8",
348+
)
349+
sidecar = _build_sidecar(tmp_path, policy_file)
350+
daemon = PredicateAuthorityDaemon(
351+
sidecar=sidecar,
352+
config=DaemonConfig(host="127.0.0.1", port=0, policy_poll_interval_s=10.0),
353+
)
354+
daemon.start()
355+
try:
356+
base_url = f"http://127.0.0.1:{daemon.bound_port}"
357+
status_code, payload = _post_json_with_status(
358+
f"{base_url}/v1/authorize",
359+
{
360+
"principal": "agent:test",
361+
"action": "http.post",
362+
"resource": "https://api.vendor.com/orders",
363+
"intent_hash": "intent-hash-123",
364+
"context": {"tenant_id": "tenant-a", "session_id": "session-a"},
365+
"state_evidence": {"source": "test", "state_hash": "state-1"},
366+
},
367+
)
368+
assert status_code == 200
369+
assert payload["allowed"] is True
370+
assert payload["reason"] == "allowed"
371+
assert isinstance(payload.get("mandate_id"), str)
372+
assert payload.get("missing_labels") == []
373+
finally:
374+
daemon.stop()
375+
376+
377+
def test_daemon_authorize_endpoint_denies_with_403(tmp_path: Path) -> None:
378+
policy_file = tmp_path / "policy.json"
379+
policy_file.write_text(
380+
json.dumps(
381+
{
382+
"rules": [
383+
{
384+
"name": "allow-only-orders",
385+
"effect": "allow",
386+
"principals": ["agent:*"],
387+
"actions": ["http.post"],
388+
"resources": ["https://api.vendor.com/orders"],
389+
}
390+
]
391+
}
392+
),
393+
encoding="utf-8",
394+
)
395+
sidecar = _build_sidecar(tmp_path, policy_file)
396+
daemon = PredicateAuthorityDaemon(
397+
sidecar=sidecar,
398+
config=DaemonConfig(host="127.0.0.1", port=0, policy_poll_interval_s=10.0),
399+
)
400+
daemon.start()
401+
try:
402+
base_url = f"http://127.0.0.1:{daemon.bound_port}"
403+
status_code, payload = _post_json_with_status(
404+
f"{base_url}/v1/authorize",
405+
{
406+
"principal": "agent:test",
407+
"action": "http.delete",
408+
"resource": "https://api.vendor.com/orders",
409+
"intent_hash": "intent-hash-123",
410+
"state_evidence": {"source": "test", "state_hash": "state-1"},
411+
},
412+
)
413+
assert status_code == 403
414+
assert payload["allowed"] is False
415+
assert payload["reason"] == "no_matching_policy"
416+
finally:
417+
daemon.stop()
418+
419+
420+
def test_daemon_authorize_endpoint_validates_required_fields(tmp_path: Path) -> None:
421+
policy_file = tmp_path / "policy.json"
422+
policy_file.write_text(json.dumps({"rules": []}), encoding="utf-8")
423+
sidecar = _build_sidecar(tmp_path, policy_file)
424+
daemon = PredicateAuthorityDaemon(
425+
sidecar=sidecar,
426+
config=DaemonConfig(host="127.0.0.1", port=0, policy_poll_interval_s=10.0),
427+
)
428+
daemon.start()
429+
try:
430+
base_url = f"http://127.0.0.1:{daemon.bound_port}"
431+
status_code, payload = _post_json_with_status(
432+
f"{base_url}/v1/authorize",
433+
{
434+
"action": "http.post",
435+
"resource": "https://api.vendor.com/orders",
436+
},
437+
)
438+
assert status_code == 400
439+
assert payload["error"] == "principal is required"
440+
finally:
441+
daemon.stop()
442+
443+
313444
class _ControlPlaneHandler(BaseHTTPRequestHandler):
314445
requests: list[tuple[str, dict[str, object], dict[str, str]]]
315446

0 commit comments

Comments
 (0)