Skip to content

Commit b583784

Browse files
committed
local IdP
1 parent 58f67f4 commit b583784

11 files changed

Lines changed: 1085 additions & 8 deletions

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,53 @@ predicate-authority revoke intent --host 127.0.0.1 --port 8787 --hash <intent_ha
150150
predicate-authorityd --host 127.0.0.1 --port 8787 --mode local_only --policy-file examples/authorityd/policy.json
151151
```
152152

153+
### Identity mode options (`predicate-authorityd`)
154+
155+
- `--identity-mode local`: deterministic local bridge (default).
156+
- `--identity-mode local-idp`: local IdP-style signed token mode for dev/air-gapped workflows.
157+
- `--identity-mode oidc`: enterprise OIDC bridge mode.
158+
- `--identity-mode entra`: Microsoft Entra bridge mode.
159+
160+
Example (`local-idp`):
161+
162+
```bash
163+
export LOCAL_IDP_SIGNING_KEY="replace-with-strong-secret"
164+
predicate-authorityd \
165+
--host 127.0.0.1 \
166+
--port 8787 \
167+
--mode local_only \
168+
--policy-file examples/authorityd/policy.json \
169+
--identity-mode local-idp \
170+
--local-idp-issuer "http://localhost/predicate-local-idp" \
171+
--local-idp-audience "api://predicate-authority"
172+
```
173+
174+
### How to run with control-plane shipping (out-of-the-box)
175+
176+
```bash
177+
export CONTROL_PLANE_URL="http://127.0.0.1:8080"
178+
export CONTROL_PLANE_TENANT_ID="dev-tenant"
179+
export CONTROL_PLANE_PROJECT_ID="dev-project"
180+
export CONTROL_PLANE_AUTH_TOKEN="<bearer-token>"
181+
182+
PYTHONPATH=. predicate-authorityd \
183+
--host 127.0.0.1 \
184+
--port 8787 \
185+
--mode local_only \
186+
--policy-file examples/authorityd/policy.json \
187+
--control-plane-enabled \
188+
--control-plane-fail-open
189+
```
190+
191+
The `/status` endpoint now includes:
192+
193+
- `control_plane_emitter_attached`
194+
- `control_plane_audit_push_success_count`
195+
- `control_plane_audit_push_failure_count`
196+
- `control_plane_usage_push_success_count`
197+
- `control_plane_usage_push_failure_count`
198+
- `control_plane_last_push_error`
199+
153200
## Security: Local Kill-Switch Path
154201

155202
`predicate-authority` supports fail-closed checks, local proof emission, and sidecar-managed revocation/token lifecycle for long-running agents.

docs/authorityd-operations.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# `predicate-authorityd` Operations Guide
2+
3+
This guide shows how to run the local sidecar daemon, provide a policy file, and verify health/status endpoints.
4+
5+
## 1) Sample `policy.json`
6+
7+
Create `examples/authorityd/policy.json`:
8+
9+
```json
10+
{
11+
"rules": [
12+
{
13+
"name": "allow-orders-http-post",
14+
"effect": "allow",
15+
"principals": ["agent:orders-*"],
16+
"actions": ["http.post"],
17+
"resources": ["https://api.vendor.com/orders"],
18+
"required_labels": []
19+
},
20+
{
21+
"name": "deny-admin-delete",
22+
"effect": "deny",
23+
"principals": ["agent:*"],
24+
"actions": ["http.delete"],
25+
"resources": ["https://api.vendor.com/admin/*"],
26+
"required_labels": []
27+
}
28+
]
29+
}
30+
```
31+
32+
## 2) Start the daemon
33+
34+
Run from repo root:
35+
36+
```bash
37+
PYTHONPATH=. predicate-authorityd \
38+
--host 127.0.0.1 \
39+
--port 8787 \
40+
--mode local_only \
41+
--policy-file examples/authorityd/policy.json \
42+
--policy-poll-interval-s 2.0 \
43+
--credential-store-file ./.predicate-authorityd/credentials.json
44+
```
45+
46+
### Optional: enable control-plane shipping
47+
48+
To automatically ship proof events and usage records to
49+
`predicate-authority-control-plane`, set:
50+
51+
```bash
52+
export CONTROL_PLANE_URL="http://127.0.0.1:8080"
53+
export CONTROL_PLANE_TENANT_ID="dev-tenant"
54+
export CONTROL_PLANE_PROJECT_ID="dev-project"
55+
export CONTROL_PLANE_AUTH_TOKEN="<bearer-token>"
56+
57+
PYTHONPATH=. predicate-authorityd \
58+
--host 127.0.0.1 \
59+
--port 8787 \
60+
--mode local_only \
61+
--policy-file examples/authorityd/policy.json \
62+
--control-plane-enabled \
63+
--control-plane-fail-open
64+
```
65+
66+
When enabled, daemon bootstrap auto-attaches `ControlPlaneTraceEmitter` so each
67+
authority decision pushes:
68+
69+
- audit events -> `/v1/audit/events:batch`
70+
- usage credits -> `/v1/metering/usage:batch`
71+
72+
Expected startup output:
73+
74+
```text
75+
predicate-authorityd listening on http://127.0.0.1:8787 (mode=local_only)
76+
```
77+
78+
## 3) Endpoint checks
79+
80+
### Health
81+
82+
```bash
83+
curl -s http://127.0.0.1:8787/health | jq
84+
```
85+
86+
Example response:
87+
88+
```json
89+
{
90+
"status": "ok",
91+
"mode": "local_only",
92+
"uptime_s": 12
93+
}
94+
```
95+
96+
### Status
97+
98+
```bash
99+
curl -s http://127.0.0.1:8787/status | jq
100+
```
101+
102+
Example response:
103+
104+
```json
105+
{
106+
"mode": "local_only",
107+
"policy_hot_reload_enabled": true,
108+
"revoked_principal_count": 0,
109+
"revoked_intent_count": 0,
110+
"revoked_mandate_count": 0,
111+
"proof_event_count": 0,
112+
"daemon_running": true,
113+
"policy_reload_count": 1,
114+
"policy_poll_error_count": 0,
115+
"last_policy_reload_epoch_s": 1700000000.0,
116+
"last_policy_poll_error": null
117+
}
118+
```
119+
120+
## 4) Verify policy hot-reload
121+
122+
1. Update `examples/authorityd/policy.json`.
123+
2. Wait for at most `--policy-poll-interval-s`.
124+
3. Check `/status` and confirm `policy_reload_count` increases.
125+
126+
## 5) Stop daemon
127+
128+
Press `Ctrl+C` in the daemon terminal.

predicate_authority/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ Core pieces:
88
- `ActionGuard` for pre-action `authorize` / `enforce`,
99
- `LocalMandateSigner` for signed short-lived mandates,
1010
- `InMemoryProofLedger` and optional `OpenTelemetryTraceEmitter`,
11-
- typed integration adapters (including `sdk-python` mapping helpers).
11+
- typed integration adapters (including `sdk-python` mapping helpers),
12+
- control-plane client primitives for shipping proof and usage batches to hosted APIs.

predicate_authority/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@
33
EntraIdentityBridge,
44
IdentityBridge,
55
IdentityProviderType,
6+
LocalIdPBridge,
7+
LocalIdPBridgeConfig,
68
OIDCBridgeConfig,
79
OIDCIdentityBridge,
810
TokenExchangeResult,
911
)
12+
from predicate_authority.control_plane import (
13+
AuditEventEnvelope,
14+
ControlPlaneClient,
15+
ControlPlaneClientConfig,
16+
ControlPlaneTraceEmitter,
17+
UsageCreditRecord,
18+
)
1019
from predicate_authority.daemon import DaemonConfig, PredicateAuthorityDaemon
1120
from predicate_authority.errors import AuthorizationDeniedError
1221
from predicate_authority.guard import ActionExecutionResult, ActionGuard
@@ -30,13 +39,19 @@
3039
"ActionGuard",
3140
"AuthorityMode",
3241
"AuthorizationDeniedError",
42+
"AuditEventEnvelope",
43+
"ControlPlaneClient",
44+
"ControlPlaneClientConfig",
45+
"ControlPlaneTraceEmitter",
3346
"CredentialRecord",
3447
"DaemonConfig",
3548
"EntraBridgeConfig",
3649
"EntraIdentityBridge",
3750
"IdentityBridge",
3851
"IdentityProviderType",
3952
"InMemoryProofLedger",
53+
"LocalIdPBridge",
54+
"LocalIdPBridgeConfig",
4055
"LocalCredentialStore",
4156
"LocalMandateSigner",
4257
"LocalRevocationCache",
@@ -53,4 +68,5 @@
5368
"SidecarError",
5469
"SidecarStatus",
5570
"TokenExchangeResult",
71+
"UsageCreditRecord",
5672
]

predicate_authority/bridge.py

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

3+
import base64
34
import hashlib
5+
import hmac
6+
import json
47
import time
8+
from collections.abc import Mapping
59
from dataclasses import dataclass
610
from enum import Enum
711

@@ -10,6 +14,7 @@
1014

1115
class IdentityProviderType(str, Enum):
1216
LOCAL = "local"
17+
LOCAL_IDP = "local_idp"
1318
OIDC = "oidc"
1419
ENTRA = "entra"
1520
OKTA = "okta"
@@ -39,6 +44,14 @@ class EntraBridgeConfig:
3944
token_ttl_seconds: int = 300
4045

4146

47+
@dataclass(frozen=True)
48+
class LocalIdPBridgeConfig:
49+
issuer: str = "http://localhost/predicate-local-idp"
50+
audience: str = "api://predicate-authority"
51+
signing_key: str = "predicate-local-idp-dev-key"
52+
token_ttl_seconds: int = 300
53+
54+
4255
class IdentityBridge:
4356
"""Local bridge implementation for development/local-only mode."""
4457

@@ -120,3 +133,84 @@ def exchange_token(
120133
token_type=result.token_type,
121134
provider=IdentityProviderType.ENTRA,
122135
)
136+
137+
138+
class LocalIdPBridge:
139+
"""Local IdP emulator for dev/offline/air-gapped workflows."""
140+
141+
def __init__(self, config: LocalIdPBridgeConfig) -> None:
142+
self._config = config
143+
144+
def exchange_token(
145+
self, subject: PrincipalRef, state_evidence: StateEvidence
146+
) -> TokenExchangeResult:
147+
expires_at = int(time.time()) + self._config.token_ttl_seconds
148+
token = self._mint_token(
149+
subject=subject,
150+
state_evidence=state_evidence,
151+
expires_at_epoch_s=expires_at,
152+
grant_kind="access",
153+
refresh_token=None,
154+
)
155+
return TokenExchangeResult(
156+
access_token=token,
157+
expires_at_epoch_s=expires_at,
158+
provider=IdentityProviderType.LOCAL_IDP,
159+
)
160+
161+
def refresh_token(
162+
self, refresh_token: str, subject: PrincipalRef, state_evidence: StateEvidence
163+
) -> TokenExchangeResult:
164+
expires_at = int(time.time()) + self._config.token_ttl_seconds
165+
token = self._mint_token(
166+
subject=subject,
167+
state_evidence=state_evidence,
168+
expires_at_epoch_s=expires_at,
169+
grant_kind="refresh_access",
170+
refresh_token=refresh_token,
171+
)
172+
return TokenExchangeResult(
173+
access_token=token,
174+
expires_at_epoch_s=expires_at,
175+
provider=IdentityProviderType.LOCAL_IDP,
176+
)
177+
178+
def _mint_token(
179+
self,
180+
subject: PrincipalRef,
181+
state_evidence: StateEvidence,
182+
expires_at_epoch_s: int,
183+
grant_kind: str,
184+
refresh_token: str | None,
185+
) -> str:
186+
header = {"alg": "HS256", "typ": "JWT", "kid": "predicate-local-idp-dev"}
187+
payload: dict[str, str | int | None] = {
188+
"iss": self._config.issuer,
189+
"aud": self._config.audience,
190+
"sub": subject.principal_id,
191+
"state_hash": state_evidence.state_hash,
192+
"state_source": state_evidence.source,
193+
"token_kind": grant_kind,
194+
"exp": expires_at_epoch_s,
195+
"iat": int(time.time()),
196+
"tenant_id": subject.tenant_id,
197+
"session_id": subject.session_id,
198+
"refresh_token_hash": (
199+
hashlib.sha256(refresh_token.encode("utf-8")).hexdigest()
200+
if refresh_token is not None
201+
else None
202+
),
203+
}
204+
header_b64 = _b64url_json(header)
205+
payload_b64 = _b64url_json(payload)
206+
signing_input = f"{header_b64}.{payload_b64}".encode()
207+
signature = hmac.new(
208+
self._config.signing_key.encode("utf-8"), signing_input, hashlib.sha256
209+
).digest()
210+
signature_b64 = base64.urlsafe_b64encode(signature).rstrip(b"=").decode("utf-8")
211+
return f"{header_b64}.{payload_b64}.{signature_b64}"
212+
213+
214+
def _b64url_json(value: Mapping[str, str | int | None]) -> str:
215+
encoded = json.dumps(value, separators=(",", ":")).encode("utf-8")
216+
return base64.urlsafe_b64encode(encoded).rstrip(b"=").decode("utf-8")

0 commit comments

Comments
 (0)