diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 35bd7cb..f6f9180 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -27,11 +27,13 @@ | workplan | RAILIANCE-WP-0025 | finished | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md | | workplan | RAILIANCE-WP-0026 | finished | — | workplans/RAILIANCE-WP-0026-attended-login-output-containment.md | | workplan | RAILIANCE-WP-0027 | blocked | — | workplans/RAILIANCE-WP-0027-openbao-operator-only-access.md | +| workplan | RAILIANCE-WP-0028 | finished | — | workplans/RAILIANCE-WP-0028-flex-auth-credential-grant-integration.md | | workplan | RPF-WP-0018 | finished | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | workplan | RPF-WP-0019 | finished | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | | workplan | RPF-WP-0020 | finished | — | workplans/RPF-WP-0020-ccr-schema-drift.md | | workplan | RPF-WP-0021 | finished | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md | | task | ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | +| task | ADHOC-2026-08-23-T02 | done | — | workplans/ADHOC-2026-08-23.md | | task | RAILIANCE-WP-0005-T01 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | | task | RAILIANCE-WP-0005-T02 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | | task | RAILIANCE-WP-0005-T03 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | @@ -116,6 +118,9 @@ | task | RAILIANCE-WP-0027-T01 | done | — | workplans/RAILIANCE-WP-0027-openbao-operator-only-access.md | | task | RAILIANCE-WP-0027-T02 | done | — | workplans/RAILIANCE-WP-0027-openbao-operator-only-access.md | | task | RAILIANCE-WP-0027-T03 | wait | — | workplans/RAILIANCE-WP-0027-openbao-operator-only-access.md | +| task | RAILIANCE-WP-0028-T01 | done | — | workplans/RAILIANCE-WP-0028-flex-auth-credential-grant-integration.md | +| task | RAILIANCE-WP-0028-T02 | done | — | workplans/RAILIANCE-WP-0028-flex-auth-credential-grant-integration.md | +| task | RAILIANCE-WP-0028-T03 | done | — | workplans/RAILIANCE-WP-0028-flex-auth-credential-grant-integration.md | | task | RPF-WP-0018-T01 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | task | RPF-WP-0018-T02 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | task | RPF-WP-0018-T03 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | diff --git a/docs/credential-broker.md b/docs/credential-broker.md index 4819bb5..ebe0dc0 100644 --- a/docs/credential-broker.md +++ b/docs/credential-broker.md @@ -213,12 +213,16 @@ The helper performs local catalog checks before any issuance: - actor type must be allowed by the grant. Optional flex-auth preflight is enabled with `--flex-auth-url` or `FLEX_AUTH_URL`. -The helper posts non-secret request metadata to -`/credential-grants/authorize` by default and accepts allow/deny responses using -`allowed`, `decision`, or `status` fields plus optional `decision_id` and -`reason`. Use `--require-flex-auth` when local preauthorization is not -acceptable. Use `--decision-id` to carry an already-approved external decision -without calling flex-auth again. +The helper posts non-secret request metadata to canonical `POST /v1/check` by +default. It maps the grant to a `credential-grant:` resource, action +`issue`, and the bound actor subject. The helper parses `requested_ttl` locally +and sends integer `context.requested_ttl_seconds`; flex-auth policy never has to +infer duration units. Only a DecisionEnvelope with `effect: allow` succeeds; +the helper also requires its decision id, evaluator provenance, and matching +subject/resource binding. Every other or malformed response fails closed. Use +`--require-flex-auth` when local preauthorization is not acceptable. Use +`--decision-id` to carry an already-approved external decision without calling +flex-auth again. ## State Hub Metadata diff --git a/scripts/credential.py b/scripts/credential.py index 3e50deb..0886349 100755 --- a/scripts/credential.py +++ b/scripts/credential.py @@ -31,6 +31,11 @@ UNSAFE_VERBOSE_VALUES = {"debug", "trace"} DEFAULT_ACTOR_TYPE = "approved-agent" DEFAULT_ACTOR = f"codex:{os.environ.get('USER', 'unknown')}" DEFAULT_SUBJECT = "agent:codex/railiance-platform" +FLEX_AUTH_SUBJECT_TYPES = { + "human-operator": "Human", + "approved-agent": "Agent", + "ci-runner": "Automation", +} @dataclass(frozen=True) @@ -207,7 +212,7 @@ def post_json( return data -def request_metadata( +def flex_auth_check_request( *, grant: dict[str, Any], ttl: str, @@ -217,17 +222,26 @@ def request_metadata( actor_type: str, subject: str, ) -> dict[str, Any]: + subject_type = FLEX_AUTH_SUBJECT_TYPES.get(actor_type) + if subject_type is None: + fail(f"actor type {actor_type!r} has no flex-auth subject type mapping") return { - "grant_id": grant["id"], - "actor": actor, - "actor_type": actor_type, - "subject": subject, - "purpose": purpose, - "requested_ttl": ttl, - "delivery_mode": delivery, - "audience": grant.get("audience"), - "issuer": grant.get("issuer"), - "credential_type": grant.get("credential_type"), + "tenant": "tenant:platform", + "subject": {"id": subject, "type": subject_type}, + "action": "issue", + "resource": { + "id": f"credential-grant:{grant['id']}", + "type": "credential-grant", + "system": "railiance-platform", + }, + "context": { + "actor": actor, + "actor_type": actor_type, + "bound_subject": subject, + "purpose": purpose, + "delivery_mode": delivery, + "requested_ttl_seconds": ttl_seconds(ttl), + }, } @@ -264,7 +278,7 @@ def authorize_request( ) endpoint = join_url(args.flex_auth_url, args.flex_auth_path) - payload = request_metadata( + payload = flex_auth_check_request( grant=grant, ttl=ttl, purpose=purpose, @@ -288,30 +302,36 @@ def authorize_request( "flex-auth unavailable; local preauthorization used", ) - allowed_value = response.get("allowed") - decision_value = str( - response.get("decision") or response.get("status") or "" - ).lower() - allowed = allowed_value is True or decision_value in { - "allow", - "allowed", - "approved", - "pass", - } - denied = allowed_value is False or decision_value in { - "deny", - "denied", - "rejected", - "fail", - } - decision_id = response.get("decision_id") or response.get("id") - reason = response.get("reason") or response.get("message") - if denied or not allowed: + effect = response.get("effect") + decision_id = response.get("id") + reason = response.get("reason") + if effect != "allow": fail(f"flex-auth denied credential request: {reason or 'no reason supplied'}") + if not isinstance(decision_id, str) or not decision_id: + fail("flex-auth returned an allow effect without a decision id") + response_subject = response.get("subject") + if ( + not isinstance(response_subject, dict) + or response_subject.get("id") != payload["subject"]["id"] + ): + fail("flex-auth allow decision subject does not match the request") + response_resource = response.get("resource") + if not isinstance(response_resource, dict) or any( + response_resource.get(field) != payload["resource"][field] + for field in ("id", "type", "system") + ): + fail("flex-auth allow decision resource does not match the request") + provenance = response.get("provenance") + if ( + not isinstance(provenance, dict) + or not provenance.get("evaluator") + or not provenance.get("mode") + ): + fail("flex-auth allow decision has no evaluator provenance") return AuthorizationResult( True, "flex-auth", - str(decision_id) if decision_id else None, + decision_id, str(reason) if reason else None, ) @@ -1096,7 +1116,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--flex-auth-url", default=os.environ.get("FLEX_AUTH_URL")) parser.add_argument( "--flex-auth-path", - default=os.environ.get("FLEX_AUTH_PATH", "/credential-grants/authorize"), + default=os.environ.get("FLEX_AUTH_PATH", "/v1/check"), ) parser.add_argument("--require-flex-auth", action="store_true") parser.add_argument( diff --git a/scripts/wp0025-broker-readiness.py b/scripts/wp0025-broker-readiness.py index 8ae1e17..a9a9030 100755 --- a/scripts/wp0025-broker-readiness.py +++ b/scripts/wp0025-broker-readiness.py @@ -121,7 +121,14 @@ def verify_adapter( } -def receipt_base(contract: dict[str, Any], reviewer: str, decision: str) -> dict[str, Any]: +def receipt_base( + contract: dict[str, Any], + reviewer: str, + decision: str, + *, + now: datetime | None = None, +) -> dict[str, Any]: + created_at = now or datetime.now(UTC) return { "interface": BROKER_INTERFACE, "version": 1, @@ -129,7 +136,7 @@ def receipt_base(contract: dict[str, Any], reviewer: str, decision: str) -> dict "owner": BROKER_OWNER, "reviewer": validate_reviewer(reviewer), "decision": decision, - "created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "created_at": created_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"), "engagement_id": contract["engagement_id"], "target_id": contract["target"]["id"], "projection_contract_digest": contract_digest(contract), @@ -142,22 +149,32 @@ def receipt_base(contract: dict[str, Any], reviewer: str, decision: str) -> dict def build_approval( - contract: dict[str, Any], reviewer: str, verification: dict[str, Any] + contract: dict[str, Any], + reviewer: str, + verification: dict[str, Any], + *, + now: datetime | None = None, ) -> dict[str, Any]: if verification.get("passed") is not True or verification.get("secret_values_observed") is not False: raise ReadinessError("consumer adapter verification did not pass") receipt = { - **receipt_base(contract, reviewer, "approve"), + **receipt_base(contract, reviewer, "approve", now=now), "adapter": verification["adapter"], "cleanup_request_supported": True, } - validate_broker_receipt(receipt, contract) + validate_broker_receipt(receipt, contract, now=now) return receipt -def build_change_request(contract: dict[str, Any], reviewer: str, note: str) -> dict[str, Any]: +def build_change_request( + contract: dict[str, Any], + reviewer: str, + note: str, + *, + now: datetime | None = None, +) -> dict[str, Any]: return { - **receipt_base(contract, reviewer, "request-changes"), + **receipt_base(contract, reviewer, "request-changes", now=now), "note": validate_note(note), } diff --git a/tests/test_credential_helper.py b/tests/test_credential_helper.py index fc4061f..8e2e8f4 100644 --- a/tests/test_credential_helper.py +++ b/tests/test_credential_helper.py @@ -9,6 +9,7 @@ import tempfile import unittest from argparse import Namespace from pathlib import Path +from unittest import mock REPO_DIR = Path(__file__).resolve().parents[1] SPEC = importlib.util.spec_from_file_location( @@ -50,6 +51,139 @@ def sample_grant() -> dict: class CredentialHelperTests(unittest.TestCase): + def test_flex_auth_request_uses_canonical_contract_and_numeric_ttl(self) -> None: + payload = credential.flex_auth_check_request( + grant=sample_grant(), + ttl="15m", + purpose="flex-auth-openbao-smoke", + delivery="exec-env", + actor="codex:operator", + actor_type="approved-agent", + subject="agent:codex/railiance-platform", + ) + self.assertEqual(payload["tenant"], "tenant:platform") + self.assertEqual( + payload["subject"], + {"id": "agent:codex/railiance-platform", "type": "Agent"}, + ) + self.assertEqual(payload["action"], "issue") + self.assertEqual( + payload["resource"], + { + "id": "credential-grant:ops-warden/warden-sign", + "type": "credential-grant", + "system": "railiance-platform", + }, + ) + self.assertEqual(payload["context"]["requested_ttl_seconds"], 900) + self.assertEqual( + payload["context"]["bound_subject"], + "agent:codex/railiance-platform", + ) + + def test_authorize_request_reads_decision_envelope(self) -> None: + args = Namespace( + decision_id=None, + dry_run=False, + flex_auth_url="http://127.0.0.1:19099", + flex_auth_path="/v1/check", + require_flex_auth=True, + actor="codex:operator", + actor_type="approved-agent", + subject="agent:codex/railiance-platform", + http_timeout=1.0, + ) + response = { + "id": "decision:credential-grant-allow", + "effect": "allow", + "reason": "credential_grant_allowed", + "subject": { + "id": "agent:codex/railiance-platform", + "type": "Agent", + }, + "resource": { + "id": "credential-grant:ops-warden/warden-sign", + "type": "credential-grant", + "system": "railiance-platform", + }, + "provenance": {"evaluator": "standalone", "mode": "embedded"}, + } + with mock.patch.object(credential, "post_json", return_value=response) as post: + result = credential.authorize_request( + args=args, + grant=sample_grant(), + ttl="15m", + purpose="flex-auth-openbao-smoke", + delivery="exec-env", + ) + self.assertEqual(result.mode, "flex-auth") + self.assertEqual(result.decision_id, "decision:credential-grant-allow") + endpoint, payload = post.call_args.args + self.assertEqual(endpoint, "http://127.0.0.1:19099/v1/check") + self.assertEqual(payload["context"]["requested_ttl_seconds"], 900) + + def test_authorize_request_fails_closed_on_non_allow_effect(self) -> None: + args = Namespace( + decision_id=None, + dry_run=False, + flex_auth_url="http://127.0.0.1:19099", + flex_auth_path="/v1/check", + require_flex_auth=True, + actor="codex:operator", + actor_type="approved-agent", + subject="agent:codex/railiance-platform", + http_timeout=1.0, + ) + with mock.patch.object( + credential, + "post_json", + return_value={ + "id": "decision:deny", + "effect": "deny", + "reason": "ttl_out_of_bounds", + }, + ): + with self.assertRaisesRegex(SystemExit, "ttl_out_of_bounds"): + credential.authorize_request( + args=args, + grant=sample_grant(), + ttl="15m", + purpose="flex-auth-openbao-smoke", + delivery="exec-env", + ) + + def test_authorize_request_rejects_unbound_allow_response(self) -> None: + args = Namespace( + decision_id=None, + dry_run=False, + flex_auth_url="http://127.0.0.1:19099", + flex_auth_path="/v1/check", + require_flex_auth=True, + actor="codex:operator", + actor_type="approved-agent", + subject="agent:codex/railiance-platform", + http_timeout=1.0, + ) + with mock.patch.object( + credential, + "post_json", + return_value={"id": "decision:unbound", "effect": "allow"}, + ): + with self.assertRaisesRegex(SystemExit, "subject does not match"): + credential.authorize_request( + args=args, + grant=sample_grant(), + ttl="15m", + purpose="flex-auth-openbao-smoke", + delivery="exec-env", + ) + + def test_flex_auth_default_path_is_canonical_check(self) -> None: + args = credential.build_parser().parse_args( + ["request", "--purpose", "flex-auth-openbao-smoke"] + ) + self.assertEqual(args.flex_auth_path, "/v1/check") + def test_database_credential_response_is_parsed_without_emission(self) -> None: lease_id, username, password = credential.parse_database_credential( '{"lease_id":"database/creds/runtime/lease-1","data":{"username":"leased-user","password":"leased-secret"}}' diff --git a/tests/test_wp0025_broker_readiness.py b/tests/test_wp0025_broker_readiness.py index 18eeeae..50ed21a 100644 --- a/tests/test_wp0025_broker_readiness.py +++ b/tests/test_wp0025_broker_readiness.py @@ -7,6 +7,7 @@ import stat import sys import tempfile import unittest +from datetime import datetime from pathlib import Path @@ -28,6 +29,12 @@ SPEC.loader.exec_module(module) class BrokerReadinessTests(unittest.TestCase): + @staticmethod + def engagement_time(contract: dict) -> datetime: + return datetime.fromisoformat( + contract["window"]["starts_at"].replace("Z", "+00:00") + ) + def test_show_is_direct_and_authorizes_no_live_mutation(self) -> None: result = module.show(projection_contract()) self.assertEqual("whitehat-security", result["owner"]) @@ -78,13 +85,21 @@ class BrokerReadinessTests(unittest.TestCase): }, "secret_values_observed": False, } - receipt = module.build_approval(contract, "whitehat-owner", verification) + now = self.engagement_time(contract) + receipt = module.build_approval( + contract, "whitehat-owner", verification, now=now + ) self.assertEqual("approve", receipt["decision"]) self.assertTrue(receipt["cleanup_request_supported"]) - change = module.build_change_request(contract, "whitehat-owner", "Bind cleanup acknowledgement.") + change = module.build_change_request( + contract, + "whitehat-owner", + "Bind cleanup acknowledgement.", + now=now, + ) self.assertEqual("request-changes", change["decision"]) with self.assertRaises(module.ReadinessError): - module.build_change_request(contract, "whitehat-owner", "") + module.build_change_request(contract, "whitehat-owner", "", now=now) def test_receipt_export_is_raw_canonical_document_with_private_mode(self) -> None: contract = projection_contract() @@ -99,7 +114,12 @@ class BrokerReadinessTests(unittest.TestCase): }, "secret_values_observed": False, } - receipt = module.build_approval(contract, "whitehat-owner", verification) + receipt = module.build_approval( + contract, + "whitehat-owner", + verification, + now=self.engagement_time(contract), + ) with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "nested" / "broker.json" module.write_receipt(path, receipt) diff --git a/workplans/ADHOC-2026-08-23.md b/workplans/ADHOC-2026-08-23.md index 1b799f8..993b273 100644 --- a/workplans/ADHOC-2026-08-23.md +++ b/workplans/ADHOC-2026-08-23.md @@ -28,3 +28,20 @@ the new entries satisfy the current-posture evidence contract directly. Verification runs the canonical NetKingdom tenancy-posture validator against this repository's declaration and checks the repository diff. + +## Stabilize expired-engagement broker readiness tests + +```task +id: ADHOC-2026-08-23-T02 +status: done +priority: medium +``` + +Full test discovery exposed two WP0025 broker-readiness tests that built a +receipt with wall-clock time for a terminal prior-day engagement. Add an +optional explicit clock to the receipt builders and pin the unit fixtures to +the contract window so the production default stays current UTC while offline +tests remain deterministic. + +The complete 146-test offline suite now passes on dates after the terminal +engagement window. diff --git a/workplans/RAILIANCE-WP-0028-flex-auth-credential-grant-integration.md b/workplans/RAILIANCE-WP-0028-flex-auth-credential-grant-integration.md new file mode 100644 index 0000000..02cb039 --- /dev/null +++ b/workplans/RAILIANCE-WP-0028-flex-auth-credential-grant-integration.md @@ -0,0 +1,71 @@ +--- +id: RAILIANCE-WP-0028 +type: workplan +title: "Adopt canonical flex-auth credential-grant checks" +domain: financials +repo: railiance-platform +status: finished +owner: codex +topic_slug: railiance +created: "2026-08-23" +updated: "2026-08-23" +related: + - FLEX-WP-0012 +origin: routed +origin_ref: "State Hub decision 1f9f257d-c9f2-4a5e-a018-8058a3f2a51a" +--- + +# RAILIANCE-WP-0028 — canonical flex-auth credential-grant checks + +## Goal + +Adopt flex-auth's single canonical `POST /v1/check` decision surface for +credential-grant preflight without exposing credential values or adding a +consumer-specific flex-auth endpoint. + +## T01 — Resolve translation ownership + +```task +id: RAILIANCE-WP-0028-T01 +status: done +priority: high +``` + +The operator explicitly approved Option A in State Hub decision +`1f9f257d-c9f2-4a5e-a018-8058a3f2a51a`: railiance-platform maps its grant +metadata to `CheckRequest` and reads `DecisionEnvelope`. Duration parsing and +normalization belong here, before the flex-auth policy boundary. + +## T02 — Implement the canonical request and response + +```task +id: RAILIANCE-WP-0028-T02 +status: done +priority: high +``` + +Change the helper default to `/v1/check`, emit the coordinated +`tenant:platform` / `credential-grant:` / `issue` request vocabulary, send +`requested_ttl_seconds` as an integer, and accept only `effect: allow`. + +Completed in source. Actor classes map to canonical subject types (`Human`, +`Agent`, or `Automation`); the bound subject and non-secret actor metadata are +carried in context. Missing, deny, redact, audit-only, and not-applicable +effects all fail closed. An allow also requires a non-empty decision id, +evaluator provenance, and subject/resource binding back to the request. + +## T03 — Verify and route the contract + +```task +id: RAILIANCE-WP-0028-T03 +status: done +priority: medium +``` + +Exercise focused allow and deny tests, validate the complete repository suite, +and route the adopted mapping to flex-auth without requesting live credentials +or a production mutation. + +Completed with 70 focused credential tests, all credential-helper dry-runs, +credential-catalog validation, and the complete 146-test offline suite passing. +No flex-auth production endpoint was called and no credential was issued.