diff --git a/credential-grants/catalog.yaml b/credential-grants/catalog.yaml index 1c7a2be..f630d53 100644 --- a/credential-grants/catalog.yaml +++ b/credential-grants/catalog.yaml @@ -1,5 +1,5 @@ version: 1 -updated: "2026-06-27" +updated: "2026-08-10" owner_repo: railiance-platform owner_domain: financials workplan_id: RAILIANCE-WP-0005 @@ -24,6 +24,84 @@ grant_classes: - break-glass grants: + - id: rapp-postgres/audit-core-runtime + title: audit-core runtime PostgreSQL lease + status: active + grant_class: self-service + credential_type: openbao-database-credential + issuer: openbao + audience: audit-core + description: Short-lived runtime connection to the audit-core database. + openbao: + namespace: openbao + token_role: audit-core-runtime-broker + issuer_policy: credential-broker-audit-core-runtime-issuer + policies: [credential-broker-audit-core-runtime] + disallowed_policies: [root, platform-admin] + mount_paths: [database/creds/audit-core-runtime] + database: + role: audit-core-runtime + host: platform-pg-rw.databases.svc.cluster.local + port: 5432 + name: audit_core + env: {PGUSER: username, PGPASSWORD: password, PGHOST: host, PGPORT: port, PGDATABASE: database} + ttl: {default: 15m, max: 1h, renewable: false, requires_human_above: 1h} + actors: + allowed_types: [human-operator, approved-agent, ci-runner] + required_subject_binding: keycape-or-kubernetes-service-account + authorization: + flex_auth_required: false + flex_auth_mode: optional-preflight + approval_required: false + purpose_required: true + allowed_purpose_examples: [audit-core-runtime] + delivery: + allowed: [exec-env] + preferred: exec-env + denied: [chat, state-hub-body, git, command-line-token-argument, llm-prompt] + exec_env: {child_only: true, redact_logs: true} + audit: {openbao_audit_required: true, state_hub_metadata_allowed: true, record_secret_values: false} + revocation: {required: true, by_accessor: true, on_exec_exit: true, on_denied_request: false} + + - id: rapp-postgres/audit-core-migration + title: audit-core migration PostgreSQL lease + status: active + grant_class: self-service + credential_type: openbao-database-credential + issuer: openbao + audience: audit-core + description: Short-lived migration connection to the audit-core database. + openbao: + namespace: openbao + token_role: audit-core-migration-broker + issuer_policy: credential-broker-audit-core-migration-issuer + policies: [credential-broker-audit-core-migration] + disallowed_policies: [root, platform-admin] + mount_paths: [database/creds/audit-core-migration] + database: + role: audit-core-migration + host: platform-pg-rw.databases.svc.cluster.local + port: 5432 + name: audit_core + env: {PGUSER: username, PGPASSWORD: password, PGHOST: host, PGPORT: port, PGDATABASE: database} + ttl: {default: 10m, max: 30m, renewable: false, requires_human_above: 30m} + actors: + allowed_types: [human-operator, approved-agent, ci-runner] + required_subject_binding: keycape-or-kubernetes-service-account + authorization: + flex_auth_required: false + flex_auth_mode: optional-preflight + approval_required: false + purpose_required: true + allowed_purpose_examples: [audit-core-migration] + delivery: + allowed: [exec-env] + preferred: exec-env + denied: [chat, state-hub-body, git, command-line-token-argument, llm-prompt] + exec_env: {child_only: true, redact_logs: true} + audit: {openbao_audit_required: true, state_hub_metadata_allowed: true, record_secret_values: false} + revocation: {required: true, by_accessor: true, on_exec_exit: true, on_denied_request: false} + - id: ops-warden/warden-sign title: Ops Warden OpenBao SSH signing smoke token status: pilot diff --git a/docs/rapp-postgres-boundary.md b/docs/rapp-postgres-boundary.md new file mode 100644 index 0000000..41edc1e --- /dev/null +++ b/docs/rapp-postgres-boundary.md @@ -0,0 +1,17 @@ +# rapp-postgres package boundary + +`rapp-postgres` owns the PostgreSQL package manifests, consumer declarations, +role/database provisioning surface, isolation tests, database-specific +operations, and recovery procedure for the shared `platform-pg` service. + +`railiance-platform` retains cluster-wide governance: the CNPG operator, +Kubernetes access, storage-class policy, monitoring substrate, approved S3 +backup target and its credentials, and the OpenBao credential-broker grant +catalog. It configures the OpenBao database secrets engine from the creation +and revocation statements owned in the rapp-postgres playbook; it does not copy +those procedures into this repo. + +The package never commits credentials. A consumer request is declared and +provisioned by rapp-postgres, while the workload receives a short-lived lease +through the platform broker. The upstream backup target and database-engine +bootstrap are governance prerequisites, not package-owned secret material. diff --git a/scripts/credential-grants-validate.py b/scripts/credential-grants-validate.py index 6f6f194..32cd1e5 100755 --- a/scripts/credential-grants-validate.py +++ b/scripts/credential-grants-validate.py @@ -29,7 +29,7 @@ REQUIRED_DENIED_MODES = { "llm-prompt", } -ALLOWED_CREDENTIAL_TYPES = {"openbao-token"} +ALLOWED_CREDENTIAL_TYPES = {"openbao-token", "openbao-database-credential"} ALLOWED_GRANT_CLASSES = {"self-service", "approval-required", "break-glass"} ALLOWED_GRANT_STATUSES = {"pilot", "active", "deprecated", "disabled"} DISALLOWED_POLICIES = {"root", "platform-admin"} @@ -151,6 +151,16 @@ def validate_grant( ) require_list(openbao.get("mount_paths"), f"{prefix}.openbao.mount_paths", errors) + if credential_type == "openbao-database-credential": + database = require_dict(grant_obj.get("database"), f"{prefix}.database", errors) + require_nonempty_string(database.get("role"), f"{prefix}.database.role", errors) + require_nonempty_string(database.get("host"), f"{prefix}.database.host", errors) + require_nonempty_string(database.get("name"), f"{prefix}.database.name", errors) + env = require_dict(database.get("env"), f"{prefix}.database.env", errors) + required_fields = {"username", "password", "host", "port", "database"} + if set(env.values()) != required_fields: + errors.append(f"{prefix}.database.env must map exactly {sorted(required_fields)}") + ttl = require_dict(grant_obj.get("ttl"), f"{prefix}.ttl", errors) default_ttl = ttl_seconds(ttl.get("default"), f"{prefix}.ttl.default", errors) max_ttl = ttl_seconds(ttl.get("max"), f"{prefix}.ttl.max", errors) @@ -228,6 +238,8 @@ def validate_grant( ) if preferred and preferred not in allowed: errors.append(f"{prefix}.delivery.preferred must be in delivery.allowed") + if credential_type == "openbao-database-credential" and allowed != {"exec-env"}: + errors.append(f"{prefix}.delivery.allowed must be exec-env only for database credentials") if "local-token-file" in allowed: local_file = require_dict( delivery.get("local_token_file"), diff --git a/scripts/credential.py b/scripts/credential.py index 56cb82c..3e50deb 100755 --- a/scripts/credential.py +++ b/scripts/credential.py @@ -125,7 +125,10 @@ def get_grant(catalog: dict[str, Any], grant_id: str) -> dict[str, Any]: fail("catalog grants must be a list") for grant in grants: if isinstance(grant, dict) and grant.get("id") == grant_id: - if grant.get("credential_type") != "openbao-token": + if grant.get("credential_type") not in { + "openbao-token", + "openbao-database-credential", + }: fail( f"unsupported credential_type for {grant_id}: {grant.get('credential_type')}" ) @@ -414,6 +417,7 @@ class BaoRunner: dry_run: bool, use_token_helper: bool, issuer_token: str | None, + local_bao: bool = False, ) -> None: self.kubectl_parts = shlex.split(kubectl) self.namespace = namespace @@ -421,6 +425,7 @@ class BaoRunner: self.dry_run = dry_run self.use_token_helper = use_token_helper self.issuer_token = issuer_token + self.local_bao = local_bao def run( self, args: list[str], *, input_text: str | None = None, quiet: bool = False @@ -429,6 +434,28 @@ class BaoRunner: print("DRY-RUN: bao " + shlex.join(args)) return subprocess.CompletedProcess(args, 0, "", "") + if self.local_bao: + if not self.issuer_token: + raise RuntimeError("issuer token is required for --local-bao") + env = os.environ.copy() + env["BAO_TOKEN"] = self.issuer_token + env["VAULT_TOKEN"] = self.issuer_token + result = subprocess.run( + ["bao", *args], input=input_text, env=env, + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + if result.stdout and not quiet: + print(redact(result.stdout, [self.issuer_token]), end="") + if result.stderr: + print(redact(result.stderr, [self.issuer_token]), file=sys.stderr, end="") + raise SystemExit(result.returncode) + if result.stdout and not quiet: + print(redact(result.stdout, [self.issuer_token]), end="") + if result.stderr and not quiet: + print(redact(result.stderr, [self.issuer_token]), file=sys.stderr, end="") + return result + if self.use_token_helper: cmd = ( self.kubectl_parts @@ -474,6 +501,54 @@ class BaoRunner: print(redact(result.stderr), file=sys.stderr, end="") return result + def run_with_token( + self, token: str, args: list[str], *, quiet: bool = False + ) -> subprocess.CompletedProcess[str]: + if self.dry_run: + print("DRY-RUN: bao " + shlex.join(args)) + return subprocess.CompletedProcess(args, 0, "", "") + if self.local_bao: + env = os.environ.copy() + env["BAO_TOKEN"] = token + env["VAULT_TOKEN"] = token + result = subprocess.run( + ["bao", *args], env=env, capture_output=True, text=True, check=False + ) + if result.returncode != 0: + if result.stdout and not quiet: + print(redact(result.stdout, [token]), end="") + if result.stderr: + print(redact(result.stderr, [token]), file=sys.stderr, end="") + raise SystemExit(result.returncode) + if result.stdout and not quiet: + print(redact(result.stdout, [token]), end="") + if result.stderr and not quiet: + print(redact(result.stderr, [token]), file=sys.stderr, end="") + return result + cmd = ( + self.kubectl_parts + + [ + "exec", "-i", "-n", self.namespace, self.pod, "--", "sh", "-c", + 'read -r BAO_TOKEN; export BAO_TOKEN; export VAULT_TOKEN="$BAO_TOKEN"; exec bao "$@"', + "sh", + ] + + args + ) + result = subprocess.run( + cmd, input=token + "\n", capture_output=True, text=True, check=False + ) + if result.returncode != 0: + if result.stdout and not quiet: + print(redact(result.stdout, [token]), end="") + if result.stderr: + print(redact(result.stderr, [token]), file=sys.stderr, end="") + raise SystemExit(result.returncode) + if result.stdout and not quiet: + print(redact(result.stdout, [token]), end="") + if result.stderr and not quiet: + print(redact(result.stderr, [token]), file=sys.stderr, end="") + return result + def token_create_args( grant: dict[str, Any], ttl: str, wrap_ttl: str | None = None @@ -501,6 +576,16 @@ def parse_token_create(stdout: str) -> tuple[str, str]: return token, accessor +def parse_database_credential(stdout: str) -> tuple[str, str, str]: + try: + payload = json.loads(stdout) + return payload["lease_id"], payload["data"]["username"], payload["data"]["password"] + except Exception as exc: # noqa: BLE001 + raise SystemExit( + f"ERROR: could not parse database credential response: {exc}" + ) from exc + + def parse_wrap_create(stdout: str) -> dict[str, Any]: try: payload = json.loads(stdout) @@ -793,6 +878,12 @@ def command_exec( args=args, grant=grant, ttl=ttl, purpose=args.purpose, delivery="exec-env" ) extra_env, program = split_env_prefix(args.command) + credential_type = grant["credential_type"] + child_env = [*extra_env.keys()] + if credential_type == "openbao-token": + child_env.append("VAULT_TOKEN") + else: + child_env.extend((grant.get("database") or {}).get("env", {}).keys()) if args.dry_run: emit_json( { @@ -804,7 +895,7 @@ def command_exec( "delivery_mode": "exec-env", "authorization_mode": authz.mode, "decision_id": authz.decision_id, - "child_env": sorted([*extra_env.keys(), "VAULT_TOKEN"]), + "child_env": sorted(child_env), "child_command": program, } ) @@ -837,6 +928,28 @@ def command_exec( ) result = runner.run(token_create_args(grant, ttl), quiet=True) token, accessor = parse_token_create(result.stdout) + lease_handle = accessor + secret_values = [token] + injected: dict[str, str] + if credential_type == "openbao-token": + injected = {"VAULT_TOKEN": token} + else: + database = grant.get("database") or {} + role = database.get("role") + env_map = database.get("env") or {} + credential = runner.run_with_token( + token, ["read", "-format=json", f"database/creds/{role}"], quiet=True + ) + lease_handle, username, password = parse_database_credential(credential.stdout) + values = { + "username": username, + "password": password, + "host": str(database.get("host", "")), + "port": str(database.get("port", "5432")), + "database": str(database.get("name", "")), + } + injected = {name: values[field] for name, field in env_map.items()} + secret_values.extend((username, password)) record_state_hub( args, state_hub_metadata( @@ -847,12 +960,12 @@ def command_exec( ttl=ttl, delivery="exec-env", authz=authz, - lease_handle=accessor, + lease_handle=lease_handle, ), ) env = os.environ.copy() env.update(extra_env) - env["VAULT_TOKEN"] = token + env.update(injected) exit_code = 1 try: child = subprocess.run( @@ -860,14 +973,14 @@ def command_exec( ) exit_code = child.returncode if child.stdout: - print(redact(child.stdout, [token]), end="") + print(redact(child.stdout, secret_values), end="") if child.stderr: - print(redact(child.stderr, [token]), file=sys.stderr, end="") + print(redact(child.stderr, secret_values), file=sys.stderr, end="") return child.returncode finally: - runner.run( - ["write", "auth/token/revoke-accessor", f"accessor={accessor}"], quiet=True - ) + if credential_type == "openbao-database-credential": + runner.run(["lease", "revoke", lease_handle], quiet=True) + runner.run(["write", "auth/token/revoke-accessor", f"accessor={accessor}"], quiet=True) record_state_hub( args, state_hub_metadata( @@ -878,7 +991,7 @@ def command_exec( ttl=ttl, delivery="exec-env", authz=authz, - lease_handle=accessor, + lease_handle=lease_handle, exit_code=exit_code, ), ) @@ -962,6 +1075,10 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Use the OpenBao CLI token helper inside the pod", ) + parser.add_argument( + "--local-bao", action="store_true", + help="Run the workstation bao CLI instead of execing into the server pod", + ) parser.add_argument("--lease-dir", type=Path, default=DEFAULT_LEASE_DIR) parser.add_argument( "--actor", default=os.environ.get("CREDENTIAL_ACTOR", DEFAULT_ACTOR) @@ -1063,6 +1180,7 @@ def main() -> int: dry_run=args.dry_run, use_token_helper=args.use_token_helper, issuer_token=issuer_token, + local_bao=args.local_bao, ) if args.command_name == "request": diff --git a/tests/test_credential_helper.py b/tests/test_credential_helper.py index 0768b3d..fc4061f 100644 --- a/tests/test_credential_helper.py +++ b/tests/test_credential_helper.py @@ -50,6 +50,14 @@ def sample_grant() -> dict: class CredentialHelperTests(unittest.TestCase): + 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"}}' + ) + self.assertEqual(lease_id, "database/creds/runtime/lease-1") + self.assertEqual(username, "leased-user") + self.assertEqual(password, "leased-secret") + def test_ttl_over_max_is_rejected(self) -> None: with self.assertRaises(SystemExit): credential.validate_issue_request(