Broker audit-core dynamic database credentials
This commit is contained in:
parent
d140e829eb
commit
9f6bdffec4
5 changed files with 245 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
17
docs/rapp-postgres-boundary.md
Normal file
17
docs/rapp-postgres-boundary.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue