diff --git a/credential-change-requests/CCR-2026-0009-qonto-assistant-workload-kv-read.yaml b/credential-change-requests/CCR-2026-0009-qonto-assistant-workload-kv-read.yaml index 546516e..f9c7503 100644 --- a/credential-change-requests/CCR-2026-0009-qonto-assistant-workload-kv-read.yaml +++ b/credential-change-requests/CCR-2026-0009-qonto-assistant-workload-kv-read.yaml @@ -5,7 +5,7 @@ request_type: workload-kv-read title: qonto-assistant workload access to tenants/binky/qonto-api status: proposed created: '2026-07-24' -updated: '2026-07-24' +updated: '2026-08-13' requester: agent: claude reason: >- @@ -26,6 +26,7 @@ target: domain: financials tenant: binky workload: qonto-assistant + rapp: rapp-qonto environment: production purpose: >- Workload (not human/admin) read access to the existing diff --git a/credential-grants/catalog.yaml b/credential-grants/catalog.yaml index f630d53..3add477 100644 --- a/credential-grants/catalog.yaml +++ b/credential-grants/catalog.yaml @@ -1,5 +1,5 @@ version: 1 -updated: "2026-08-10" +updated: "2026-08-13" owner_repo: railiance-platform owner_domain: financials workplan_id: RAILIANCE-WP-0005 @@ -26,6 +26,7 @@ grant_classes: grants: - id: rapp-postgres/audit-core-runtime title: audit-core runtime PostgreSQL lease + rapp_id: rapp-postgres status: active grant_class: self-service credential_type: openbao-database-credential @@ -65,6 +66,7 @@ grants: - id: rapp-postgres/audit-core-migration title: audit-core migration PostgreSQL lease + rapp_id: rapp-postgres status: active grant_class: self-service credential_type: openbao-database-credential diff --git a/docs/rapp-credential-lane-binding.md b/docs/rapp-credential-lane-binding.md new file mode 100644 index 0000000..2962312 --- /dev/null +++ b/docs/rapp-credential-lane-binding.md @@ -0,0 +1,188 @@ +# Rapp credential-lane binding + +Work record: `RAILIANCE-WP-0015-T06` +Owner: `railiance-platform` (S3) + +This is the single recipe a new rapp uses to acquire runtime secrets. It +binds the existing S3 lanes. It does not invent a package-local broker, +change grant TTLs, delivery modes, approval, or revocation. + +## The rule + +A rapp **declares** what it needs. S3 **vends** it through the lanes that +already exist. The package never commits a credential, never holds a +grant catalog, and never files a CCR in the rapp repo. + +```text +rapp.yaml S3 lane object live delivery +───────── ────────────── ───────────── +runtime_dependencies[] ──► capability (who satisfies it + is not the rapp's problem) + +secret_references[] ──► CCR (standing KV secret) + openbao.kv_path == the reference + workload lane: delivery.surface + is external-secrets / kubernetes + +consumer_contract ──► grant catalog (short-lived lease) + + consumers[] id: /- + rapp_id: +``` + +Two existing lane objects. Not a third. + +| Need | Lane | Home | How the rapp names it | +| --- | --- | --- | --- | +| Standing secret (API key, provider token, webhook secret) | Credential change request | `credential-change-requests/CCR-*.yaml` | `secret_references` lists the OpenBao path | +| Short-lived lease (dynamic DB password, bounded OpenBao token) | Grant catalog | `credential-grants/catalog.yaml` | grant `id` is `/-` and grant `rapp_id` equals the package | + +Operator/human fetch of the same KV path is a **separate** CCR +(`delivery.surface: operator-workstation`). It may share the path. It is +not the rapp's runtime bind. + +## What each rapp field means + +`runtime_dependencies` names **capabilities**. The schema is explicit: +the list does not say who provisions the thing that satisfies a name. +`openbao-database-secrets-engine` and `s3-backup-target` are capabilities. +The database engine is configured by S3 from statements the package +owns; the backup bucket is a `resource-control` resource and the +provider credential is S3 custody (`Secret platform-pg-backup-s3`). +Fail-closed until that handoff lands is correct, not a hole in the +package. + +`secret_references` names **OpenBao paths**, never values. Each path on +a placed rapp must have exactly one **workload** CCR whose +`openbao.kv_path` matches. "Workload" means the CCR's delivery surface +is the in-cluster consumer (External Secrets or Kubernetes auth), not +an operator workstation. A rapp with no standing KV secret omits the +field. + +`consumer_contract.credential_lane` names the **broker**, not a grant. +Today that value is `railiance-platform-credential-broker`. Individual +consumer leases are catalog entries bound by `rapp_id`. + +## Choose the lane + +1. Is the secret a long-lived value that a pod must hold across + restarts? File a CCR. Delivery is External Secrets into a + namespace-scoped Secret. The CCR is the approval, policy, auth role, + and ops-warden front door. Follow `docs/credential-change-approval.md` + and `docs/credential-lane-lifecycle-runbook.md`. +2. Is the secret something that should exist only for the length of a + connection or an attended command? Add a grant. Delivery is + `scripts/credential.py exec` (or in-cluster database/creds). Follow + `docs/credential-broker.md`. +3. Never both for the same need. A CCR is not a grant with a longer + TTL. A grant is not a KV write. + +Do not open a package-local OpenBao policy, a rapp-owned Secret +manifest with a value, or a second catalog. + +## Binding fields + +These fields are pointers. They do not change who may approve, how a +lease is minted, or how it is revoked. + +**Grant catalog** — required when the grant id starts with `rapp-`: + +```yaml +id: rapp-postgres/audit-core-runtime +rapp_id: rapp-postgres +``` + +`rapp_id` must equal the id prefix before `/`. The validator enforces +that. Grants that are not package-owned (`ops-warden/warden-sign`) omit +the field. + +**CCR** — set when the lane is the runtime bind for a rapp: + +```yaml +target: + workload: qonto-assistant # existing; the running unit + rapp: rapp-qonto # optional; the package that owns rollout +``` + +`target.rapp` is optional because most live CCRs belong to workloads +that are not yet packages. Adding it is part of extracting a rapp, not +a reason to invent a CCR. The validator accepts the field and rejects a +value that is not a `rapp-*` slug. + +`target.workload` stays the workload identity. It must not become the +repo slug. + +## Recipe for a new rapp + +Do these in order. Stop if a step would change a gate, a TTL, a +delivery mode, or a revocation rule — that work is a CCR or a grant +change, not a rapp-local patch. + +1. Write `runtime_dependencies` for every capability the package + assumes, including secret-bearing ones. +2. Decide standing-secret vs lease for each credential need. +3. Standing secret: put the OpenBao path in `secret_references`. File + the CCR in this repo (`scripts/credential-change.py`), set + `target.rapp`, and keep policy HCL here. The rapp repo may hold only + the ExternalSecret that *consumes* the store S3 applies. +4. Lease: add a grant whose id is `/-` and + whose `rapp_id` matches. For a provisioning rapp, declare the + consumer in `consumers:` and keep + `consumer_contract.credential_lane: railiance-platform-credential-broker`. +5. Validate without minting anything: + + ```bash + make credential-grants-validate + make credential-change-validate + ``` + +6. Apply and verify through the existing attended helpers. Do not add a + rapp-local apply path. + +A workload that is not yet a rapp keeps using a CCR or grant with no +`rapp` / `rapp_id`. Extraction adds the pointer; it does not restamp +the secret. + +## Worked examples + +### `rapp-openbao` — the store, not a consumer + +No `secret_references`, no grant, no CCR. OpenBao is the custody +engine. Package-owned smoke stays non-secret. Authenticated policy and +audit checks stay in S3. A later platform rapp must not copy this +shape unless it *is* a secrets engine. + +### `rapp-postgres` — provisioning rapp, lease lane + +- `runtime_dependencies` includes `openbao-database-secrets-engine` and + `s3-backup-target`. +- `consumer_contract.credential_lane` is the broker. +- Catalog grants `rapp-postgres/audit-core-runtime` and + `rapp-postgres/audit-core-migration` carry `rapp_id: rapp-postgres`. +- No `secret_references`: the package does not read a KV path. Consumers + receive a short-lived `database/creds/` lease. +- `s3-backup-target` is unsatisfied until `resource-control` hands an + endpoint. `make postgres-backup-deploy` stays fail-closed. That is + not a missing CCR on this package. + +### `rapp-qonto` — consuming rapp, standing-secret lane + +- `secret_references: [tenants/binky/qonto-api]`. +- Workload CCR: `CCR-2026-0009` (`delivery.surface: external-secrets`, + `target.rapp: rapp-qonto`). That is the runtime bind. +- Operator CCR: `CCR-2026-0008` (same path, workstation delivery). + Not the rapp bind. Rotating the value rotates both lanes because they + share the secret; deactivating the operator front door must not be + mistaken for taking the workload offline. + +## Current bind table + +| Rapp | Declaration | S3 lane | Status | +| --- | --- | --- | --- | +| `rapp-openbao` | none | none — package is the store | correct | +| `rapp-postgres` | `consumer_contract` + `openbao-database-secrets-engine` | `rapp-postgres/audit-core-runtime`, `rapp-postgres/audit-core-migration` | bound | +| `rapp-postgres` | `s3-backup-target` | no lane yet; Secret `platform-pg-backup-s3` is S3 custody after RESOURCE-WP-0002 | fail-closed on purpose | +| `rapp-qonto` | `secret_references: tenants/binky/qonto-api` | `CCR-2026-0009` | pointer set; CCR itself is still `proposed` | + +Live CCRs without `target.rapp` are un-rapped workloads. They stay on +the CCR lane. They do not get a grant or a package invented to make +this table look complete. diff --git a/docs/rapp-platform-service-pattern.md b/docs/rapp-platform-service-pattern.md index a919a6d..46682bb 100644 --- a/docs/rapp-platform-service-pattern.md +++ b/docs/rapp-platform-service-pattern.md @@ -193,11 +193,12 @@ explicitly in `consumers:`. That is the shape to copy. ## Credential lanes A platform-service rapp never owns credential custody. It declares what it -needs; S3 vends it through the existing broker. The binding between a rapp's -`runtime_dependencies` / `secret_references` and the S3 grant catalog and CCR -lanes is specified in `RAILIANCE-WP-0015-T06` — until that lands, follow -`docs/credential-broker.md` and `docs/credential-change-approval.md` directly -and do not create a package-local lane. +needs; S3 vends it through the existing broker. The bind is +`docs/rapp-credential-lane-binding.md`: standing secrets go through a CCR +and `secret_references`; short-lived leases go through the grant catalog +and `rapp_id`. Follow `docs/credential-broker.md` and +`docs/credential-change-approval.md` for gate, delivery, and revocation — +this pattern does not replace them. The existing rule holds without exception: the package never commits credentials, and a workload receives a short-lived lease through the platform diff --git a/schemas/credential-change-request.schema.yaml b/schemas/credential-change-request.schema.yaml index 7e6551e..3eb8610 100644 --- a/schemas/credential-change-request.schema.yaml +++ b/schemas/credential-change-request.schema.yaml @@ -52,6 +52,11 @@ secret_markers_rejected: - ghp_ - sk- +# Optional. Set when this CCR is the runtime bind for a rapp (RAILIANCE-WP-0015-T06). +# target.rapp is a rapp-* slug. It does not replace target.workload. +optional_target_fields: + - rapp + workload_kv_read: required: openbao: diff --git a/scripts/credential-change.py b/scripts/credential-change.py index aaf8402..03cdfb8 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -63,6 +63,7 @@ FRONTDOOR_READINESS = { "compromised", } SAFE_ID_RE = re.compile(r"^[A-Z0-9][A-Z0-9_.-]*$") +RAPP_SLUG_RE = re.compile(r"^rapp-[a-z0-9]+(-[a-z0-9]+)*$") TTL_RE = re.compile(r"^[1-9][0-9]*[smhd]$") LOWER_SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") FIELD_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") @@ -191,6 +192,10 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: target = require_object(ccr.get("target"), "target", errors) for field in ("domain", "tenant", "workload", "environment", "purpose"): require_string(target.get(field), f"target.{field}", errors) + rapp = target.get("rapp") + if rapp is not None: + if not isinstance(rapp, str) or not RAPP_SLUG_RE.match(rapp): + errors.append("target.rapp must be a rapp-* slug when set") openbao = require_object(ccr.get("openbao"), "openbao", errors) mount = require_string(openbao.get("mount"), "openbao.mount", errors) diff --git a/scripts/credential-grants-validate.py b/scripts/credential-grants-validate.py index 32cd1e5..d2f341b 100755 --- a/scripts/credential-grants-validate.py +++ b/scripts/credential-grants-validate.py @@ -30,6 +30,7 @@ REQUIRED_DENIED_MODES = { } ALLOWED_CREDENTIAL_TYPES = {"openbao-token", "openbao-database-credential"} +RAPP_SLUG_RE = re.compile(r"^rapp-[a-z0-9]+(-[a-z0-9]+)*$") ALLOWED_GRANT_CLASSES = {"self-service", "approval-required", "break-glass"} ALLOWED_GRANT_STATUSES = {"pilot", "active", "deprecated", "disabled"} DISALLOWED_POLICIES = {"root", "platform-admin"} @@ -84,6 +85,20 @@ def validate_grant( return "" grant_id = require_nonempty_string(grant_obj.get("id"), f"{prefix}.id", errors) + rapp_id = grant_obj.get("rapp_id") + if rapp_id is not None: + if not isinstance(rapp_id, str) or not RAPP_SLUG_RE.match(rapp_id): + errors.append(f"{prefix}.rapp_id must be a rapp-* slug when set") + if grant_id.startswith("rapp-"): + prefix_id = grant_id.split("/", 1)[0] + if not rapp_id: + errors.append( + f"{prefix}.rapp_id is required when id starts with rapp-" + ) + elif isinstance(rapp_id, str) and rapp_id != prefix_id: + errors.append( + f"{prefix}.rapp_id must equal the grant id prefix {prefix_id!r}" + ) require_nonempty_string(grant_obj.get("title"), f"{prefix}.title", errors) require_nonempty_string( grant_obj.get("description"), f"{prefix}.description", errors diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index e10283d..50682f7 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -67,6 +67,23 @@ class CredentialChangeTests(unittest.TestCase): _ccr, errors, _warnings = credential_change.validate_ccr(path) self.assertEqual(errors, []) + def test_qonto_workload_ccr_binds_rapp(self) -> None: + path = ( + REPO_DIR + / "credential-change-requests/CCR-2026-0009-qonto-assistant-workload-kv-read.yaml" + ) + ccr, errors, _warnings = credential_change.validate_ccr(path) + self.assertEqual(errors, []) + self.assertEqual(ccr["target"]["rapp"], "rapp-qonto") + + def test_target_rapp_rejects_non_slug(self) -> None: + path = self.unapproved_ccr() + data = credential_change.load_yaml(path) + data["target"]["rapp"] = "qonto" + credential_change.dump_yaml(path, data) + _ccr, errors, _warnings = credential_change.validate_ccr(path) + self.assertTrue(any("target.rapp" in error for error in errors)) + def test_render_summary_contains_review_fields(self) -> None: ccr, _errors, warnings = credential_change.validate_ccr(self.sample) rendered = credential_change.render_summary(ccr, warnings) diff --git a/tests/test_credential_grants.py b/tests/test_credential_grants.py new file mode 100644 index 0000000..fc81816 --- /dev/null +++ b/tests/test_credential_grants.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "credential_grants_validate", REPO_DIR / "scripts/credential-grants-validate.py" +) +grants = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = grants +SPEC.loader.exec_module(grants) + + +class CredentialGrantBindTests(unittest.TestCase): + def test_catalog_validates(self) -> None: + errors: list[str] = [] + catalog = grants.yaml.safe_load( + (REPO_DIR / "credential-grants/catalog.yaml").read_text() + ) + for index, grant in enumerate(catalog["grants"]): + grants.validate_grant(grant, index, catalog, errors) + self.assertEqual(errors, []) + + def test_postgres_grants_bind_rapp(self) -> None: + catalog = grants.yaml.safe_load( + (REPO_DIR / "credential-grants/catalog.yaml").read_text() + ) + bound = { + grant["id"]: grant.get("rapp_id") + for grant in catalog["grants"] + if str(grant["id"]).startswith("rapp-") + } + self.assertEqual( + bound, + { + "rapp-postgres/audit-core-runtime": "rapp-postgres", + "rapp-postgres/audit-core-migration": "rapp-postgres", + }, + ) + + def test_rapp_prefix_requires_matching_rapp_id(self) -> None: + errors: list[str] = [] + grant = { + "id": "rapp-postgres/audit-core-runtime", + "title": "x", + "description": "x", + "status": "active", + "grant_class": "self-service", + "credential_type": "openbao-database-credential", + "issuer": "openbao", + "audience": "audit-core", + "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"], + }, + "ttl": {"default": "15m", "max": "1h", "renewable": False}, + "actors": {"allowed_types": ["human-operator"]}, + "authorization": { + "flex_auth_required": False, + "purpose_required": True, + }, + "delivery": { + "allowed": ["exec-env"], + "preferred": "exec-env", + "denied": [ + "chat", + "state-hub-body", + "git", + "command-line-token-argument", + "llm-prompt", + ], + }, + "audit": { + "openbao_audit_required": True, + "record_secret_values": False, + }, + "revocation": {"required": True, "by_accessor": True}, + } + grants.validate_grant(grant, 0, {}, errors) + self.assertTrue(any("rapp_id is required" in error for error in errors)) + errors.clear() + grant["rapp_id"] = "rapp-openbao" + grants.validate_grant(grant, 0, {}, errors) + self.assertTrue(any("must equal the grant id prefix" in error for error in errors)) diff --git a/workplans/RAILIANCE-WP-0015-platform-rapp-consistency.md b/workplans/RAILIANCE-WP-0015-platform-rapp-consistency.md index 700c953..1eb0861 100644 --- a/workplans/RAILIANCE-WP-0015-platform-rapp-consistency.md +++ b/workplans/RAILIANCE-WP-0015-platform-rapp-consistency.md @@ -4,7 +4,7 @@ type: workplan title: "Platform rapp consistency and deployment-management contract" domain: financials repo: railiance-platform -status: active +status: finished owner: codex topic_slug: railiance created: "2026-08-11" @@ -174,7 +174,7 @@ the open C-31 failures on multi-segment `RAPP-*-WP-` ids. ```task id: RAILIANCE-WP-0015-T06 -status: todo +status: done priority: medium state_hub_task_id: "9ea10522-47a9-4cce-b0d8-cb6f9c7813b8" ``` @@ -202,4 +202,11 @@ the T06 coverage check is `scripts/capture-reef-deployables.py`. Family-validator remainder is outside this task: `rapp-qonto` still on the pre-schema shape, three undeclared `rapp-*` engine repos, and `reef-railiance` `bound_rapps` -still hand-listed. T06 is open. +still hand-listed. + +Session of 2026-08-13 (later): T06 done. Binding recipe is +`docs/rapp-credential-lane-binding.md`. Standing secrets bind through +a CCR `target.rapp` + `secret_references`; short-lived leases bind +through grant `rapp_id` (required when the grant id is `rapp-*/…`). +Stamped `rapp-postgres` catalog grants and `CCR-2026-0009`. Gate, +delivery, and revocation semantics are unchanged. Workplan finished.