Skip to content

Commit 0a3bc43

Browse files
committed
Phase 2 hardening
1 parent 70992d5 commit 0a3bc43

6 files changed

Lines changed: 447 additions & 3 deletions

File tree

docs/authorityd-operations.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,98 @@ PYTHONPATH=. predicate-authorityd \
8181
--mandate-signing-key-env PREDICATE_AUTHORITY_SIGNING_KEY
8282
```
8383

84+
## 2b) Okta production hardening checklist + staging matrix
85+
86+
Use this section when validating enterprise IdP readiness for Phase 2.
87+
88+
### Checklist
89+
90+
- [ ] Configure dedicated Okta OIDC app integration per environment (staging/prod split).
91+
- [ ] Verify configured `issuer` and `audience` are exact matches to the target environment.
92+
- [ ] Verify required claims/scopes/groups mapping used by authority role/tenant checks.
93+
- [ ] Enforce strict JWT checks (`iss`, `aud`, `exp`, `nbf`, `iat`, required claims, alg allowlist).
94+
- [ ] Validate JWKS retrieval and cache behavior for normal operation.
95+
- [ ] Validate key rotation behavior (`kid` rollover) without service restart.
96+
- [ ] Validate fail-closed behavior for cold-start JWKS failure and stale key scenarios.
97+
- [ ] Validate redaction: no token/secret leakage in logs on failures/retries.
98+
- [x] Validate startup diagnostics for missing/invalid auth configuration.
99+
- [ ] Validate revocation path behavior under Okta-backed principals.
100+
101+
### Staging test matrix
102+
103+
| Test ID | Scenario | Expected Result |
104+
| --- | --- | --- |
105+
| OKTA-01 | Valid token (correct issuer/audience/scope) | Request authorized and audit emitted |
106+
| OKTA-02 | Wrong issuer | Denied with issuer mismatch reason |
107+
| OKTA-03 | Wrong audience | Denied with audience mismatch reason |
108+
| OKTA-04 | Missing required scope | Denied fail-closed before action |
109+
| OKTA-05 | Expired token | Denied with expiration reason |
110+
| OKTA-06 | Future `nbf` beyond leeway | Denied with temporal validation reason |
111+
| OKTA-07 | Unsupported signing algorithm | Denied before trust decision |
112+
| OKTA-08 | JWKS rotation (`kid` changes) | Validation recovers without restart |
113+
| OKTA-09 | JWKS outage with warm cache | Existing key path continues until cache boundary |
114+
| OKTA-10 | JWKS outage with cold cache | Startup/auth fails closed with actionable diagnostics |
115+
| OKTA-11 | Tenant outside allow-list | Denied with tenant policy reason |
116+
| OKTA-12 | Principal/intent revocation during run | Subsequent action denied promptly |
117+
| OKTA-13 | Log redaction check | No raw tokens/secrets in logs |
118+
119+
### Signoff evidence commands (deterministic integration tests)
120+
121+
Run these from `AgentIdentity` repo root and attach output to signoff evidence.
122+
123+
1) Network partition fail-closed behavior:
124+
125+
```bash
126+
python3 -m pytest tests/test_daemon_phase2.py -k "network_partition_fail_closed_raises_and_tracks_failure"
127+
```
128+
129+
Checkpoints:
130+
131+
- pass result proves fail-closed error path is enforced when control-plane is partitioned and `fail_open=False`,
132+
- `/status` payload includes incremented control-plane failure counters.
133+
134+
2) Restart recovery with persisted queue:
135+
136+
```bash
137+
python3 -m pytest tests/test_daemon_phase2.py -k "restart_recovers_queue_after_partition"
138+
```
139+
140+
Checkpoints:
141+
142+
- pre-restart flush queue has pending event(s),
143+
- post-restart `POST /ledger/flush-now` reports `sent_count >= 1`,
144+
- post-flush queue is empty (`GET /ledger/flush-queue` returns no items).
145+
84146
When enabled, daemon bootstrap auto-attaches `ControlPlaneTraceEmitter` so each
85147
authority decision pushes:
86148

87149
- audit events -> `/v1/audit/events:batch`
88150
- usage credits -> `/v1/metering/usage:batch`
89151

152+
### Optional: use Okta identity mode
153+
154+
Provide Okta OIDC values via env vars:
155+
156+
```bash
157+
export OKTA_ISSUER="https://<org>.okta.com/oauth2/default"
158+
export OKTA_CLIENT_ID="<okta-client-id>"
159+
export OKTA_AUDIENCE="api://predicate-authority"
160+
```
161+
162+
Start daemon in Okta mode:
163+
164+
```bash
165+
PYTHONPATH=. predicate-authorityd \
166+
--host 127.0.0.1 \
167+
--port 8787 \
168+
--mode cloud_connected \
169+
--identity-mode okta \
170+
--okta-issuer "$OKTA_ISSUER" \
171+
--okta-client-id "$OKTA_CLIENT_ID" \
172+
--okta-audience "$OKTA_AUDIENCE" \
173+
--policy-file examples/authorityd/policy.json
174+
```
175+
90176
## 3b) Optional local identity registry (ephemeral task identities)
91177

92178
Enable local identity support:

predicate_authority/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
LocalIdPBridgeConfig,
88
OIDCBridgeConfig,
99
OIDCIdentityBridge,
10+
OktaBridgeConfig,
11+
OktaIdentityBridge,
1012
TokenExchangeResult,
1113
)
1214
from predicate_authority.client import AuthorityClient, LocalAuthorizationContext
@@ -71,6 +73,8 @@
7173
"LocalRevocationCache",
7274
"OIDCBridgeConfig",
7375
"OIDCIdentityBridge",
76+
"OktaBridgeConfig",
77+
"OktaIdentityBridge",
7478
"OpenTelemetryTraceEmitter",
7579
"PolicyEngine",
7680
"PolicyFileSource",

predicate_authority/bridge.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ class EntraBridgeConfig:
4444
token_ttl_seconds: int = 300
4545

4646

47+
@dataclass(frozen=True)
48+
class OktaBridgeConfig:
49+
issuer: str
50+
client_id: str
51+
audience: str
52+
token_ttl_seconds: int = 300
53+
54+
4755
@dataclass(frozen=True)
4856
class LocalIdPBridgeConfig:
4957
issuer: str = "http://localhost/predicate-local-idp"
@@ -138,6 +146,33 @@ def exchange_token(
138146
)
139147

140148

149+
class OktaIdentityBridge(OIDCIdentityBridge):
150+
"""Okta adapter built on generic OIDC behavior.
151+
152+
Phase 2 keeps this as a deterministic local stand-in for real IdP token exchange.
153+
"""
154+
155+
def __init__(self, config: OktaBridgeConfig) -> None:
156+
oidc_config = OIDCBridgeConfig(
157+
issuer=config.issuer,
158+
client_id=config.client_id,
159+
audience=config.audience,
160+
token_ttl_seconds=config.token_ttl_seconds,
161+
)
162+
super().__init__(oidc_config)
163+
164+
def exchange_token(
165+
self, subject: PrincipalRef, state_evidence: StateEvidence
166+
) -> TokenExchangeResult:
167+
result = super().exchange_token(subject, state_evidence)
168+
return TokenExchangeResult(
169+
access_token=result.access_token,
170+
expires_at_epoch_s=result.expires_at_epoch_s,
171+
token_type=result.token_type,
172+
provider=IdentityProviderType.OKTA,
173+
)
174+
175+
141176
class LocalIdPBridge:
142177
"""Local IdP emulator for dev/offline/air-gapped workflows."""
143178

predicate_authority/daemon.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
LocalIdPBridgeConfig,
2121
OIDCBridgeConfig,
2222
OIDCIdentityBridge,
23+
OktaBridgeConfig,
24+
OktaIdentityBridge,
2325
)
2426
from predicate_authority.control_plane import (
2527
ControlPlaneClient,
@@ -727,6 +729,19 @@ def _build_identity_bridge_from_args(args: argparse.Namespace) -> ExchangeTokenB
727729
token_ttl_seconds=int(args.idp_token_ttl_s),
728730
)
729731
)
732+
if mode == "okta":
733+
if args.okta_issuer is None or args.okta_client_id is None or args.okta_audience is None:
734+
raise SystemExit(
735+
"identity-mode=okta requires --okta-issuer, --okta-client-id, and --okta-audience."
736+
)
737+
return OktaIdentityBridge(
738+
OktaBridgeConfig(
739+
issuer=str(args.okta_issuer),
740+
client_id=str(args.okta_client_id),
741+
audience=str(args.okta_audience),
742+
token_ttl_seconds=int(args.idp_token_ttl_s),
743+
)
744+
)
730745
raise SystemExit(f"Unsupported identity mode: {mode}")
731746

732747

@@ -773,9 +788,9 @@ def main() -> None:
773788
parser.add_argument("--local-identity-default-ttl-s", type=int, default=900)
774789
parser.add_argument(
775790
"--identity-mode",
776-
choices=["local", "local-idp", "oidc", "entra"],
791+
choices=["local", "local-idp", "oidc", "entra", "okta"],
777792
default="local",
778-
help="Identity source for token exchange: local, local-idp, oidc, or entra.",
793+
help="Identity source for token exchange: local, local-idp, oidc, entra, or okta.",
779794
)
780795
parser.add_argument("--idp-token-ttl-s", type=int, default=300)
781796
parser.add_argument(
@@ -797,6 +812,9 @@ def main() -> None:
797812
parser.add_argument("--entra-tenant-id", default=os.getenv("ENTRA_TENANT_ID"))
798813
parser.add_argument("--entra-client-id", default=os.getenv("ENTRA_CLIENT_ID"))
799814
parser.add_argument("--entra-audience", default=os.getenv("ENTRA_AUDIENCE"))
815+
parser.add_argument("--okta-issuer", default=os.getenv("OKTA_ISSUER"))
816+
parser.add_argument("--okta-client-id", default=os.getenv("OKTA_CLIENT_ID"))
817+
parser.add_argument("--okta-audience", default=os.getenv("OKTA_AUDIENCE"))
800818
parser.add_argument(
801819
"--control-plane-enabled",
802820
action="store_true",

0 commit comments

Comments
 (0)