From 0a3bc435f20fb86b412445b434164824c5300ac2 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Tue, 17 Feb 2026 18:39:38 -0800 Subject: [PATCH 1/5] Phase 2 hardening --- docs/authorityd-operations.md | 86 ++++++++ predicate_authority/__init__.py | 4 + predicate_authority/bridge.py | 35 ++++ predicate_authority/daemon.py | 22 ++- tests/test_daemon_phase2.py | 282 ++++++++++++++++++++++++++- tests/test_identity_bridge_phase2.py | 21 ++ 6 files changed, 447 insertions(+), 3 deletions(-) diff --git a/docs/authorityd-operations.md b/docs/authorityd-operations.md index 462533f..b1e0a76 100644 --- a/docs/authorityd-operations.md +++ b/docs/authorityd-operations.md @@ -81,12 +81,98 @@ PYTHONPATH=. predicate-authorityd \ --mandate-signing-key-env PREDICATE_AUTHORITY_SIGNING_KEY ``` +## 2b) Okta production hardening checklist + staging matrix + +Use this section when validating enterprise IdP readiness for Phase 2. + +### Checklist + +- [ ] Configure dedicated Okta OIDC app integration per environment (staging/prod split). +- [ ] Verify configured `issuer` and `audience` are exact matches to the target environment. +- [ ] Verify required claims/scopes/groups mapping used by authority role/tenant checks. +- [ ] Enforce strict JWT checks (`iss`, `aud`, `exp`, `nbf`, `iat`, required claims, alg allowlist). +- [ ] Validate JWKS retrieval and cache behavior for normal operation. +- [ ] Validate key rotation behavior (`kid` rollover) without service restart. +- [ ] Validate fail-closed behavior for cold-start JWKS failure and stale key scenarios. +- [ ] Validate redaction: no token/secret leakage in logs on failures/retries. +- [x] Validate startup diagnostics for missing/invalid auth configuration. +- [ ] Validate revocation path behavior under Okta-backed principals. + +### Staging test matrix + +| Test ID | Scenario | Expected Result | +| --- | --- | --- | +| OKTA-01 | Valid token (correct issuer/audience/scope) | Request authorized and audit emitted | +| OKTA-02 | Wrong issuer | Denied with issuer mismatch reason | +| OKTA-03 | Wrong audience | Denied with audience mismatch reason | +| OKTA-04 | Missing required scope | Denied fail-closed before action | +| OKTA-05 | Expired token | Denied with expiration reason | +| OKTA-06 | Future `nbf` beyond leeway | Denied with temporal validation reason | +| OKTA-07 | Unsupported signing algorithm | Denied before trust decision | +| OKTA-08 | JWKS rotation (`kid` changes) | Validation recovers without restart | +| OKTA-09 | JWKS outage with warm cache | Existing key path continues until cache boundary | +| OKTA-10 | JWKS outage with cold cache | Startup/auth fails closed with actionable diagnostics | +| OKTA-11 | Tenant outside allow-list | Denied with tenant policy reason | +| OKTA-12 | Principal/intent revocation during run | Subsequent action denied promptly | +| OKTA-13 | Log redaction check | No raw tokens/secrets in logs | + +### Signoff evidence commands (deterministic integration tests) + +Run these from `AgentIdentity` repo root and attach output to signoff evidence. + +1) Network partition fail-closed behavior: + +```bash +python3 -m pytest tests/test_daemon_phase2.py -k "network_partition_fail_closed_raises_and_tracks_failure" +``` + +Checkpoints: + +- pass result proves fail-closed error path is enforced when control-plane is partitioned and `fail_open=False`, +- `/status` payload includes incremented control-plane failure counters. + +2) Restart recovery with persisted queue: + +```bash +python3 -m pytest tests/test_daemon_phase2.py -k "restart_recovers_queue_after_partition" +``` + +Checkpoints: + +- pre-restart flush queue has pending event(s), +- post-restart `POST /ledger/flush-now` reports `sent_count >= 1`, +- post-flush queue is empty (`GET /ledger/flush-queue` returns no items). + When enabled, daemon bootstrap auto-attaches `ControlPlaneTraceEmitter` so each authority decision pushes: - audit events -> `/v1/audit/events:batch` - usage credits -> `/v1/metering/usage:batch` +### Optional: use Okta identity mode + +Provide Okta OIDC values via env vars: + +```bash +export OKTA_ISSUER="https://.okta.com/oauth2/default" +export OKTA_CLIENT_ID="" +export OKTA_AUDIENCE="api://predicate-authority" +``` + +Start daemon in Okta mode: + +```bash +PYTHONPATH=. predicate-authorityd \ + --host 127.0.0.1 \ + --port 8787 \ + --mode cloud_connected \ + --identity-mode okta \ + --okta-issuer "$OKTA_ISSUER" \ + --okta-client-id "$OKTA_CLIENT_ID" \ + --okta-audience "$OKTA_AUDIENCE" \ + --policy-file examples/authorityd/policy.json +``` + ## 3b) Optional local identity registry (ephemeral task identities) Enable local identity support: diff --git a/predicate_authority/__init__.py b/predicate_authority/__init__.py index 452c683..0f277d1 100644 --- a/predicate_authority/__init__.py +++ b/predicate_authority/__init__.py @@ -7,6 +7,8 @@ LocalIdPBridgeConfig, OIDCBridgeConfig, OIDCIdentityBridge, + OktaBridgeConfig, + OktaIdentityBridge, TokenExchangeResult, ) from predicate_authority.client import AuthorityClient, LocalAuthorizationContext @@ -71,6 +73,8 @@ "LocalRevocationCache", "OIDCBridgeConfig", "OIDCIdentityBridge", + "OktaBridgeConfig", + "OktaIdentityBridge", "OpenTelemetryTraceEmitter", "PolicyEngine", "PolicyFileSource", diff --git a/predicate_authority/bridge.py b/predicate_authority/bridge.py index b934561..f93af3b 100644 --- a/predicate_authority/bridge.py +++ b/predicate_authority/bridge.py @@ -44,6 +44,14 @@ class EntraBridgeConfig: token_ttl_seconds: int = 300 +@dataclass(frozen=True) +class OktaBridgeConfig: + issuer: str + client_id: str + audience: str + token_ttl_seconds: int = 300 + + @dataclass(frozen=True) class LocalIdPBridgeConfig: issuer: str = "http://localhost/predicate-local-idp" @@ -138,6 +146,33 @@ def exchange_token( ) +class OktaIdentityBridge(OIDCIdentityBridge): + """Okta adapter built on generic OIDC behavior. + + Phase 2 keeps this as a deterministic local stand-in for real IdP token exchange. + """ + + def __init__(self, config: OktaBridgeConfig) -> None: + oidc_config = OIDCBridgeConfig( + issuer=config.issuer, + client_id=config.client_id, + audience=config.audience, + token_ttl_seconds=config.token_ttl_seconds, + ) + super().__init__(oidc_config) + + def exchange_token( + self, subject: PrincipalRef, state_evidence: StateEvidence + ) -> TokenExchangeResult: + result = super().exchange_token(subject, state_evidence) + return TokenExchangeResult( + access_token=result.access_token, + expires_at_epoch_s=result.expires_at_epoch_s, + token_type=result.token_type, + provider=IdentityProviderType.OKTA, + ) + + class LocalIdPBridge: """Local IdP emulator for dev/offline/air-gapped workflows.""" diff --git a/predicate_authority/daemon.py b/predicate_authority/daemon.py index 648a083..83cb28e 100644 --- a/predicate_authority/daemon.py +++ b/predicate_authority/daemon.py @@ -20,6 +20,8 @@ LocalIdPBridgeConfig, OIDCBridgeConfig, OIDCIdentityBridge, + OktaBridgeConfig, + OktaIdentityBridge, ) from predicate_authority.control_plane import ( ControlPlaneClient, @@ -727,6 +729,19 @@ def _build_identity_bridge_from_args(args: argparse.Namespace) -> ExchangeTokenB token_ttl_seconds=int(args.idp_token_ttl_s), ) ) + if mode == "okta": + if args.okta_issuer is None or args.okta_client_id is None or args.okta_audience is None: + raise SystemExit( + "identity-mode=okta requires --okta-issuer, --okta-client-id, and --okta-audience." + ) + return OktaIdentityBridge( + OktaBridgeConfig( + issuer=str(args.okta_issuer), + client_id=str(args.okta_client_id), + audience=str(args.okta_audience), + token_ttl_seconds=int(args.idp_token_ttl_s), + ) + ) raise SystemExit(f"Unsupported identity mode: {mode}") @@ -773,9 +788,9 @@ def main() -> None: parser.add_argument("--local-identity-default-ttl-s", type=int, default=900) parser.add_argument( "--identity-mode", - choices=["local", "local-idp", "oidc", "entra"], + choices=["local", "local-idp", "oidc", "entra", "okta"], default="local", - help="Identity source for token exchange: local, local-idp, oidc, or entra.", + help="Identity source for token exchange: local, local-idp, oidc, entra, or okta.", ) parser.add_argument("--idp-token-ttl-s", type=int, default=300) parser.add_argument( @@ -797,6 +812,9 @@ def main() -> None: parser.add_argument("--entra-tenant-id", default=os.getenv("ENTRA_TENANT_ID")) parser.add_argument("--entra-client-id", default=os.getenv("ENTRA_CLIENT_ID")) parser.add_argument("--entra-audience", default=os.getenv("ENTRA_AUDIENCE")) + parser.add_argument("--okta-issuer", default=os.getenv("OKTA_ISSUER")) + parser.add_argument("--okta-client-id", default=os.getenv("OKTA_CLIENT_ID")) + parser.add_argument("--okta-audience", default=os.getenv("OKTA_AUDIENCE")) parser.add_argument( "--control-plane-enabled", action="store_true", diff --git a/tests/test_daemon_phase2.py b/tests/test_daemon_phase2.py index 5fc4c3b..a45d3cc 100644 --- a/tests/test_daemon_phase2.py +++ b/tests/test_daemon_phase2.py @@ -11,6 +11,8 @@ from typing import Any from urllib.parse import urlsplit +import pytest + # pylint: disable=import-error from predicate_authority import ( ActionGuard, @@ -181,7 +183,22 @@ def test_daemon_policy_polling_tracks_reload_count(tmp_path: Path) -> None: def test_daemon_supports_policy_reload_and_revoke_endpoints(tmp_path: Path) -> None: policy_file = tmp_path / "policy.json" - policy_file.write_text(json.dumps({"rules": []}), encoding="utf-8") + policy_file.write_text( + json.dumps( + { + "rules": [ + { + "name": "allow-any-http", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.*"], + "resources": ["https://*/*"], + } + ] + } + ), + encoding="utf-8", + ) sidecar = _build_sidecar(tmp_path, policy_file) daemon = PredicateAuthorityDaemon( sidecar=sidecar, @@ -261,6 +278,41 @@ class BoundHandler(_ControlPlaneHandler): return server, thread +def _start_partitionable_control_plane_server() -> tuple[ThreadingHTTPServer, threading.Thread]: + class PartitionableHandler(BaseHTTPRequestHandler): + requests: list[tuple[str, dict[str, object], dict[str, str]]] = [] + fail_mode = False + + def do_POST(self) -> None: # noqa: N802 + raw_length = self.headers.get("Content-Length", "0") + content_length = int(raw_length) if raw_length.isdigit() else 0 + payload_raw = ( + self.rfile.read(content_length).decode("utf-8") if content_length > 0 else "{}" + ) + loaded = json.loads(payload_raw) + assert isinstance(loaded, dict) + self.requests.append((self.path, loaded, dict(self.headers.items()))) + if self.fail_mode: + self.send_response(503) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error":"partition"}') + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 + _ = fmt + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), PartitionableHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + def _start_failing_control_plane_server() -> tuple[ThreadingHTTPServer, threading.Thread]: class FailingHandler(BaseHTTPRequestHandler): requests: list[tuple[str, dict[str, object], dict[str, str]]] = [] @@ -386,6 +438,52 @@ def test_daemon_identity_mode_local_idp_builder() -> None: assert len(token.access_token.split(".")) == 3 +def test_daemon_identity_mode_okta_builder() -> None: + args = Namespace( + identity_mode="okta", + idp_token_ttl_s=120, + local_idp_issuer="http://localhost/local-idp", + local_idp_audience="api://predicate-authority", + local_idp_signing_key_env="LOCAL_IDP_SIGNING_KEY", + oidc_issuer=None, + oidc_client_id=None, + oidc_audience=None, + entra_tenant_id=None, + entra_client_id=None, + entra_audience=None, + okta_issuer="https://dev-123456.okta.com/oauth2/default", + okta_client_id="okta-client-id", + okta_audience="api://predicate-authority", + ) + bridge = _build_identity_bridge_from_args(args) + token = bridge.exchange_token( + PrincipalRef(principal_id="agent:test"), + StateEvidence(source="test", state_hash="state-1"), + ) + assert token.provider.value == "okta" + + +def test_daemon_identity_mode_okta_requires_args() -> None: + args = Namespace( + identity_mode="okta", + idp_token_ttl_s=120, + local_idp_issuer="http://localhost/local-idp", + local_idp_audience="api://predicate-authority", + local_idp_signing_key_env="LOCAL_IDP_SIGNING_KEY", + oidc_issuer=None, + oidc_client_id=None, + oidc_audience=None, + entra_tenant_id=None, + entra_client_id=None, + entra_audience=None, + okta_issuer=None, + okta_client_id="okta-client-id", + okta_audience="api://predicate-authority", + ) + with pytest.raises(SystemExit): + _build_identity_bridge_from_args(args) + + def test_daemon_local_identity_registry_endpoints(tmp_path: Path) -> None: policy_file = tmp_path / "policy.json" policy_file.write_text(json.dumps({"rules": []}), encoding="utf-8") @@ -698,3 +796,185 @@ def test_dead_letter_threshold_quarantines_queue_items(tmp_path: Path) -> None: daemon.stop() server.shutdown() server.server_close() + + +def test_daemon_network_partition_fail_closed_raises_and_tracks_failure(tmp_path: Path) -> None: + policy_file = tmp_path / "policy.json" + policy_file.write_text( + json.dumps( + { + "rules": [ + { + "name": "allow-any-http", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.*"], + "resources": ["https://*/*"], + } + ] + } + ), + encoding="utf-8", + ) + server, _ = _start_partitionable_control_plane_server() + daemon: PredicateAuthorityDaemon | None = None + try: + sidecar = _build_default_sidecar( + mode=AuthorityMode.LOCAL_ONLY, + policy_file=str(policy_file), + credential_store_file=str(tmp_path / "credentials.json"), + control_plane_config=ControlPlaneBootstrapConfig( + enabled=True, + base_url=f"http://127.0.0.1:{server.server_port}", + tenant_id="tenant-a", + project_id="project-a", + auth_token="token-a", + fail_open=False, + max_retries=0, + ), + ) + daemon = PredicateAuthorityDaemon( + sidecar=sidecar, + config=DaemonConfig(host="127.0.0.1", port=0, policy_poll_interval_s=10.0), + ) + daemon.start() + request = ActionRequest( + principal=PrincipalRef(principal_id="agent:partition"), + action_spec=ActionSpec( + action="http.post", + resource="https://api.vendor.com/orders", + intent="create order", + ), + state_evidence=StateEvidence(source="test", state_hash="partition-state"), + verification_evidence=VerificationEvidence(), + ) + + warmup = sidecar.issue_mandate(request) + assert warmup.allowed is True + + handler_cls = server.RequestHandlerClass + setattr(handler_cls, "fail_mode", True) + + try: + _ = sidecar.issue_mandate(request) + raise AssertionError("Expected fail-closed control-plane error during partition.") + except RuntimeError as exc: + assert "control-plane request failed" in str(exc) + + status = _fetch_json(f"http://127.0.0.1:{daemon.bound_port}/status") + assert int(status["control_plane_audit_push_failure_count"]) >= 1 + assert status["control_plane_last_push_error"] is not None + finally: + if daemon is not None: + daemon.stop() + server.shutdown() + server.server_close() + + +def test_daemon_restart_recovers_queue_after_partition(tmp_path: Path) -> None: + policy_file = tmp_path / "policy.json" + policy_file.write_text( + json.dumps( + { + "rules": [ + { + "name": "allow-any-http", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.*"], + "resources": ["https://*/*"], + } + ] + } + ), + encoding="utf-8", + ) + registry_file = tmp_path / "local-identities.json" + failing_server, _ = _start_failing_control_plane_server() + daemon: PredicateAuthorityDaemon | None = None + try: + sidecar = _build_default_sidecar( + mode=AuthorityMode.LOCAL_ONLY, + policy_file=str(policy_file), + credential_store_file=str(tmp_path / "credentials.json"), + control_plane_config=ControlPlaneBootstrapConfig( + enabled=True, + base_url=f"http://127.0.0.1:{failing_server.server_port}", + tenant_id="tenant-a", + project_id="project-a", + auth_token="token-a", + fail_open=True, + max_retries=0, + ), + local_identity_config=LocalIdentityBootstrapConfig( + enabled=True, + registry_file_path=str(registry_file), + default_ttl_seconds=60, + ), + ) + daemon = PredicateAuthorityDaemon( + sidecar=sidecar, + config=DaemonConfig(host="127.0.0.1", port=0, policy_poll_interval_s=10.0), + flush_worker=FlushWorkerConfig(enabled=False, interval_s=10.0, max_batch_size=20), + ) + daemon.start() + request = ActionRequest( + principal=PrincipalRef(principal_id="agent:restart"), + action_spec=ActionSpec( + action="http.post", + resource="https://api.vendor.com/orders", + intent="create order", + ), + state_evidence=StateEvidence(source="test", state_hash="restart-state"), + verification_evidence=VerificationEvidence(), + ) + decision = sidecar.issue_mandate(request) + assert decision.allowed is True + base_url = f"http://127.0.0.1:{daemon.bound_port}" + pending_before = _fetch_json(f"{base_url}/ledger/flush-queue") + assert len(pending_before.get("items", [])) == 1 + finally: + if daemon is not None: + daemon.stop() + failing_server.shutdown() + failing_server.server_close() + + healthy_server, _ = _start_control_plane_server() + daemon_after_restart: PredicateAuthorityDaemon | None = None + try: + restarted_sidecar = _build_default_sidecar( + mode=AuthorityMode.LOCAL_ONLY, + policy_file=str(policy_file), + credential_store_file=str(tmp_path / "credentials.json"), + control_plane_config=ControlPlaneBootstrapConfig( + enabled=True, + base_url=f"http://127.0.0.1:{healthy_server.server_port}", + tenant_id="tenant-a", + project_id="project-a", + auth_token="token-a", + fail_open=True, + max_retries=0, + ), + local_identity_config=LocalIdentityBootstrapConfig( + enabled=True, + registry_file_path=str(registry_file), + default_ttl_seconds=60, + ), + ) + daemon_after_restart = PredicateAuthorityDaemon( + sidecar=restarted_sidecar, + config=DaemonConfig(host="127.0.0.1", port=0, policy_poll_interval_s=10.0), + flush_worker=FlushWorkerConfig(enabled=False, interval_s=10.0, max_batch_size=20), + ) + daemon_after_restart.start() + base_url = f"http://127.0.0.1:{daemon_after_restart.bound_port}" + flush_result = _post_json(f"{base_url}/ledger/flush-now", {"max_items": 10}) + assert flush_result["ok"] is True + assert int(flush_result["sent_count"]) >= 1 + pending_after = _fetch_json(f"{base_url}/ledger/flush-queue") + assert len(pending_after.get("items", [])) == 0 + finally: + if daemon_after_restart is not None: + daemon_after_restart.stop() + healthy_server.shutdown() + healthy_server.server_close() diff --git a/tests/test_identity_bridge_phase2.py b/tests/test_identity_bridge_phase2.py index fa4d11c..c735283 100644 --- a/tests/test_identity_bridge_phase2.py +++ b/tests/test_identity_bridge_phase2.py @@ -10,9 +10,13 @@ LocalIdPBridgeConfig, OIDCBridgeConfig, OIDCIdentityBridge, + OktaBridgeConfig, + OktaIdentityBridge, ) from predicate_contracts import PrincipalRef, StateEvidence +# pylint: disable=import-error + def test_oidc_bridge_exchange_and_refresh() -> None: bridge = OIDCIdentityBridge( @@ -79,6 +83,23 @@ def test_local_idp_bridge_issues_jwt_like_token() -> None: assert refreshed_payload["token_kind"] == "refresh_access" +def test_okta_bridge_marks_provider() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + token_ttl_seconds=120, + ) + ) + subject = PrincipalRef(principal_id="agent:okta") + state = StateEvidence(source="backend", state_hash="state-okta") + + result = bridge.exchange_token(subject, state) + + assert result.provider.value == "okta" + + def _decode_jwt_payload(payload_segment: str) -> dict[str, object]: # Pad URL-safe base64 to standard length. padding = "=" * (-len(payload_segment) % 4) From ce69bacb1882d95526ece57553ef27dcdcb779bd Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Tue, 17 Feb 2026 20:05:43 -0800 Subject: [PATCH 2/5] wrap up phase 2 hardening --- .github/workflows/phase1-ci-and-release.yml | 7 +- .github/workflows/tests.yml | 7 +- docs/authorityd-operations.md | 82 ++- predicate_authority/__init__.py | 4 + predicate_authority/bridge.py | 295 +++++++++++ predicate_authority/daemon.py | 125 ++++- scripts/check_no_plaintext_okta_secrets.py | 88 ++++ tests/test_daemon_phase2.py | 100 ++++ tests/test_identity_bridge_phase2.py | 552 ++++++++++++++++++++ tests/test_sidecar_phase2.py | 68 +++ 10 files changed, 1324 insertions(+), 4 deletions(-) create mode 100644 scripts/check_no_plaintext_okta_secrets.py diff --git a/.github/workflows/phase1-ci-and-release.yml b/.github/workflows/phase1-ci-and-release.yml index 886e2d5..b2c5ccf 100644 --- a/.github/workflows/phase1-ci-and-release.yml +++ b/.github/workflows/phase1-ci-and-release.yml @@ -27,7 +27,7 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip pre-commit pytest + python -m pip install --upgrade pip pre-commit pytest bandit python -m pip install -e predicate_contracts -e predicate_authority - name: Verify package release order @@ -36,6 +36,11 @@ jobs: - name: Run tests run: python -m pytest -q + - name: Run auth module security checks + run: | + python -m bandit -q -r predicate_authority/bridge.py predicate_authority/daemon.py predicate_authority/control_plane.py + python scripts/check_no_plaintext_okta_secrets.py + - name: Run pre-commit checks run: pre-commit run --all-files diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 25d30f4..111a2c6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,8 +25,13 @@ jobs: - name: Install test dependencies run: | - python -m pip install --upgrade pip pytest + python -m pip install --upgrade pip pytest bandit python -m pip install -e predicate_contracts -e predicate_authority - name: Run tests run: python -m pytest -q + + - name: Run auth module security checks + run: | + python -m bandit -q -r predicate_authority/bridge.py predicate_authority/daemon.py predicate_authority/control_plane.py + python scripts/check_no_plaintext_okta_secrets.py diff --git a/docs/authorityd-operations.md b/docs/authorityd-operations.md index b1e0a76..e9890b0 100644 --- a/docs/authorityd-operations.md +++ b/docs/authorityd-operations.md @@ -94,7 +94,7 @@ Use this section when validating enterprise IdP readiness for Phase 2. - [ ] Validate JWKS retrieval and cache behavior for normal operation. - [ ] Validate key rotation behavior (`kid` rollover) without service restart. - [ ] Validate fail-closed behavior for cold-start JWKS failure and stale key scenarios. -- [ ] Validate redaction: no token/secret leakage in logs on failures/retries. +- [x] Validate redaction: no token/secret leakage in logs on failures/retries. - [x] Validate startup diagnostics for missing/invalid auth configuration. - [ ] Validate revocation path behavior under Okta-backed principals. @@ -116,6 +116,43 @@ Use this section when validating enterprise IdP readiness for Phase 2. | OKTA-12 | Principal/intent revocation during run | Subsequent action denied promptly | | OKTA-13 | Log redaction check | No raw tokens/secrets in logs | +### Emergency JWKS key-rotation runbook (owner + on-call flow) + +Owner model: + +- Primary owner: Platform Identity On-call. +- Secondary owner: Security On-call (approver for forced key disable). +- Incident commander: Platform lead on duty. + +Trigger conditions: + +- compromised signing key suspected, +- unexpected `kid` churn causing authorization failures, +- emergency tenant request to invalidate active key material. + +Runbook steps: + +1. **Declare incident + freeze risky deploys** + - open incident channel and assign owner/approver, + - freeze policy/auth-related deploy pipelines until stabilized. +2. **Rotate signing key in Okta** + - publish new signing key and ensure new `kid` appears in JWKS, + - stop issuing tokens from compromised/old key. +3. **Force validation against refreshed JWKS** + - run targeted validation: + - `python3 -m pytest tests/test_identity_bridge_phase2.py -k "jwks_kid_rollover_refreshes_without_restart"` + - if runtime impact is active, temporarily reduce cache TTL and trigger sidecar restart waves. +4. **Confirm deny behavior for old/unknown `kid`** + - run: + - `python3 -m pytest tests/test_identity_bridge_phase2.py -k "jwks_stale_cache_and_outage_fails_closed_with_diagnostics"` + - verify fail-closed behavior remains active. +5. **Recovery validation** + - confirm healthy authorization path with new `kid`, + - confirm no broad deny regressions in tenant traffic. +6. **Closeout** + - document timeline, affected tenants, and remediation actions, + - restore deploy pipeline and publish post-incident notes. + ### Signoff evidence commands (deterministic integration tests) Run these from `AgentIdentity` repo root and attach output to signoff evidence. @@ -143,6 +180,25 @@ Checkpoints: - post-restart `POST /ledger/flush-now` reports `sent_count >= 1`, - post-flush queue is empty (`GET /ledger/flush-queue` returns no items). +3) Redaction and failure-reason validation: + +```bash +python3 -m pytest tests/test_identity_bridge_phase2.py -k "reasonful_and_redacted" +``` + +Checkpoints: + +- validation error includes a reason category (e.g. issuer mismatch), +- error text does not include raw token string or sensitive claim values. + +### Secret storage policy (Okta credentials) + +- never commit Okta client secrets/API tokens/private keys to repo files, +- store Okta credentials in runtime secret manager and CI secret store only, +- CI enforcement: + - `scripts/check_no_plaintext_okta_secrets.py` scans for plaintext Okta secrets, + - auth module security checks run Bandit for `predicate_authority` auth paths. + When enabled, daemon bootstrap auto-attaches `ControlPlaneTraceEmitter` so each authority decision pushes: @@ -170,9 +226,33 @@ PYTHONPATH=. predicate-authorityd \ --okta-issuer "$OKTA_ISSUER" \ --okta-client-id "$OKTA_CLIENT_ID" \ --okta-audience "$OKTA_AUDIENCE" \ + --okta-required-claims "sub,tenant_id" \ + --okta-required-scopes "authority:check" \ + --okta-required-roles "authority-operator" \ + --okta-allowed-tenants "tenant-a" \ + --idp-token-ttl-s 300 \ + --mandate-ttl-s 300 \ --policy-file examples/authorityd/policy.json ``` +Safety gate note: + +- in `cloud_connected` mode, `identity-mode local` or `identity-mode local-idp` now requires explicit `--allow-local-fallback`, +- this prevents accidental implicit downgrade to local identity behavior. + +TTL alignment note: + +- startup enforces `idp-token-ttl-s >= mandate-ttl-s` to avoid mandates outliving identity session controls. + +### Emergency rollback route (Okta integration) + +If Okta integration causes broad auth failures, use this rollback sequence: + +1. disable the affected Okta app integration for the impacted environment, +2. rotate signing keys and invalidate compromised sessions in Okta, +3. switch sidecar traffic to a known-good identity config (or controlled local fallback with explicit `--allow-local-fallback`), +4. verify deny behavior + recovery through signoff evidence commands before restoring normal traffic. + ## 3b) Optional local identity registry (ephemeral task identities) Enable local identity support: diff --git a/predicate_authority/__init__.py b/predicate_authority/__init__.py index 0f277d1..48d9dfb 100644 --- a/predicate_authority/__init__.py +++ b/predicate_authority/__init__.py @@ -9,7 +9,9 @@ OIDCIdentityBridge, OktaBridgeConfig, OktaIdentityBridge, + OktaTokenClaims, TokenExchangeResult, + TokenValidationError, ) from predicate_authority.client import AuthorityClient, LocalAuthorizationContext from predicate_authority.control_plane import ( @@ -75,6 +77,7 @@ "OIDCIdentityBridge", "OktaBridgeConfig", "OktaIdentityBridge", + "OktaTokenClaims", "OpenTelemetryTraceEmitter", "PolicyEngine", "PolicyFileSource", @@ -89,5 +92,6 @@ "CompositeTraceEmitter", "LedgerQueueItem", "TaskIdentityRecord", + "TokenValidationError", "UsageCreditRecord", ] diff --git a/predicate_authority/bridge.py b/predicate_authority/bridge.py index f93af3b..adf2749 100644 --- a/predicate_authority/bridge.py +++ b/predicate_authority/bridge.py @@ -3,11 +3,13 @@ import base64 import hashlib import hmac +import http.client import json import time from collections.abc import Mapping from dataclasses import dataclass from enum import Enum +from urllib.parse import urlsplit from predicate_contracts import PrincipalRef, StateEvidence @@ -50,6 +52,40 @@ class OktaBridgeConfig: client_id: str audience: str token_ttl_seconds: int = 300 + required_claims: tuple[str, ...] = ("sub",) + allowed_signing_algs: tuple[str, ...] = ("RS256",) + clock_skew_leeway_seconds: int = 30 + tenant_claim: str = "tenant_id" + scope_claim: str = "scope" + role_claim: str = "groups" + allowed_tenants: tuple[str, ...] = () + required_scopes: tuple[str, ...] = () + required_roles: tuple[str, ...] = () + enable_jwks_validation: bool = False + jwks_url: str | None = None + discovery_url: str | None = None + jwks_cache_ttl_seconds: int = 300 + jwks_timeout_s: float = 1.0 + jwks_max_retries: int = 1 + jwks_backoff_initial_s: float = 0.1 + + +@dataclass(frozen=True) +class OktaTokenClaims: + issuer: str + subject: str + audience: tuple[str, ...] + claims: dict[str, object] + + +class TokenValidationError(RuntimeError): + pass + + +@dataclass(frozen=True) +class _JwksCacheState: + expires_at_epoch_s: int + keys_by_kid: dict[str, dict[str, object]] @dataclass(frozen=True) @@ -153,6 +189,16 @@ class OktaIdentityBridge(OIDCIdentityBridge): """ def __init__(self, config: OktaBridgeConfig) -> None: + self._okta_config = config + self._jwks_cache: _JwksCacheState | None = None + if ( + config.enable_jwks_validation + and config.jwks_url is None + and config.discovery_url is None + ): + raise ValueError( + "Okta JWKS validation is enabled but no jwks_url/discovery_url configured." + ) oidc_config = OIDCBridgeConfig( issuer=config.issuer, client_id=config.client_id, @@ -172,6 +218,162 @@ def exchange_token( provider=IdentityProviderType.OKTA, ) + def validate_token_claims(self, token: str, now_epoch_s: int | None = None) -> OktaTokenClaims: + header, payload = _decode_jwt_parts(token) + alg = header.get("alg") + if not isinstance(alg, str) or alg.strip() == "": + raise TokenValidationError("Token header missing required algorithm: alg") + if alg.lower() == "none": + raise TokenValidationError("Token algorithm 'none' is not allowed") + if alg not in self._okta_config.allowed_signing_algs: + raise TokenValidationError("Token algorithm is not in Okta allowlist") + self._validate_jwks_kid(header=header, now_epoch_s=now_epoch_s) + + issuer = payload.get("iss") + if not isinstance(issuer, str) or issuer.strip() == "": + raise TokenValidationError("Token missing required issuer claim: iss") + if issuer != self._okta_config.issuer: + raise TokenValidationError("Token issuer mismatch for configured Okta issuer") + + audience_raw = payload.get("aud") + audiences = _normalize_audience(audience_raw) + if self._okta_config.audience not in audiences: + raise TokenValidationError("Token audience mismatch for configured Okta audience") + + for claim_name in self._okta_config.required_claims: + if claim_name not in payload: + raise TokenValidationError(f"Token missing required claim: {claim_name}") + claim_value = payload.get(claim_name) + if isinstance(claim_value, str) and claim_value.strip() == "": + raise TokenValidationError(f"Token claim is empty: {claim_name}") + if claim_value is None: + raise TokenValidationError(f"Token claim is null: {claim_name}") + + subject = payload.get("sub") + if not isinstance(subject, str) or subject.strip() == "": + raise TokenValidationError("Token missing required subject claim: sub") + self._validate_temporal_claims(payload=payload, now_epoch_s=now_epoch_s) + self._validate_tenant_scope_role_guards(payload=payload) + return OktaTokenClaims( + issuer=issuer, + subject=subject, + audience=audiences, + claims=payload, + ) + + def _validate_temporal_claims( + self, payload: dict[str, object], now_epoch_s: int | None + ) -> None: + now_value = int(time.time()) if now_epoch_s is None else int(now_epoch_s) + leeway = max(0, int(self._okta_config.clock_skew_leeway_seconds)) + + exp = _required_int_claim(payload=payload, claim_name="exp") + iat = _required_int_claim(payload=payload, claim_name="iat") + + nbf_raw = payload.get("nbf") + nbf = _optional_int_claim(claim_name="nbf", claim_value=nbf_raw) + + if exp < now_value - leeway: + raise TokenValidationError("Token is expired (exp outside allowed leeway)") + if nbf is not None and nbf > now_value + leeway: + raise TokenValidationError("Token not yet valid (nbf outside allowed leeway)") + if iat > now_value + leeway: + raise TokenValidationError("Token issued-at time is in the future beyond leeway") + + def _validate_tenant_scope_role_guards(self, payload: dict[str, object]) -> None: + tenant_claim_name = self._okta_config.tenant_claim + tenant_value_raw = payload.get(tenant_claim_name) + tenant_value = tenant_value_raw if isinstance(tenant_value_raw, str) else None + if len(self._okta_config.allowed_tenants) > 0: + if tenant_value is None or tenant_value.strip() == "": + raise TokenValidationError( + f"Token missing tenant claim required for allow-list: {tenant_claim_name}" + ) + if tenant_value not in self._okta_config.allowed_tenants: + raise TokenValidationError("Token tenant is not in configured allow-list") + + scope_values = _normalize_string_collection(payload.get(self._okta_config.scope_claim)) + for required_scope in self._okta_config.required_scopes: + if required_scope not in scope_values: + raise TokenValidationError("Token missing required scope") + + role_values = _normalize_string_collection(payload.get(self._okta_config.role_claim)) + for required_role in self._okta_config.required_roles: + if required_role not in role_values: + raise TokenValidationError("Token missing required role/group") + + def _validate_jwks_kid(self, header: dict[str, object], now_epoch_s: int | None) -> None: + if not self._okta_config.enable_jwks_validation: + return + kid = header.get("kid") + if not isinstance(kid, str) or kid.strip() == "": + raise TokenValidationError("Token header missing required key id: kid") + now_value = int(time.time()) if now_epoch_s is None else int(now_epoch_s) + key = self._get_key_for_kid(kid=kid, now_epoch_s=now_value) + if key is None: + raise TokenValidationError(f"No JWKS key found for kid: {kid}") + + def _get_key_for_kid(self, kid: str, now_epoch_s: int) -> dict[str, object] | None: + cache = self._jwks_cache + cache_fresh = cache is not None and now_epoch_s <= cache.expires_at_epoch_s + if cache_fresh and kid in cache.keys_by_kid: + return cache.keys_by_kid[kid] + + fetched = self._fetch_jwks_keys_with_retry() + self._jwks_cache = _JwksCacheState( + expires_at_epoch_s=now_epoch_s + max(1, int(self._okta_config.jwks_cache_ttl_seconds)), + keys_by_kid=fetched, + ) + return fetched.get(kid) + + def _fetch_jwks_keys_with_retry(self) -> dict[str, dict[str, object]]: + attempts = max(0, int(self._okta_config.jwks_max_retries)) + 1 + last_exc: Exception | None = None + for attempt in range(attempts): + try: + return self._fetch_jwks_keys_once() + except Exception as exc: # noqa: BLE001 + last_exc = exc + if attempt == attempts - 1: + break + sleep_s = float(self._okta_config.jwks_backoff_initial_s) * (2**attempt) + time.sleep(max(0.0, sleep_s)) + raise TokenValidationError( + "JWKS fetch failed after retries " + f"(attempts={attempts}, timeout_s={self._okta_config.jwks_timeout_s})" + ) from last_exc + + def _fetch_jwks_keys_once(self) -> dict[str, dict[str, object]]: + jwks_url = self._resolve_jwks_url() + payload = _http_get_json(url=jwks_url, timeout_s=float(self._okta_config.jwks_timeout_s)) + keys_raw = payload.get("keys") + if not isinstance(keys_raw, list): + raise TokenValidationError("JWKS response missing keys array") + keys_by_kid: dict[str, dict[str, object]] = {} + for item in keys_raw: + if not isinstance(item, dict): + continue + kid = item.get("kid") + if isinstance(kid, str) and kid != "": + keys_by_kid[kid] = item + if len(keys_by_kid) == 0: + raise TokenValidationError("JWKS response contains no usable kid entries") + return keys_by_kid + + def _resolve_jwks_url(self) -> str: + if self._okta_config.jwks_url is not None and self._okta_config.jwks_url.strip() != "": + return self._okta_config.jwks_url + discovery_url = self._okta_config.discovery_url + if discovery_url is None or discovery_url.strip() == "": + raise TokenValidationError("No JWKS endpoint configured") + payload = _http_get_json( + url=discovery_url, timeout_s=float(self._okta_config.jwks_timeout_s) + ) + jwks_uri = payload.get("jwks_uri") + if not isinstance(jwks_uri, str) or jwks_uri.strip() == "": + raise TokenValidationError("OIDC discovery response missing jwks_uri") + return jwks_uri + class LocalIdPBridge: """Local IdP emulator for dev/offline/air-gapped workflows.""" @@ -252,3 +454,96 @@ def _mint_token( def _b64url_json(value: Mapping[str, str | int | None]) -> str: encoded = json.dumps(value, separators=(",", ":")).encode("utf-8") return base64.urlsafe_b64encode(encoded).rstrip(b"=").decode("utf-8") + + +def _decode_jwt_payload(token: str) -> dict[str, object]: + _, payload = _decode_jwt_parts(token) + return payload + + +def _decode_jwt_parts(token: str) -> tuple[dict[str, object], dict[str, object]]: + segments = token.split(".") + if len(segments) != 3: + raise TokenValidationError("Token must be a 3-segment JWT") + header = _decode_jwt_segment(segments[0]) + payload_segment = segments[1] + payload = _decode_jwt_segment(payload_segment) + return header, payload + + +def _decode_jwt_segment(segment: str) -> dict[str, object]: + padding = "=" * (-len(segment) % 4) + try: + decoded = base64.urlsafe_b64decode((segment + padding).encode("utf-8")) + loaded = json.loads(decoded.decode("utf-8")) + except Exception as exc: # noqa: BLE001 + raise TokenValidationError("Token segment is not valid base64url JSON") from exc + if not isinstance(loaded, dict): + raise TokenValidationError("Token segment must be a JSON object") + return loaded + + +def _normalize_audience(audience_raw: object) -> tuple[str, ...]: + if isinstance(audience_raw, str): + if audience_raw.strip() == "": + return () + return (audience_raw,) + if isinstance(audience_raw, list): + normalized = tuple(item for item in audience_raw if isinstance(item, str) and item != "") + return normalized + return () + + +def _required_int_claim(payload: dict[str, object], claim_name: str) -> int: + value = payload.get(claim_name) + if not isinstance(value, int): + raise TokenValidationError(f"Token missing required numeric claim: {claim_name}") + return int(value) + + +def _optional_int_claim(claim_name: str, claim_value: object) -> int | None: + if claim_value is None: + return None + if not isinstance(claim_value, int): + raise TokenValidationError(f"Token claim must be numeric when present: {claim_name}") + return int(claim_value) + + +def _normalize_string_collection(value: object) -> tuple[str, ...]: + if isinstance(value, str): + if value.strip() == "": + return () + return tuple(item for item in value.split() if item != "") + if isinstance(value, list): + return tuple(item for item in value if isinstance(item, str) and item != "") + return () + + +def _http_get_json(url: str, timeout_s: float) -> dict[str, object]: + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or parsed.netloc == "": + raise TokenValidationError("JWKS URL must be a valid http/https URL") + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + if parsed.scheme == "https": + connection: http.client.HTTPConnection = http.client.HTTPSConnection( + parsed.netloc, timeout=timeout_s + ) + else: + connection = http.client.HTTPConnection(parsed.netloc, timeout=timeout_s) + try: + connection.request("GET", path) + response = connection.getresponse() + body = response.read().decode("utf-8") + finally: + connection.close() + if response.status >= 400: + raise TokenValidationError(f"JWKS HTTP error: {response.status}") + try: + loaded = json.loads(body) + except json.JSONDecodeError as exc: + raise TokenValidationError("JWKS response is not valid JSON") from exc + if not isinstance(loaded, dict): + raise TokenValidationError("JWKS response must be a JSON object") + return loaded diff --git a/predicate_authority/daemon.py b/predicate_authority/daemon.py index 83cb28e..c0ebd45 100644 --- a/predicate_authority/daemon.py +++ b/predicate_authority/daemon.py @@ -615,6 +615,7 @@ def _build_default_sidecar( local_identity_config: LocalIdentityBootstrapConfig | None = None, identity_bridge: ExchangeTokenBridge | None = None, mandate_signing_key: str | None = None, + mandate_ttl_seconds: int = 300, ) -> PredicateAuthoritySidecar: policy_rules: tuple[PolicyRule, ...] = () global_max_delegation_depth: int | None = None @@ -670,7 +671,10 @@ def _build_default_sidecar( guard = ActionGuard( policy_engine=policy_engine, - mandate_signer=LocalMandateSigner(secret_key=mandate_signing_key or secrets.token_hex(32)), + mandate_signer=LocalMandateSigner( + secret_key=mandate_signing_key or secrets.token_hex(32), + ttl_seconds=max(1, int(mandate_ttl_seconds)), + ), proof_ledger=proof_ledger, ) return PredicateAuthoritySidecar( @@ -687,6 +691,14 @@ def _build_default_sidecar( def _build_identity_bridge_from_args(args: argparse.Namespace) -> ExchangeTokenBridge: mode = str(args.identity_mode) + authority_mode = str(getattr(args, "mode", AuthorityMode.LOCAL_ONLY.value)) + allow_local_fallback = bool(getattr(args, "allow_local_fallback", False)) + if authority_mode == AuthorityMode.CLOUD_CONNECTED.value and mode in {"local", "local-idp"}: + if not allow_local_fallback: + raise SystemExit( + "cloud_connected mode with local/local-idp identity requires explicit " + "--allow-local-fallback acknowledgement." + ) if mode == "local": return IdentityBridge(token_ttl_seconds=int(args.idp_token_ttl_s)) if mode == "local-idp": @@ -734,17 +746,70 @@ def _build_identity_bridge_from_args(args: argparse.Namespace) -> ExchangeTokenB raise SystemExit( "identity-mode=okta requires --okta-issuer, --okta-client-id, and --okta-audience." ) + okta_required_claims = _parse_multi_string_values( + getattr(args, "okta_required_claims", None) + ) + okta_required_scopes = _parse_multi_string_values( + getattr(args, "okta_required_scopes", None) + ) + okta_required_roles = _parse_multi_string_values(getattr(args, "okta_required_roles", None)) + okta_allowed_tenants = _parse_multi_string_values( + getattr(args, "okta_allowed_tenants", None) + ) return OktaIdentityBridge( OktaBridgeConfig( issuer=str(args.okta_issuer), client_id=str(args.okta_client_id), audience=str(args.okta_audience), token_ttl_seconds=int(args.idp_token_ttl_s), + required_claims=( + tuple(okta_required_claims) if len(okta_required_claims) > 0 else ("sub",) + ), + tenant_claim=str(getattr(args, "okta_tenant_claim", "tenant_id")), + scope_claim=str(getattr(args, "okta_scope_claim", "scope")), + role_claim=str(getattr(args, "okta_role_claim", "groups")), + allowed_tenants=tuple(okta_allowed_tenants), + required_scopes=tuple(okta_required_scopes), + required_roles=tuple(okta_required_roles), ) ) raise SystemExit(f"Unsupported identity mode: {mode}") +def _parse_multi_string_values(raw_values: object) -> list[str]: + if raw_values is None: + return [] + if isinstance(raw_values, str): + values = [item.strip() for item in raw_values.split(",")] + return [item for item in values if item != ""] + if isinstance(raw_values, list): + parsed: list[str] = [] + for item in raw_values: + if not isinstance(item, str): + continue + parts = [part.strip() for part in item.split(",")] + parsed.extend(part for part in parts if part != "") + # Keep insertion order while deduplicating. + deduped: list[str] = [] + seen: set[str] = set() + for item in parsed: + if item in seen: + continue + seen.add(item) + deduped.append(item) + return deduped + return [] + + +def _validate_ttl_alignment(idp_token_ttl_s: int, mandate_ttl_s: int) -> None: + if int(idp_token_ttl_s) <= 0 or int(mandate_ttl_s) <= 0: + raise SystemExit("--idp-token-ttl-s and --mandate-ttl-s must be > 0.") + if int(idp_token_ttl_s) < int(mandate_ttl_s): + raise SystemExit( + "idp token ttl must be >= mandate ttl to avoid mandate outliving identity session." + ) + + def _resolve_mandate_signing_key( signing_key_file: str | None, signing_key_env: str, @@ -792,7 +857,21 @@ def main() -> None: default="local", help="Identity source for token exchange: local, local-idp, oidc, entra, or okta.", ) + parser.add_argument( + "--allow-local-fallback", + action="store_true", + help=( + "Explicitly allow local/local-idp identity while mode=cloud_connected. " + "Without this flag, implicit local fallback is denied." + ), + ) parser.add_argument("--idp-token-ttl-s", type=int, default=300) + parser.add_argument( + "--mandate-ttl-s", + type=int, + default=int(os.getenv("PREDICATE_AUTHORITY_MANDATE_TTL_SECONDS", "300")), + help="Mandate token TTL seconds; should be aligned to IdP token/session strategy.", + ) parser.add_argument( "--local-idp-issuer", default=os.getenv("LOCAL_IDP_ISSUER", "http://localhost/predicate-local-idp"), @@ -815,6 +894,45 @@ def main() -> None: parser.add_argument("--okta-issuer", default=os.getenv("OKTA_ISSUER")) parser.add_argument("--okta-client-id", default=os.getenv("OKTA_CLIENT_ID")) parser.add_argument("--okta-audience", default=os.getenv("OKTA_AUDIENCE")) + parser.add_argument( + "--okta-required-claims", + action="append", + default=[], + help="Comma-separated required Okta claims. Can be repeated.", + ) + parser.add_argument( + "--okta-allowed-tenants", + action="append", + default=[], + help="Comma-separated allowed tenant identifiers. Can be repeated.", + ) + parser.add_argument( + "--okta-required-scopes", + action="append", + default=[], + help="Comma-separated required scope values. Can be repeated.", + ) + parser.add_argument( + "--okta-required-roles", + action="append", + default=[], + help="Comma-separated required role/group values. Can be repeated.", + ) + parser.add_argument( + "--okta-tenant-claim", + default=os.getenv("OKTA_TENANT_CLAIM", "tenant_id"), + help="Claim name carrying tenant identifier.", + ) + parser.add_argument( + "--okta-scope-claim", + default=os.getenv("OKTA_SCOPE_CLAIM", "scope"), + help="Claim name carrying scopes.", + ) + parser.add_argument( + "--okta-role-claim", + default=os.getenv("OKTA_ROLE_CLAIM", "groups"), + help="Claim name carrying roles/groups.", + ) parser.add_argument( "--control-plane-enabled", action="store_true", @@ -893,6 +1011,10 @@ def main() -> None: "CONTROL_PLANE_PROJECT_ID", "dev-project" ) control_plane_enabled = bool(args.control_plane_enabled) + _validate_ttl_alignment( + idp_token_ttl_s=int(args.idp_token_ttl_s), + mandate_ttl_s=int(args.mandate_ttl_s), + ) if control_plane_enabled and (control_plane_url is None or control_plane_url.strip() == ""): raise SystemExit( "control-plane is enabled but no URL provided. " @@ -928,6 +1050,7 @@ def main() -> None: local_identity_config=local_identity_bootstrap, identity_bridge=identity_bridge, mandate_signing_key=mandate_signing_key, + mandate_ttl_seconds=int(args.mandate_ttl_s), ) daemon = PredicateAuthorityDaemon( sidecar=sidecar, diff --git a/scripts/check_no_plaintext_okta_secrets.py b/scripts/check_no_plaintext_okta_secrets.py new file mode 100644 index 0000000..b115630 --- /dev/null +++ b/scripts/check_no_plaintext_okta_secrets.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +TEXT_SUFFIX_ALLOWLIST = { + ".py", + ".md", + ".txt", + ".yml", + ".yaml", + ".json", + ".toml", + ".ini", + ".cfg", + ".env", + ".example", +} + +EXCLUDED_DIRS = { + ".git", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".cursor", +} + +SECRET_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"(?i)\bOKTA_CLIENT_SECRET\s*=\s*(?![\"']?<)(?![\"']?your-)[\"']?[^\"'\s]+"), + re.compile(r"(?i)\bOKTA_API_TOKEN\s*=\s*(?![\"']?<)(?![\"']?your-)[\"']?[^\"'\s]+"), + re.compile(r"(?i)\bOKTA_PRIVATE_KEY\s*=\s*(?![\"']?<)(?![\"']?your-)[\"']?[^\"'\s]+"), +) + + +def _should_scan(path: Path) -> bool: + if any(part in EXCLUDED_DIRS for part in path.parts): + return False + if path.name.startswith(".") and path.suffix == "": + return False + if path.suffix in TEXT_SUFFIX_ALLOWLIST: + return True + # Include common dotfiles without suffix. + if path.name in {".env", ".env.example", ".gitignore"}: + return True + return False + + +def _iter_text_files(root: Path) -> list[Path]: + paths: list[Path] = [] + for path in root.rglob("*"): + if not path.is_file(): + continue + if _should_scan(path): + paths.append(path) + return paths + + +def main() -> int: + violations: list[str] = [] + for file_path in _iter_text_files(REPO_ROOT): + try: + content = file_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for idx, line in enumerate(content.splitlines(), start=1): + if "OKTA_" not in line.upper(): + continue + for pattern in SECRET_PATTERNS: + if pattern.search(line): + rel = file_path.relative_to(REPO_ROOT) + violations.append(f"{rel}:{idx}: potential plaintext Okta secret") + break + if violations: + print("Found potential plaintext Okta secrets:") + for item in violations: + print(f" - {item}") + return 1 + print("No plaintext Okta secrets detected.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_daemon_phase2.py b/tests/test_daemon_phase2.py index a45d3cc..a92c02a 100644 --- a/tests/test_daemon_phase2.py +++ b/tests/test_daemon_phase2.py @@ -34,6 +34,7 @@ LocalIdentityBootstrapConfig, _build_default_sidecar, _build_identity_bridge_from_args, + _validate_ttl_alignment, ) from predicate_contracts import ( ActionRequest, @@ -440,7 +441,9 @@ def test_daemon_identity_mode_local_idp_builder() -> None: def test_daemon_identity_mode_okta_builder() -> None: args = Namespace( + mode="local_only", identity_mode="okta", + allow_local_fallback=False, idp_token_ttl_s=120, local_idp_issuer="http://localhost/local-idp", local_idp_audience="api://predicate-authority", @@ -463,9 +466,56 @@ def test_daemon_identity_mode_okta_builder() -> None: assert token.provider.value == "okta" +def test_daemon_identity_mode_okta_builder_maps_claim_scope_role_config() -> None: + args = Namespace( + mode="local_only", + identity_mode="okta", + allow_local_fallback=False, + idp_token_ttl_s=300, + local_idp_issuer="http://localhost/local-idp", + local_idp_audience="api://predicate-authority", + local_idp_signing_key_env="LOCAL_IDP_SIGNING_KEY", + oidc_issuer=None, + oidc_client_id=None, + oidc_audience=None, + entra_tenant_id=None, + entra_client_id=None, + entra_audience=None, + okta_issuer="https://dev-123456.okta.com/oauth2/default", + okta_client_id="okta-client-id", + okta_audience="api://predicate-authority", + okta_required_claims=["sub,tenant_id"], + okta_allowed_tenants=["tenant-a"], + okta_required_scopes=["authority:check"], + okta_required_roles=["authority-operator"], + okta_tenant_claim="tenant_id", + okta_scope_claim="scope", + okta_role_claim="groups", + ) + bridge = _build_identity_bridge_from_args(args) + token = ( + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3Qta2lkIn0." + "eyJpc3MiOiJodHRwczovL2Rldi0xMjM0NTYub2t0YS5jb20vb2F1dGgyL2RlZmF1bHQiLCJhdWQiOiJhcGk6Ly9wcmVkaWNhdGUtYXV0aG9yaXR5Iiwic3ViIjoiYWdlbnQ6dGVzdCIsInRlbmFudF9pZCI6InRlbmFudC1hIiwic2NvcGUiOiJhdXRob3JpdHk6Y2hlY2siLCJncm91cHMiOlsiYXV0aG9yaXR5LW9wZXJhdG9yIl0sImV4cCI6MTEwMCwiaWF0IjoxMDAwfQ." + "eyJzaWciOiJ0ZXN0In0" + ) + assert hasattr(bridge, "validate_token_claims") + bridge.validate_token_claims(token, now_epoch_s=1000) # type: ignore[attr-defined] + + +def test_validate_ttl_alignment_rejects_idp_shorter_than_mandate() -> None: + with pytest.raises(SystemExit): + _validate_ttl_alignment(idp_token_ttl_s=120, mandate_ttl_s=300) + + +def test_validate_ttl_alignment_accepts_aligned_values() -> None: + _validate_ttl_alignment(idp_token_ttl_s=300, mandate_ttl_s=300) + + def test_daemon_identity_mode_okta_requires_args() -> None: args = Namespace( + mode="local_only", identity_mode="okta", + allow_local_fallback=False, idp_token_ttl_s=120, local_idp_issuer="http://localhost/local-idp", local_idp_audience="api://predicate-authority", @@ -484,6 +534,56 @@ def test_daemon_identity_mode_okta_requires_args() -> None: _build_identity_bridge_from_args(args) +def test_daemon_cloud_connected_local_identity_requires_explicit_fallback() -> None: + args = Namespace( + mode="cloud_connected", + identity_mode="local", + allow_local_fallback=False, + idp_token_ttl_s=120, + local_idp_issuer="http://localhost/local-idp", + local_idp_audience="api://predicate-authority", + local_idp_signing_key_env="LOCAL_IDP_SIGNING_KEY", + oidc_issuer=None, + oidc_client_id=None, + oidc_audience=None, + entra_tenant_id=None, + entra_client_id=None, + entra_audience=None, + okta_issuer=None, + okta_client_id=None, + okta_audience=None, + ) + with pytest.raises(SystemExit): + _build_identity_bridge_from_args(args) + + +def test_daemon_cloud_connected_local_identity_allows_with_explicit_fallback() -> None: + args = Namespace( + mode="cloud_connected", + identity_mode="local", + allow_local_fallback=True, + idp_token_ttl_s=120, + local_idp_issuer="http://localhost/local-idp", + local_idp_audience="api://predicate-authority", + local_idp_signing_key_env="LOCAL_IDP_SIGNING_KEY", + oidc_issuer=None, + oidc_client_id=None, + oidc_audience=None, + entra_tenant_id=None, + entra_client_id=None, + entra_audience=None, + okta_issuer=None, + okta_client_id=None, + okta_audience=None, + ) + bridge = _build_identity_bridge_from_args(args) + token = bridge.exchange_token( + PrincipalRef(principal_id="agent:test"), + StateEvidence(source="test", state_hash="state-1"), + ) + assert token.provider.value == "local" + + def test_daemon_local_identity_registry_endpoints(tmp_path: Path) -> None: policy_file = tmp_path / "policy.json" policy_file.write_text(json.dumps({"rules": []}), encoding="utf-8") diff --git a/tests/test_identity_bridge_phase2.py b/tests/test_identity_bridge_phase2.py index c735283..704f6e3 100644 --- a/tests/test_identity_bridge_phase2.py +++ b/tests/test_identity_bridge_phase2.py @@ -2,6 +2,11 @@ import base64 import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import pytest from predicate_authority import ( EntraBridgeConfig, @@ -12,6 +17,7 @@ OIDCIdentityBridge, OktaBridgeConfig, OktaIdentityBridge, + TokenValidationError, ) from predicate_contracts import PrincipalRef, StateEvidence @@ -100,6 +106,539 @@ def test_okta_bridge_marks_provider() -> None: assert result.provider.value == "okta" +def test_okta_bridge_validates_issuer_audience_and_required_claims() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + required_claims=("sub", "tenant_id"), + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "tenant_id": "tenant-a", + "exp": 1100, + "iat": 1000, + "nbf": 995, + } + ) + claims = bridge.validate_token_claims(token, now_epoch_s=1000) + assert claims.subject == "agent:okta" + assert "api://predicate-authority" in claims.audience + + +def test_okta_bridge_fails_closed_on_issuer_mismatch() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://evil.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_on_audience_mismatch() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://wrong-audience", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_on_missing_required_claims() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + required_claims=("sub", "tenant_id"), + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_on_algorithm_none() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("RS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + }, + alg="none", + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_on_algorithm_not_allowlisted() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("RS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + }, + alg="HS256", + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_on_expired_token() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 900, + "iat": 800, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_when_nbf_is_too_far_in_future() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + "nbf": 1010, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_when_iat_is_too_far_in_future() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1010, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_enforces_tenant_scope_and_role_guards() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + allowed_tenants=("tenant-a",), + required_scopes=("authority:check",), + required_roles=("authority-operator",), + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "tenant_id": "tenant-a", + "scope": "authority:check authority:read", + "groups": ["authority-operator", "auditor"], + "exp": 1100, + "iat": 1000, + } + ) + claims = bridge.validate_token_claims(token, now_epoch_s=1000) + assert claims.claims["tenant_id"] == "tenant-a" + + +def test_okta_bridge_fails_closed_when_tenant_not_allowed() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + allowed_tenants=("tenant-a",), + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "tenant_id": "tenant-z", + "exp": 1100, + "iat": 1000, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_when_required_scope_missing() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + required_scopes=("authority:check",), + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "scope": "authority:read", + "exp": 1100, + "iat": 1000, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +def test_okta_bridge_fails_closed_when_required_role_missing() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + required_roles=("authority-operator",), + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "groups": ["auditor"], + "exp": 1100, + "iat": 1000, + } + ) + with pytest.raises(TokenValidationError): + bridge.validate_token_claims(token, now_epoch_s=1000) + + +class _OktaJwksHandler(BaseHTTPRequestHandler): + jwks_keys: list[dict[str, object]] = [{"kid": "kid-1", "kty": "RSA"}] + fail_mode: bool = False + hits_discovery: int = 0 + hits_jwks: int = 0 + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/.well-known/openid-configuration": + self.__class__.hits_discovery += 1 + self._send_json(200, {"jwks_uri": f"http://127.0.0.1:{self.server.server_port}/jwks"}) + return + if self.path == "/jwks": + self.__class__.hits_jwks += 1 + if self.__class__.fail_mode: + self._send_json(503, {"error": "unavailable"}) + return + self._send_json(200, {"keys": self.__class__.jwks_keys}) + return + self._send_json(404, {"error": "not_found"}) + + def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 + _ = fmt + return + + def _send_json(self, status: int, payload: dict[str, object]) -> None: + encoded = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +def _start_okta_jwks_server() -> tuple[ThreadingHTTPServer, threading.Thread]: + _OktaJwksHandler.jwks_keys = [{"kid": "kid-1", "kty": "RSA"}] + _OktaJwksHandler.fail_mode = False + _OktaJwksHandler.hits_discovery = 0 + _OktaJwksHandler.hits_jwks = 0 + server = ThreadingHTTPServer(("127.0.0.1", 0), _OktaJwksHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + +def test_okta_bridge_jwks_discovery_fetch_and_cache() -> None: + server, _ = _start_okta_jwks_server() + try: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("RS256",), + clock_skew_leeway_seconds=5, + enable_jwks_validation=True, + discovery_url=( + f"http://127.0.0.1:{server.server_port}/.well-known/openid-configuration" + ), + jwks_cache_ttl_seconds=60, + ) + ) + token = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + }, + alg="RS256", + kid="kid-1", + ) + + bridge.validate_token_claims(token, now_epoch_s=1000) + bridge.validate_token_claims(token, now_epoch_s=1001) + + assert _OktaJwksHandler.hits_discovery == 1 + assert _OktaJwksHandler.hits_jwks == 1 + finally: + server.shutdown() + server.server_close() + + +def test_okta_bridge_jwks_kid_rollover_refreshes_without_restart() -> None: + server, _ = _start_okta_jwks_server() + try: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("RS256",), + clock_skew_leeway_seconds=5, + enable_jwks_validation=True, + jwks_url=f"http://127.0.0.1:{server.server_port}/jwks", + jwks_cache_ttl_seconds=60, + ) + ) + token_k1 = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + }, + alg="RS256", + kid="kid-1", + ) + bridge.validate_token_claims(token_k1, now_epoch_s=1000) + + _OktaJwksHandler.jwks_keys = [{"kid": "kid-2", "kty": "RSA"}] + token_k2 = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1101, + "iat": 1001, + }, + alg="RS256", + kid="kid-2", + ) + bridge.validate_token_claims(token_k2, now_epoch_s=1001) + assert _OktaJwksHandler.hits_jwks == 2 + finally: + server.shutdown() + server.server_close() + + +def test_okta_bridge_jwks_stale_cache_and_outage_fails_closed_with_diagnostics() -> None: + server, _ = _start_okta_jwks_server() + try: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("RS256",), + clock_skew_leeway_seconds=5, + enable_jwks_validation=True, + jwks_url=f"http://127.0.0.1:{server.server_port}/jwks", + jwks_cache_ttl_seconds=1, + jwks_timeout_s=0.5, + jwks_max_retries=2, + jwks_backoff_initial_s=0.0, + ) + ) + token_k1 = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1100, + "iat": 1000, + }, + alg="RS256", + kid="kid-1", + ) + bridge.validate_token_claims(token_k1, now_epoch_s=1000) + + _OktaJwksHandler.fail_mode = True + token_k2 = _build_test_jwt( + { + "iss": "https://dev-123456.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "agent:okta", + "exp": 1102, + "iat": 1002, + }, + alg="RS256", + kid="kid-2", + ) + with pytest.raises(TokenValidationError) as exc: + bridge.validate_token_claims(token_k2, now_epoch_s=1002) + assert "JWKS fetch failed after retries" in str(exc.value) + assert "attempts=3" in str(exc.value) + assert "timeout_s=0.5" in str(exc.value) + finally: + server.shutdown() + server.server_close() + + +def test_okta_validation_error_is_reasonful_and_redacted() -> None: + bridge = OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + clock_skew_leeway_seconds=5, + ) + ) + token = _build_test_jwt( + { + "iss": "https://evil.okta.com/oauth2/default", + "aud": "api://predicate-authority", + "sub": "secret-principal-token-material", + "exp": 1100, + "iat": 1000, + }, + alg="HS256", + kid="secret-kid-material", + ) + with pytest.raises(TokenValidationError) as exc: + bridge.validate_token_claims(token, now_epoch_s=1000) + message = str(exc.value) + assert "issuer mismatch" in message.lower() + # Ensure we do not leak raw token/claim values in validation errors. + assert token not in message + assert "secret-principal-token-material" not in message + assert "secret-kid-material" not in message + + def _decode_jwt_payload(payload_segment: str) -> dict[str, object]: # Pad URL-safe base64 to standard length. padding = "=" * (-len(payload_segment) % 4) @@ -107,3 +646,16 @@ def _decode_jwt_payload(payload_segment: str) -> dict[str, object]: loaded = json.loads(decoded.decode("utf-8")) assert isinstance(loaded, dict) return loaded + + +def _build_test_jwt(payload: dict[str, object], alg: str = "HS256", kid: str = "test-kid") -> str: + header = {"alg": alg, "typ": "JWT", "kid": kid} + header_segment = _encode_json_segment(header) + payload_segment = _encode_json_segment(payload) + signature_segment = _encode_json_segment({"sig": "test"}) + return f"{header_segment}.{payload_segment}.{signature_segment}" + + +def _encode_json_segment(value: dict[str, object]) -> str: + encoded = json.dumps(value, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(encoded).rstrip(b"=").decode("utf-8") diff --git a/tests/test_sidecar_phase2.py b/tests/test_sidecar_phase2.py index 6b922e6..aedcb7a 100644 --- a/tests/test_sidecar_phase2.py +++ b/tests/test_sidecar_phase2.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import time from pathlib import Path @@ -14,6 +15,8 @@ LocalRevocationCache, OIDCBridgeConfig, OIDCIdentityBridge, + OktaBridgeConfig, + OktaIdentityBridge, PolicyEngine, PredicateAuthoritySidecar, SidecarConfig, @@ -138,3 +141,68 @@ def test_sidecar_revocation_and_policy_hot_reload(tmp_path: Path) -> None: assert allowed.allowed is True assert revoked.allowed is False assert revoked.reason == AuthorizationReason.INVALID_MANDATE + + +def test_sidecar_okta_identity_revocation_and_killswitch_flow(tmp_path: Path) -> None: + policy_engine = PolicyEngine( + rules=( + PolicyRule( + name="allow-orders", + effect=PolicyEffect.ALLOW, + principals=("agent:*",), + actions=("http.*",), + resources=("https://api.vendor.com/*",), + ), + ) + ) + proof_ledger = InMemoryProofLedger() + sidecar = PredicateAuthoritySidecar( + config=SidecarConfig(mode=AuthorityMode.LOCAL_ONLY), + action_guard=_guard(policy_engine, proof_ledger), + proof_ledger=proof_ledger, + identity_bridge=OktaIdentityBridge( + OktaBridgeConfig( + issuer="https://dev-123456.okta.com/oauth2/default", + client_id="okta-client-id", + audience="api://predicate-authority", + allowed_signing_algs=("HS256",), + ) + ), + credential_store=LocalCredentialStore(str(tmp_path / "credentials.json")), + revocation_cache=LocalRevocationCache(), + policy_engine=policy_engine, + ) + request = ActionRequest( + principal=PrincipalRef(principal_id="agent:okta-user-1"), + action_spec=ActionSpec( + action="http.post", + resource="https://api.vendor.com/orders", + intent="create order", + ), + state_evidence=StateEvidence(source="backend", state_hash="state-abc"), + verification_evidence=VerificationEvidence(), + ) + + allowed = sidecar.issue_mandate(request) + assert allowed.allowed is True + sidecar.revoke_by_invariant("agent:okta-user-1") + denied_principal = sidecar.issue_mandate(request) + assert denied_principal.allowed is False + assert denied_principal.reason == AuthorizationReason.INVALID_MANDATE + + # Intent-level kill-switch should also deny when principal is otherwise allowed. + request_for_killswitch = ActionRequest( + principal=PrincipalRef(principal_id="agent:okta-user-2"), + action_spec=request.action_spec, + state_evidence=request.state_evidence, + verification_evidence=request.verification_evidence, + ) + pre_killswitch = sidecar.issue_mandate(request_for_killswitch) + assert pre_killswitch.allowed is True + intent_hash = hashlib.sha256( + request_for_killswitch.action_spec.intent.encode("utf-8") + ).hexdigest() + sidecar.revoke_intent_hash(intent_hash) + denied_intent = sidecar.issue_mandate(request_for_killswitch) + assert denied_intent.allowed is False + assert denied_intent.reason == AuthorizationReason.INVALID_MANDATE From e00d70ed77019de0716ab4da6c582072ac08e0d8 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Tue, 17 Feb 2026 20:33:22 -0800 Subject: [PATCH 3/5] test okta delegation --- .env.example | 14 ++ docs/authorityd-operations.md | 39 +++++ docs/predicate-authority-user-manual.md | 59 +++++++ examples/delegation/okta_obo_compat_demo.py | 93 ++++++++++ predicate_authority/__init__.py | 12 ++ predicate_authority/okta_compat.py | 157 +++++++++++++++++ tests/test_okta_obo_compatibility.py | 185 ++++++++++++++++++++ 7 files changed, 559 insertions(+) create mode 100644 .env.example create mode 100644 examples/delegation/okta_obo_compat_demo.py create mode 100644 predicate_authority/okta_compat.py create mode 100644 tests/test_okta_obo_compatibility.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c4db69 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# AgentIdentity Okta compatibility checks (examples/tests) + +# Core Okta app settings used by OBO compatibility demo/test. +OKTA_ISSUER=https:///oauth2/default +OKTA_CLIENT_ID= +OKTA_CLIENT_SECRET= +OKTA_AUDIENCE=api://predicate-authority +OKTA_SCOPE=authority:check + +# Enable live compatibility check test (disabled by default). +OKTA_OBO_COMPAT_CHECK_ENABLED=0 + +# Set to 1/true only if your Okta tenant supports token exchange/OBO. +OKTA_SUPPORTS_TOKEN_EXCHANGE=0 diff --git a/docs/authorityd-operations.md b/docs/authorityd-operations.md index e9890b0..4e8b174 100644 --- a/docs/authorityd-operations.md +++ b/docs/authorityd-operations.md @@ -191,6 +191,45 @@ Checkpoints: - validation error includes a reason category (e.g. issuer mismatch), - error text does not include raw token string or sensitive claim values. +4) Okta token exchange/OBO compatibility (tenant capability-gated): + +```bash +# If tenant supports token exchange: +export OKTA_OBO_COMPAT_CHECK_ENABLED=1 +export OKTA_SUPPORTS_TOKEN_EXCHANGE=true +python3 -m pytest tests/test_okta_obo_compatibility.py -k "live_check_when_enabled" + +# If tenant does NOT support token exchange: +export OKTA_OBO_COMPAT_CHECK_ENABLED=1 +export OKTA_SUPPORTS_TOKEN_EXCHANGE=false +python3 -m pytest tests/test_okta_obo_compatibility.py -k "live_check_when_enabled" +``` + +Checkpoints: + +- `client_credentials_ok` must pass in both modes, +- when `OKTA_SUPPORTS_TOKEN_EXCHANGE=true`, token exchange must succeed, +- when `OKTA_SUPPORTS_TOKEN_EXCHANGE=false`, token exchange path is explicitly gated as tenant-disabled (no false failure). + +### Example demo script: Okta delegation compatibility + +Run example from repo root: + +```bash +python3 examples/delegation/okta_obo_compat_demo.py \ + --issuer "$OKTA_ISSUER" \ + --client-id "$OKTA_CLIENT_ID" \ + --client-secret "$OKTA_CLIENT_SECRET" \ + --audience "$OKTA_AUDIENCE" \ + --scope "${OKTA_SCOPE:-authority:check}" \ + --supports-token-exchange +``` + +Notes: + +- omit `--supports-token-exchange` for tenants that do not support OBO/token exchange, +- script reports whether delegation path should use IdP token exchange or authority mandate delegation. + ### Secret storage policy (Okta credentials) - never commit Okta client secrets/API tokens/private keys to repo files, diff --git a/docs/predicate-authority-user-manual.md b/docs/predicate-authority-user-manual.md index b332f64..b33eec1 100644 --- a/docs/predicate-authority-user-manual.md +++ b/docs/predicate-authority-user-manual.md @@ -219,6 +219,65 @@ predicate-authorityd \ --- +## Okta delegation compatibility check (capability-gated) + +Use this when you want to verify whether your Okta tenant can do IdP token +exchange/OBO for delegation, or if you should use authority mandate delegation +as the fallback path. + +### 1) Set environment variables + +```bash +cp .env.example .env + +export OKTA_ISSUER="https://.okta.com/oauth2/default" +export OKTA_CLIENT_ID="" +export OKTA_CLIENT_SECRET="" +export OKTA_AUDIENCE="api://predicate-authority" +export OKTA_SCOPE="authority:check" +``` + +### 2) Run compatibility test (live check is opt-in) + +```bash +# Tenant supports token exchange/OBO +export OKTA_OBO_COMPAT_CHECK_ENABLED=1 +export OKTA_SUPPORTS_TOKEN_EXCHANGE=true +python -m pytest tests/test_okta_obo_compatibility.py -k "live_check_when_enabled" + +# Tenant does NOT support token exchange/OBO +export OKTA_OBO_COMPAT_CHECK_ENABLED=1 +export OKTA_SUPPORTS_TOKEN_EXCHANGE=false +python -m pytest tests/test_okta_obo_compatibility.py -k "live_check_when_enabled" +``` + +Expected behavior: + +- `client_credentials` path succeeds in both modes. +- if `OKTA_SUPPORTS_TOKEN_EXCHANGE=true`, token exchange should succeed. +- if `OKTA_SUPPORTS_TOKEN_EXCHANGE=false`, test is explicitly gated and does not + fail as a false negative. + +### 3) Run demo script in `examples/` + +```bash +python examples/delegation/okta_obo_compat_demo.py \ + --issuer "$OKTA_ISSUER" \ + --client-id "$OKTA_CLIENT_ID" \ + --client-secret "$OKTA_CLIENT_SECRET" \ + --audience "$OKTA_AUDIENCE" \ + --scope "${OKTA_SCOPE:-authority:check}" \ + --supports-token-exchange +``` + +If your tenant does not support token exchange, omit +`--supports-token-exchange`. The script reports which delegation path to use: + +- `idp_token_exchange` (when supported), or +- `authority_mandate_delegation` (fallback). + +--- + ## Local identity registry + flush queue Enable ephemeral task identity registry and local ledger queue: diff --git a/examples/delegation/okta_obo_compat_demo.py b/examples/delegation/okta_obo_compat_demo.py new file mode 100644 index 0000000..cc81ae7 --- /dev/null +++ b/examples/delegation/okta_obo_compat_demo.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + + +def _ensure_repo_root_on_syspath() -> None: + repo_root = Path(__file__).resolve().parents[2] + root = str(repo_root) + if root not in sys.path: + sys.path.insert(0, root) + + +def run( + issuer: str, + client_id: str, + client_secret: str, + audience: str, + scope: str, + supports_token_exchange: bool, + timeout_s: float, +) -> dict[str, object]: + _ensure_repo_root_on_syspath() + from predicate_authority import ( # pylint: disable=import-error + OktaCompatibilityConfig, + OktaTenantCapabilities, + run_okta_obo_compatibility_check, + ) + + result = run_okta_obo_compatibility_check( + config=OktaCompatibilityConfig( + issuer=issuer, + client_id=client_id, + client_secret=client_secret, + audience=audience, + scope=scope, + ), + capabilities=OktaTenantCapabilities(supports_token_exchange=supports_token_exchange), + timeout_s=timeout_s, + ) + result["delegation_path"] = ( + "idp_token_exchange" + if bool(result.get("token_exchange_ok", False)) + else "authority_mandate_delegation" + ) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Okta OBO compatibility demo for delegation flow.") + parser.add_argument("--issuer", default=os.getenv("OKTA_ISSUER")) + parser.add_argument("--client-id", default=os.getenv("OKTA_CLIENT_ID")) + parser.add_argument("--client-secret", default=os.getenv("OKTA_CLIENT_SECRET")) + parser.add_argument("--audience", default=os.getenv("OKTA_AUDIENCE")) + parser.add_argument("--scope", default=os.getenv("OKTA_SCOPE", "authority:check")) + parser.add_argument( + "--supports-token-exchange", + action="store_true", + help="Set if this Okta tenant is expected to support token exchange/OBO.", + ) + parser.add_argument("--timeout-s", type=float, default=5.0) + args = parser.parse_args() + + missing = [ + name + for name, value in ( + ("issuer", args.issuer), + ("client_id", args.client_id), + ("client_secret", args.client_secret), + ("audience", args.audience), + ) + if value is None or str(value).strip() == "" + ] + if missing: + raise SystemExit(f"Missing required arguments/env vars: {', '.join(missing)}") + + payload = run( + issuer=str(args.issuer), + client_id=str(args.client_id), + client_secret=str(args.client_secret), + audience=str(args.audience), + scope=str(args.scope), + supports_token_exchange=bool(args.supports_token_exchange), + timeout_s=float(args.timeout_s), + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/predicate_authority/__init__.py b/predicate_authority/__init__.py index 48d9dfb..183529c 100644 --- a/predicate_authority/__init__.py +++ b/predicate_authority/__init__.py @@ -33,6 +33,13 @@ TaskIdentityRecord, ) from predicate_authority.mandate import LocalMandateSigner +from predicate_authority.okta_compat import ( + OktaCompatibilityConfig, + OktaCompatibilityError, + OktaTenantCapabilities, + parse_bool, + run_okta_obo_compatibility_check, +) from predicate_authority.policy import PolicyEngine, PolicyMatchResult from predicate_authority.policy_source import PolicyFileSource, PolicyReloadResult from predicate_authority.proof import InMemoryProofLedger @@ -79,6 +86,9 @@ "OktaIdentityBridge", "OktaTokenClaims", "OpenTelemetryTraceEmitter", + "OktaCompatibilityConfig", + "OktaCompatibilityError", + "OktaTenantCapabilities", "PolicyEngine", "PolicyFileSource", "PolicyMatchResult", @@ -94,4 +104,6 @@ "TaskIdentityRecord", "TokenValidationError", "UsageCreditRecord", + "parse_bool", + "run_okta_obo_compatibility_check", ] diff --git a/predicate_authority/okta_compat.py b/predicate_authority/okta_compat.py new file mode 100644 index 0000000..d23a8b0 --- /dev/null +++ b/predicate_authority/okta_compat.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import http.client +import json +import urllib.error +import urllib.parse +from dataclasses import dataclass + + +class OktaCompatibilityError(RuntimeError): + pass + + +STATUS_REASON_CAPABILITY_DISABLED = "tenant_capability_disabled" +STATUS_REASON_OK = "ok" +TOKEN_TYPE_ACCESS = "urn:ietf:params:oauth:token-type:access_token" # nosec B105 +GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange" # nosec B105 + + +@dataclass(frozen=True) +class OktaTenantCapabilities: + supports_token_exchange: bool = False + + +@dataclass(frozen=True) +class OktaCompatibilityConfig: + issuer: str + client_id: str + client_secret: str + audience: str + scope: str = "authority:check" + + +def parse_bool(value: str | None, default: bool = False) -> bool: + if value is None: + return default + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "y", "on"}: + return True + if lowered in {"0", "false", "no", "n", "off"}: + return False + return default + + +def run_okta_obo_compatibility_check( + config: OktaCompatibilityConfig, + capabilities: OktaTenantCapabilities, + timeout_s: float = 5.0, +) -> dict[str, object]: + discovery_url = f"{config.issuer.rstrip('/')}/.well-known/openid-configuration" + discovery = _http_get_json(url=discovery_url, timeout_s=timeout_s) + token_endpoint = discovery.get("token_endpoint") + if not isinstance(token_endpoint, str) or token_endpoint.strip() == "": + raise OktaCompatibilityError("Okta discovery did not return token_endpoint.") + + cc_payload = { + "grant_type": "client_credentials", + "client_id": config.client_id, + "client_secret": config.client_secret, + "scope": config.scope, + "audience": config.audience, + } + cc_response = _http_post_form(url=token_endpoint, payload=cc_payload, timeout_s=timeout_s) + access_token = cc_response.get("access_token") + if not isinstance(access_token, str) or access_token.strip() == "": + raise OktaCompatibilityError("Client credentials flow did not return access_token.") + + result: dict[str, object] = { + "issuer": config.issuer, + "token_endpoint": token_endpoint, + "client_credentials_ok": True, + "supports_token_exchange": capabilities.supports_token_exchange, + } + + if not capabilities.supports_token_exchange: + result["token_exchange_ok"] = False + result["token_exchange_reason"] = STATUS_REASON_CAPABILITY_DISABLED + return result + + te_payload = { + "grant_type": GRANT_TYPE_TOKEN_EXCHANGE, + "client_id": config.client_id, + "client_secret": config.client_secret, + "subject_token": access_token, + "subject_token_type": TOKEN_TYPE_ACCESS, + "requested_token_type": TOKEN_TYPE_ACCESS, + "audience": config.audience, + "scope": config.scope, + } + te_response = _http_post_form(url=token_endpoint, payload=te_payload, timeout_s=timeout_s) + delegated_token = te_response.get("access_token") + if not isinstance(delegated_token, str) or delegated_token.strip() == "": + raise OktaCompatibilityError("Token exchange did not return access_token.") + result["token_exchange_ok"] = True + result["token_exchange_reason"] = STATUS_REASON_OK + return result + + +def _http_get_json(url: str, timeout_s: float) -> dict[str, object]: + raw = _http_request(url=url, method="GET", body=None, headers={}, timeout_s=timeout_s) + try: + loaded = json.loads(raw) + except json.JSONDecodeError as exc: + raise OktaCompatibilityError(f"Invalid JSON response from {url}") from exc + if not isinstance(loaded, dict): + raise OktaCompatibilityError(f"Expected object JSON response from {url}") + return loaded + + +def _http_post_form(url: str, payload: dict[str, str], timeout_s: float) -> dict[str, object]: + body = urllib.parse.urlencode(payload).encode("utf-8") + raw = _http_request( + url=url, + method="POST", + body=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout_s=timeout_s, + ) + try: + loaded = json.loads(raw) + except json.JSONDecodeError as exc: + raise OktaCompatibilityError(f"Invalid JSON response from token endpoint {url}") from exc + if not isinstance(loaded, dict): + raise OktaCompatibilityError(f"Expected object JSON response from token endpoint {url}") + return loaded + + +def _http_request( + url: str, + method: str, + body: bytes | None, + headers: dict[str, str], + timeout_s: float, +) -> str: + parsed = urllib.parse.urlsplit(url) + if parsed.scheme not in {"http", "https"} or parsed.netloc == "": + raise OktaCompatibilityError(f"Invalid URL: {url}") + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + if parsed.scheme == "https": + conn: http.client.HTTPConnection = http.client.HTTPSConnection( + parsed.netloc, timeout=timeout_s + ) + else: + conn = http.client.HTTPConnection(parsed.netloc, timeout=timeout_s) + try: + conn.request(method, path, body=body, headers=headers) + response = conn.getresponse() + raw = response.read().decode("utf-8") + except OSError as exc: + raise OktaCompatibilityError(f"Network error reaching {url}: {exc}") from exc + finally: + conn.close() + if response.status >= 400: + raise OktaCompatibilityError(f"HTTP {response.status} from {url}: {raw}") + return raw diff --git a/tests/test_okta_obo_compatibility.py b/tests/test_okta_obo_compatibility.py new file mode 100644 index 0000000..9c51a31 --- /dev/null +++ b/tests/test_okta_obo_compatibility.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import json +import os +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import parse_qs + +import pytest + +# pylint: disable=import-error +from predicate_authority import ( + OktaCompatibilityConfig, + OktaTenantCapabilities, + parse_bool, + run_okta_obo_compatibility_check, +) + + +@dataclass +class _Recorder: + token_requests: list[dict[str, str]] + + +class _OktaCompatHandler(BaseHTTPRequestHandler): + recorder: _Recorder + supports_token_exchange: bool + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/.well-known/openid-configuration": + self._send_json( + 200, + {"token_endpoint": f"http://127.0.0.1:{self.server.server_port}/oauth2/v1/token"}, + ) + return + self._send_json(404, {"error": "not_found"}) + + def do_POST(self) -> None: # noqa: N802 + if self.path != "/oauth2/v1/token": + self._send_json(404, {"error": "not_found"}) + return + raw_len = self.headers.get("Content-Length", "0") + content_length = int(raw_len) if raw_len.isdigit() else 0 + payload = self.rfile.read(content_length).decode("utf-8") if content_length > 0 else "" + parsed = {k: v[0] for k, v in parse_qs(payload).items() if len(v) > 0} + self.recorder.token_requests.append(parsed) + grant_type = parsed.get("grant_type", "") + if grant_type == "client_credentials": + self._send_json( + 200, {"access_token": "cc-token", "token_type": "Bearer", "expires_in": 300} + ) + return + if grant_type == "urn:ietf:params:oauth:grant-type:token-exchange": + if self.supports_token_exchange: + self._send_json( + 200, + {"access_token": "delegated-token", "token_type": "Bearer", "expires_in": 300}, + ) + return + self._send_json( + 400, + { + "error": "unsupported_grant_type", + "error_description": "token exchange not enabled", + }, + ) + return + self._send_json(400, {"error": "invalid_request"}) + + def log_message( + self, fmt: str, *args: Any + ) -> None: # noqa: A003 # pylint: disable=arguments-differ + _ = fmt + return + + def _send_json(self, status: int, payload: dict[str, object]) -> None: + encoded = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +def _start_server( + supports_token_exchange: bool, +) -> tuple[ThreadingHTTPServer, threading.Thread, _Recorder]: + recorder = _Recorder(token_requests=[]) + + class _BoundHandler(_OktaCompatHandler): + pass + + _BoundHandler.recorder = recorder + _BoundHandler.supports_token_exchange = supports_token_exchange + server = ThreadingHTTPServer(("127.0.0.1", 0), _BoundHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, recorder + + +def test_parse_bool_common_values() -> None: + assert parse_bool("true") is True + assert parse_bool("1") is True + assert parse_bool("false") is False + assert parse_bool("0") is False + assert parse_bool(None, default=True) is True + + +def test_okta_obo_check_gates_on_tenant_capability() -> None: + server, _, recorder = _start_server(supports_token_exchange=False) + try: + result = run_okta_obo_compatibility_check( + config=OktaCompatibilityConfig( + issuer=f"http://127.0.0.1:{server.server_port}", + client_id="client-id", + client_secret="client-secret", + audience="api://predicate-authority", + ), + capabilities=OktaTenantCapabilities(supports_token_exchange=False), + timeout_s=2.0, + ) + assert result["client_credentials_ok"] is True + assert result["token_exchange_ok"] is False + assert result["token_exchange_reason"] == "tenant_capability_disabled" + # Ensure no token-exchange call is attempted when capability is disabled. + grant_types = [item.get("grant_type", "") for item in recorder.token_requests] + assert grant_types.count("client_credentials") == 1 + assert grant_types.count("urn:ietf:params:oauth:grant-type:token-exchange") == 0 + finally: + server.shutdown() + server.server_close() + + +def test_okta_obo_check_succeeds_when_tenant_supports_exchange() -> None: + server, _, recorder = _start_server(supports_token_exchange=True) + try: + result = run_okta_obo_compatibility_check( + config=OktaCompatibilityConfig( + issuer=f"http://127.0.0.1:{server.server_port}", + client_id="client-id", + client_secret="client-secret", + audience="api://predicate-authority", + ), + capabilities=OktaTenantCapabilities(supports_token_exchange=True), + timeout_s=2.0, + ) + assert result["client_credentials_ok"] is True + assert result["token_exchange_ok"] is True + assert result["token_exchange_reason"] == "ok" + grant_types = [item.get("grant_type", "") for item in recorder.token_requests] + assert grant_types.count("client_credentials") == 1 + assert grant_types.count("urn:ietf:params:oauth:grant-type:token-exchange") == 1 + finally: + server.shutdown() + server.server_close() + + +def test_okta_obo_live_check_when_enabled() -> None: + if os.getenv("OKTA_OBO_COMPAT_CHECK_ENABLED") != "1": + pytest.skip("Set OKTA_OBO_COMPAT_CHECK_ENABLED=1 to run live Okta compatibility check.") + issuer = os.getenv("OKTA_ISSUER") + client_id = os.getenv("OKTA_CLIENT_ID") + client_secret = os.getenv("OKTA_CLIENT_SECRET") + audience = os.getenv("OKTA_AUDIENCE") + if not all([issuer, client_id, client_secret, audience]): + pytest.skip("Missing required live Okta env vars.") + supports_exchange = parse_bool(os.getenv("OKTA_SUPPORTS_TOKEN_EXCHANGE"), default=False) + result = run_okta_obo_compatibility_check( + config=OktaCompatibilityConfig( + issuer=str(issuer), + client_id=str(client_id), + client_secret=str(client_secret), + audience=str(audience), + scope=os.getenv("OKTA_SCOPE", "authority:check"), + ), + capabilities=OktaTenantCapabilities(supports_token_exchange=supports_exchange), + timeout_s=float(os.getenv("OKTA_HTTP_TIMEOUT_S", "5.0")), + ) + assert result["client_credentials_ok"] is True + if supports_exchange: + assert result["token_exchange_ok"] is True + else: + assert result["token_exchange_reason"] == "tenant_capability_disabled" From 37df4113d515ee6770ced2c47dba485f2bb130d1 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Tue, 17 Feb 2026 20:48:51 -0800 Subject: [PATCH 4/5] verified okta --- Makefile | 5 ++- README.md | 8 ++++ examples/README.md | 6 +++ examples/README_Okta.md | 81 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 examples/README_Okta.md diff --git a/Makefile b/Makefile index 3aa3042..ce08e52 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -.PHONY: hooks lint test examples verify-release-order build-packages format format-python format-docs lint-docs +.PHONY: hooks lint test examples verify-release-order build-packages format format-python format-docs lint-docs dev-install + +dev-install: + python -m pip install -e predicate_contracts -e predicate_authority hooks: pre-commit install diff --git a/README.md b/README.md index 4479e40..894f252 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,14 @@ Implemented in this repository: pip install predicate-authority ``` +For local editable development in this monorepo, install both package roots +(do not use `pip install -e .` at repo root): + +```bash +make dev-install +# equivalent: python -m pip install -e predicate_contracts -e predicate_authority +``` + For shared contracts directly: ```bash diff --git a/examples/README.md b/examples/README.md index 7f15422..ce25d8a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,3 +36,9 @@ Check endpoints: ```bash PYTHONPATH=. python examples/authorityd/daemon_endpoint_check.py ``` + +## Okta compatibility example notes + +For Okta OBO/token-exchange compatibility setup and troubleshooting, see: + +- `examples/README_Okta.md` diff --git a/examples/README_Okta.md b/examples/README_Okta.md new file mode 100644 index 0000000..c681559 --- /dev/null +++ b/examples/README_Okta.md @@ -0,0 +1,81 @@ +# Okta Example Notes + +This note covers running the Okta delegation compatibility demo and fixing the +most common setup issues. + +## Prerequisites + +- Populate `AgentIdentity/.env` with: + - `OKTA_ISSUER` + - `OKTA_CLIENT_ID` + - `OKTA_CLIENT_SECRET` + - `OKTA_AUDIENCE` + - `OKTA_SCOPE` (defaults to `authority:check`) +- Load env vars in your terminal: + +```bash +set -a +source .env +set +a +``` + +## Run the compatibility demo + +```bash +python examples/delegation/okta_obo_compat_demo.py \ + --issuer "$OKTA_ISSUER" \ + --client-id "$OKTA_CLIENT_ID" \ + --client-secret "$OKTA_CLIENT_SECRET" \ + --audience "$OKTA_AUDIENCE" \ + --scope "${OKTA_SCOPE:-authority:check}" +``` + +If your tenant supports token exchange/OBO, add: + +```bash +--supports-token-exchange +``` + +Full command: + +```bash +python examples/delegation/okta_obo_compat_demo.py \ + --issuer "$OKTA_ISSUER" \ + --client-id "$OKTA_CLIENT_ID" \ + --client-secret "$OKTA_CLIENT_SECRET" \ + --audience "$OKTA_AUDIENCE" \ + --scope "$OKTA_SCOPE" \ + --supports-token-exchange +``` + +## Common error: `invalid_scope` + +Error example: + +```text +HTTP 400 ... {"error":"invalid_scope","error_description":"One or more scopes are not configured for the authorization server resource."} +``` + +This means the requested scope (for example `authority:check`) is not configured +for your Okta authorization server. + +### Fix + +1. In Okta Admin, open `Security -> API -> Authorization Servers -> default`. +2. Add scope `authority:check` (or another scope you intend to use). +3. In Access Policies, allow: + - grant type: `Client Credentials` + - scope: `authority:check` (or your chosen scope) +4. Re-run the demo. + +If you want a quick workaround, set `OKTA_SCOPE` to an existing scope that your +app policy already allows. + +## Compatibility behavior + +- If `--supports-token-exchange` is set and tenant supports it: + - output should report `delegation_path: idp_token_exchange`. +- Otherwise: + - output should report `delegation_path: authority_mandate_delegation`. + +This keeps delegation deterministic even when IdP-native OBO is unavailable. From a6f9566de77ba520450662c653491a3d4504c692 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Tue, 17 Feb 2026 20:54:18 -0800 Subject: [PATCH 5/5] updated readme --- .github/workflows/phase1-ci-and-release.yml | 13 ++++- Makefile | 8 ++- README.md | 4 ++ docs/pypi-release-guide.md | 37 ++++++++----- predicate_authority/README.md | 6 ++- predicate_authority/pyproject.toml | 5 +- scripts/validate_release_tag.py | 60 +++++++++++++++++++++ 7 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 scripts/validate_release_tag.py diff --git a/.github/workflows/phase1-ci-and-release.yml b/.github/workflows/phase1-ci-and-release.yml index b2c5ccf..f46ec33 100644 --- a/.github/workflows/phase1-ci-and-release.yml +++ b/.github/workflows/phase1-ci-and-release.yml @@ -4,6 +4,7 @@ on: pull_request: push: branches: ["main", "phase1"] + tags: ["v*"] workflow_dispatch: inputs: publish: @@ -47,7 +48,7 @@ jobs: publish-predicate-contracts: runs-on: ubuntu-latest needs: [quality] - if: github.event_name == 'workflow_dispatch' && inputs.publish == 'true' + if: (github.event_name == 'workflow_dispatch' && inputs.publish == 'true') || startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout uses: actions/checkout@v4 @@ -63,6 +64,10 @@ jobs: - name: Verify release order run: python scripts/verify_release_order.py + - name: Validate release tag version + if: startsWith(github.ref, 'refs/tags/v') + run: python scripts/validate_release_tag.py --tag "${GITHUB_REF_NAME}" + - name: Build predicate-contracts run: python -m build predicate_contracts @@ -78,7 +83,7 @@ jobs: publish-predicate-authority: runs-on: ubuntu-latest needs: [publish-predicate-contracts] - if: github.event_name == 'workflow_dispatch' && inputs.publish == 'true' + if: (github.event_name == 'workflow_dispatch' && inputs.publish == 'true') || startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout uses: actions/checkout@v4 @@ -94,6 +99,10 @@ jobs: - name: Verify release order run: python scripts/verify_release_order.py + - name: Validate release tag version + if: startsWith(github.ref, 'refs/tags/v') + run: python scripts/validate_release_tag.py --tag "${GITHUB_REF_NAME}" + - name: Build predicate-authority run: python -m build predicate_authority diff --git a/Makefile b/Makefile index ce08e52..55336f5 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,14 @@ -.PHONY: hooks lint test examples verify-release-order build-packages format format-python format-docs lint-docs dev-install +.PHONY: hooks lint test examples verify-release-order build-packages format format-python format-docs lint-docs dev-install tag-release dev-install: python -m pip install -e predicate_contracts -e predicate_authority +tag-release: + @test -n "$(VERSION)" || (echo "Usage: make tag-release VERSION=X.Y.Z" && exit 1) + python scripts/validate_release_tag.py --tag "v$(VERSION)" + git tag -a "v$(VERSION)" -m "Release v$(VERSION)" + @echo "Created tag v$(VERSION). Push with: git push origin v$(VERSION)" + hooks: pre-commit install diff --git a/README.md b/README.md index 894f252..b5384be 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![License](https://img.shields.io/badge/License-MIT%2FApache--2.0-blue.svg)](LICENSE) [![PyPI - predicate-authority](https://img.shields.io/pypi/v/predicate-authority.svg)](https://pypi.org/project/predicate-authority/) [![PyPI - predicate-contracts](https://img.shields.io/pypi/v/predicate-contracts.svg)](https://pypi.org/project/predicate-contracts/) +[![Release Tag](https://img.shields.io/badge/release-vX.Y.Z-blue)](docs/pypi-release-guide.md) `predicate-authority` is a production-grade pre-execution authority layer that binds AI agent identity to deterministic state. It bridges standard IdPs (Entra ID, Okta, OIDC) with runtime verification so every sensitive action is authorized, bounded, and provable. @@ -69,6 +70,9 @@ make dev-install # equivalent: python -m pip install -e predicate_contracts -e predicate_authority ``` +Release note: publish is supported by pushing a synchronized git tag `vX.Y.Z` +(see `docs/pypi-release-guide.md`). + For shared contracts directly: ```bash diff --git a/docs/pypi-release-guide.md b/docs/pypi-release-guide.md index 0265603..25d876f 100644 --- a/docs/pypi-release-guide.md +++ b/docs/pypi-release-guide.md @@ -49,6 +49,28 @@ python -m build predicate_authority - `publish-predicate-contracts` - `publish-predicate-authority` (runs only after contracts publish succeeds) +## 3b) Publish via git tag (recommended for traceability) + +This repository supports tag-triggered publish with a single synchronized tag: + +- tag format: `vX.Y.Z` +- both packages must use the same version `X.Y.Z` + +When a tag like `v0.2.0` is pushed: + +- workflow `phase1-ci-and-release` triggers on the tag, +- release order remains enforced, +- `scripts/validate_release_tag.py` verifies: + - `predicate_contracts/pyproject.toml` version == `predicate_authority/pyproject.toml` version, + - tag version matches both package versions. + +Example: + +```bash +make tag-release VERSION=0.1.0 +git push origin v0.1.0 +``` + ## 4) Verify published artifacts ```bash @@ -60,23 +82,14 @@ print("ok", predicate_contracts.__name__, predicate_authority.__name__) PY ``` -## 5) Optional: create git tags per package release - -Tags are not required for publishing in this repo, but they are recommended for traceability. +## 5) Optional: manual tags (legacy style) -Suggested tag format: +If you need package-specific traceability tags for historical reasons, you can still add: - `predicate-contracts-vX.Y.Z` - `predicate-authority-vX.Y.Z` -Example commands (after publish succeeds): - -```bash -git tag -a predicate-contracts-v0.1.0 -m "predicate-contracts v0.1.0" -git tag -a predicate-authority-v0.1.0 -m "predicate-authority v0.1.0" -git push origin predicate-contracts-v0.1.0 -git push origin predicate-authority-v0.1.0 -``` +These tags are informational only; automated publish is wired to `vX.Y.Z`. ## 6) Manual fallback publish (if needed) diff --git a/predicate_authority/README.md b/predicate_authority/README.md index d7ee07b..2c282ae 100644 --- a/predicate_authority/README.md +++ b/predicate_authority/README.md @@ -1,6 +1,10 @@ # predicate-authority -`predicate-authority` provides pre-execution authorization for AI agent actions. +`predicate-authority` is a deterministic pre-execution authority layer for AI agents. +It binds identity, policy, and runtime evidence so risky actions are authorized +before execution and denied fail-closed when checks do not pass. + +Docs: https://www.PredicateSystems.ai/docs Core pieces: diff --git a/predicate_authority/pyproject.toml b/predicate_authority/pyproject.toml index 17c9088..f183c5a 100644 --- a/predicate_authority/pyproject.toml +++ b/predicate_authority/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "predicate-authority" version = "0.1.0" -description = "Pre-execution authority enforcement runtime for AI agents." +description = "Deterministic pre-execution authority layer for AI agents." readme = "README.md" requires-python = ">=3.11" license = "MIT OR Apache-2.0" @@ -27,6 +27,9 @@ predicate-authorityd = "predicate_authority.daemon:main" [project.optional-dependencies] telemetry = ["opentelemetry-api>=1.24.0"] +[project.urls] +Documentation = "https://www.PredicateSystems.ai/docs" + [tool.setuptools] packages = ["predicate_authority", "predicate_authority.integrations"] diff --git a/scripts/validate_release_tag.py b/scripts/validate_release_tag.py new file mode 100644 index 0000000..dacf13c --- /dev/null +++ b/scripts/validate_release_tag.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _read_version(pyproject_path: Path) -> str: + content = pyproject_path.read_text(encoding="utf-8") + for raw_line in content.splitlines(): + line = raw_line.strip() + if line.startswith("version = "): + value = line.split("=", maxsplit=1)[1].strip().strip('"') + if value: + return value + raise RuntimeError(f"Unable to read version from {pyproject_path}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate git release tag against package versions." + ) + parser.add_argument( + "--tag", + required=True, + help="Git tag name, expected format vX.Y.Z", + ) + args = parser.parse_args() + tag = args.tag.strip() + if not tag.startswith("v"): + raise SystemExit("Release tag must start with 'v' (example: v0.1.0)") + tag_version = tag[1:] + if tag_version == "": + raise SystemExit("Release tag version cannot be empty") + + repo_root = Path(__file__).resolve().parents[1] + contracts_version = _read_version(repo_root / "predicate_contracts" / "pyproject.toml") + authority_version = _read_version(repo_root / "predicate_authority" / "pyproject.toml") + + if contracts_version != authority_version: + raise SystemExit( + "Package versions are not in sync: " + f"predicate-contracts={contracts_version}, predicate-authority={authority_version}" + ) + if tag_version != contracts_version: + raise SystemExit( + f"Tag version {tag_version} does not match package version {contracts_version}" + ) + + print( + "release tag validated:", + f"tag={tag}", + f"predicate-contracts={contracts_version}", + f"predicate-authority={authority_version}", + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())