Adopt canonical flex-auth credential checks
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02e56-e4ad-71a2-b3e2-b6193e0d8093
This commit is contained in:
codex 2026-08-23 14:03:40 +02:00
parent 8ae77ca006
commit c9d02147d3
8 changed files with 338 additions and 50 deletions

View file

@ -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 |

View file

@ -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:<grant-id>` 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

View file

@ -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(

View file

@ -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),
}

View file

@ -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"}}'

View file

@ -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)

View file

@ -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.

View file

@ -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:<id>` / `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.