Close RAILIANCE-WP-0015-T06 rapp credential-lane binding
Document the one recipe a new rapp uses to acquire runtime secrets: standing KV secrets bind through a CCR target.rapp, leases through grant rapp_id. Stamp the existing postgres grants and the qonto workload CCR. Gate, delivery, and revocation are unchanged.
This commit is contained in:
parent
6ab882cc44
commit
dfa6373985
10 changed files with 342 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
188
docs/rapp-credential-lane-binding.md
Normal file
188
docs/rapp-credential-lane-binding.md
Normal file
|
|
@ -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>/<consumer>-<role>
|
||||
rapp_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 `<rapp_id>/<consumer>-<role>` 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 `<rapp_id>/<consumer>-<role>` 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/<role>` 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
91
tests/test_credential_grants.py
Normal file
91
tests/test_credential_grants.py
Normal file
|
|
@ -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))
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue