diff --git a/docs/keycape-issuer-eso-contract.md b/docs/keycape-issuer-eso-contract.md new file mode 100644 index 0000000..56cfa1e --- /dev/null +++ b/docs/keycape-issuer-eso-contract.md @@ -0,0 +1,68 @@ +# Proposed fixed issuer ESO contract — RPF-1.3-T4 / NK48-R5-F2 + +Status: proposed source; not admitted, deployed, or owner-acknowledged. This is a +new Platform declaration for review, not an existing Forgejo grant discovery. + +The only substitution in `manifests/templates/keycape-issuer-eso.yaml` +(sha256:e17498bd8a4c820041a4105e4efe9c60c6a50953db8f621ff54887080065c743) is the admitted `client_id`. The proposed issuer +store is **ClusterSecretStore `openbao-keycape-{client_id}-issuer`**, cluster +scoped (no metadata namespace), restricted by conditions to ExternalSecrets in +`sso`. For Forgejo it is `openbao-keycape-forgejo-issuer`. The suffix makes the +issuer purpose explicit and keeps type-admitted IDs ending in `-` representable. +The store's DNS subdomain name can exceed 63 characters; the existing adapter's +63-character target Secret reference bound is preserved (client ID at most 48). +No type parameter is added or changed. + +The provider is `http://openbao.openbao.svc:8200`, KV v2 mount `platform`, +Kubernetes auth mount `kubernetes`, role `external-secrets-keycape-{client_id}`, +serviceAccountRef `external-secrets/external-secrets`, audience `openbao`. +CCR-2026-0041 already records that Forgejo role/identity/audience, not this new +store name or an unrestricted client family grant. Other clients still require +their own existing owner-controlled role/case admission. + +The ExternalSecret is `sso/keycape-{client_id}-client`, references precisely that +ClusterSecretStore, refreshes every 5m and targets the same name with Owner +creation and Retain remote deletion. Its only data entry maps `client-secret` +from remoteRef `workloads/keycape/{client_id}`, property `CLIENT_SECRET`. The +provider mount plus mount-relative remoteRef equals custody reference +`platform/workloads/keycape/{client_id}`; do not double-prefix `platform/`. +This is distinct from the Apps-owned consumer namespace SecretStore +`openbao-keycape-{client_id}-consumer` and its dedicated consumer identity. + +Store provisioning belongs to Platform's separate owner-controlled path. The +proposed `platform-secret-delivery` lane retains ONLY create/get/watch/delete on +the exact ExternalSecret in `sso`; this source does not delegate store creation +or Secret read/list. Never apply the multi-document template through that lane. +The pure `scripts/keycape_issuer_eso.py` module exposes only the ExternalSecret +document through `external_secret()` and validates fixed public observations +through `require_fixed_store()` and `require_ready_delivery()`. It performs no +API call and issues no evidence/receipt. It is not a real capability or admission +boundary; a caller-supplied observation alone establishes no authenticity. + +The existing RPF-1.3-T4 delivery binding must validate the case/client binding, +verify this exact preexisting store's public identity/spec/Ready state, apply or +compare the exact ExternalSecret, and wait for matching spec plus Ready=True. +The proposed helper accepts extra controller metadata but deliberately refuses +spec drift. Actual API defaulting/normalization and installed ESO schema support +must be checked in that implementation; these fixtures establish no native +compatibility or credential isolation. ESO's shared controller remains trusted. +No Secret bodies, values, arbitrary remoteRefs, or raw errors may enter receipts. + +Existing owner requirements remain: closed `netkingdom.deliver-secret.v1` +metadata receipt with authenticated before/after UID/resourceVersion provenance, +unchanged objects reported NO_CHANGE, attempt-session revocation, and created-only +rollback guarded by exact UID/resourceVersion (drift or uncertain ownership goes +to owner reconciliation). The helper neither deletes a target Secret nor creates +an OpenBao role/policy nor changes CCR-0041. These capabilities and catalog routing +remain unimplemented/unaccepted under RPF-1.3-T4. Independent Platform source +review of this declaration precedes adopting the conditional NetKingdom type +text; no descriptor acceptance, acknowledgment, pin, grant, catalog or native +permission is inferred. + +Grounding: CCR-2026-0041 exact role/identity; the approved historical +`docs/provisioning-windows/2026-10-04-1000/keycape/eso.draft.yaml` issuer +ClusterSecretStore and target pattern; `scripts/catalog_lane_scaffold.py` +ClusterSecretStore/namespace restriction and mount-relative remoteRef model; +RPF-1.3-C1 and RPF-1.3-T4 in the frozen owner review. The scaffold's historical +audience field placement differs from the issuer draft; this candidate uses +the issuer draft's serviceAccountRef.audiences, not a claim of installed support. diff --git a/manifests/templates/keycape-issuer-eso.yaml b/manifests/templates/keycape-issuer-eso.yaml new file mode 100644 index 0000000..406a29c --- /dev/null +++ b/manifests/templates/keycape-issuer-eso.yaml @@ -0,0 +1,46 @@ +# PROPOSED SOURCE ONLY — RPF-1.3-T4 / NK48-R5-F2; not admitted or applied. +# Platform controls this template. Only admitted client_id is substituted. +# Provision the store through Platform's separate reviewed owner path; the +# platform-secret-delivery lane applies ONLY the ExternalSecret document. +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: openbao-keycape-{client_id}-issuer +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: external-secrets-keycape-{client_id} + serviceAccountRef: + name: external-secrets + namespace: external-secrets + audiences: + - openbao + conditions: + - namespaces: + - sso +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: keycape-{client_id}-client + namespace: sso +spec: + refreshInterval: 5m + secretStoreRef: + kind: ClusterSecretStore + name: openbao-keycape-{client_id}-issuer + target: + name: keycape-{client_id}-client + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: client-secret + remoteRef: + key: workloads/keycape/{client_id} + property: CLIENT_SECRET diff --git a/scripts/keycape_issuer_eso.py b/scripts/keycape_issuer_eso.py new file mode 100644 index 0000000..8acace3 --- /dev/null +++ b/scripts/keycape_issuer_eso.py @@ -0,0 +1,70 @@ +"""Proposed fixed issuer ESO source contract for RPF-1.3-T4 / NK48-R5-F2. + +Pure public metadata only. No API/credential/Secret access or apply path. This +new declaration requires independent Platform review before type integration. +The delivery lane may use only external_secret(); store provisioning is a +separate owner action and is never delegated by this module. +""" +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +import re + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE = ROOT / 'manifests/templates/keycape-issuer-eso.yaml' + + +class Refused(ValueError): + """Fixed issuer metadata differs; never return an owner delivery receipt.""" + + +def documents(client_id: str) -> tuple[dict, dict]: + # Match the frozen type client_id pattern and existing adapter's derived bound. + # No caller can supply a store name/kind/namespace or an OpenBao path. + if not isinstance(client_id, str) or not re.fullmatch(r'[a-z0-9][a-z0-9-]{1,62}', client_id): + raise Refused('bounded admitted client_id required') + if len('keycape-' + client_id + '-client') > 63: + raise Refused('derived issuer reference exceeds existing adapter bound') + store, external = yaml.safe_load_all(TEMPLATE.read_text().format(client_id=client_id)) + return store, external + + +def external_secret(client_id: str) -> dict: + """The ONLY document the proposed delivery capability may apply.""" + return deepcopy(documents(client_id)[1]) + + +def _matching_public_resource(observed: dict, expected: dict) -> None: + if not isinstance(observed, dict): + raise Refused('public resource observation required') + metadata = observed.get('metadata') + if not isinstance(metadata, dict): + raise Refused('resource metadata required') + expected_namespace = expected['metadata'].get('namespace') + namespace_matches = (metadata.get('namespace') == expected_namespace + if expected_namespace else metadata.get('namespace') in (None, '')) + if (observed.get('apiVersion') != expected['apiVersion'] + or observed.get('kind') != expected['kind'] + or metadata.get('name') != expected['metadata']['name'] + or not namespace_matches + or observed.get('spec') != expected['spec']): + raise Refused('fixed issuer resource differs') + status = observed.get('status') + conditions = status.get('conditions', []) if isinstance(status, dict) else [] + if not isinstance(conditions, list) or not any( + isinstance(c, dict) and c.get('type') == 'Ready' and c.get('status') == 'True' + for c in conditions): + raise Refused('fixed issuer resource not ready') + + +def require_fixed_store(observed: dict, client_id: str) -> None: + """Refuse a missing/drifted/not-ready store; never create it through delivery.""" + _matching_public_resource(observed, documents(client_id)[0]) + + +def require_ready_delivery(observed: dict, client_id: str) -> None: + """Spec equality and Ready precede a created/NO_CHANGE owner receipt.""" + _matching_public_resource(observed, documents(client_id)[1]) diff --git a/tests/test_keycape_issuer_eso.py b/tests/test_keycape_issuer_eso.py new file mode 100644 index 0000000..fd6e75d --- /dev/null +++ b/tests/test_keycape_issuer_eso.py @@ -0,0 +1,129 @@ +"""Public metadata fixtures only: no API, credentials, or native calls.""" +import copy +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import keycape_issuer_eso as contract + + +class FixedIssuerContract(unittest.TestCase): + def observations(self): + resources = contract.documents("forgejo") + for resource in resources: + resource["metadata"].update(uid="public-fixture-uid", resourceVersion="7") + resource["status"] = {"conditions": [{"type": "Ready", "status": "True"}]} + return resources + + def test_independent_forgejo_binding(self): + # Independent owner-source example: CCR0041 role/SA/audience and the + # existing issuer ESO draft's provider/target/relative key convention. + store, external = contract.documents("forgejo") + self.assertEqual(store["kind"], "ClusterSecretStore") + self.assertEqual(store["metadata"], {"name": "openbao-keycape-forgejo-issuer"}) + self.assertEqual(store["spec"], { + "provider": {"vault": { + "server": "http://openbao.openbao.svc:8200", "path": "platform", "version": "v2", + "auth": {"kubernetes": {"mountPath": "kubernetes", + "role": "external-secrets-keycape-forgejo", + "serviceAccountRef": {"name": "external-secrets", "namespace": "external-secrets", + "audiences": ["openbao"]}}}}}, + "conditions": [{"namespaces": ["sso"]}]}) + self.assertEqual(external["metadata"], {"name": "keycape-forgejo-client", "namespace": "sso"}) + self.assertEqual(external["spec"], { + "refreshInterval": "5m", + "secretStoreRef": {"kind": "ClusterSecretStore", "name": "openbao-keycape-forgejo-issuer"}, + "target": {"name": "keycape-forgejo-client", "creationPolicy": "Owner", "deletionPolicy": "Retain"}, + "data": [{"secretKey": "client-secret", "remoteRef": { + "key": "workloads/keycape/forgejo", "property": "CLIENT_SECRET"}}]}) + + def test_valid_ready_metadata_allows_extra_controller_metadata(self): + store, external = self.observations() + contract.require_fixed_store(store, "forgejo") + contract.require_ready_delivery(external, "forgejo") + + def test_store_drift_refuses(self): + changes = [ + ("kind", "SecretStore"), + ("metadata.namespace", "sso"), + ("metadata.name", "caller-store"), + ("spec.provider.vault.server", "https://wrong.invalid"), + ("spec.provider.vault.path", "tenant"), + ("spec.provider.vault.auth.kubernetes.role", "external-secrets-keycape-sibling"), + ("spec.provider.vault.auth.kubernetes.serviceAccountRef.audiences", ["wrong"]), + ("spec.provider.vault.auth.kubernetes.serviceAccountRef.namespace", "sso"), + ("spec.conditions", [{"namespaces": ["sso", "other"]}]), + ] + for path, value in changes: + with self.subTest(path=path): + store = self.observations()[0] + mutate(store, path, value) + with self.assertRaises(contract.Refused): + contract.require_fixed_store(store, "forgejo") + + def test_external_drift_refuses(self): + changes = [ + ("metadata.namespace", ""), ("metadata.namespace", None), ("metadata.namespace", "other"), + ("metadata.name", "keycape-sibling-client"), + ("spec.secretStoreRef.kind", "SecretStore"), + ("spec.secretStoreRef.name", "openbao-keycape-forgejo-consumer"), + ("spec.target.name", "other"), ("spec.target.creationPolicy", "Merge"), + ("spec.target.deletionPolicy", "Delete"), + ("spec.data", [{"secretKey": "client-secret", "remoteRef": { + "key": "platform/workloads/keycape/forgejo", "property": "CLIENT_SECRET"}}]), + ("spec.data", [{"secretKey": "wrong", "remoteRef": { + "key": "workloads/keycape/forgejo", "property": "CLIENT_SECRET"}}]), + ("spec.data", [{"secretKey": "client-secret", "remoteRef": { + "key": "workloads/keycape/forgejo", "property": "wrong"}}]), + ] + for path, value in changes: + with self.subTest(path=path, value=value): + external = self.observations()[1] + mutate(external, path, value) + with self.assertRaises(contract.Refused): + contract.require_ready_delivery(external, "forgejo") + + def test_missing_or_unready_refuses(self): + for check, index in [(contract.require_fixed_store, 0), (contract.require_ready_delivery, 1)]: + for status in [None, {}, [], "bad", {"conditions": None}, + {"conditions": [{"type": "Ready", "status": "False"}]}]: + with self.subTest(check=check.__name__, status=status): + observed = self.observations()[index] + observed["status"] = status + with self.assertRaises(contract.Refused): + check(observed, "forgejo") + for malformed in [None, [], {}, {"metadata": None}]: + with self.assertRaises(contract.Refused): + check(malformed, "forgejo") + + def test_no_caller_store_or_path_override(self): + for invalid in ["", "f", "../forgejo", "forgejo/sibling", "Forgejo", "forgejo\n", "-forgejo", "f" * 49, None]: + with self.subTest(invalid=invalid), self.assertRaises(contract.Refused): + contract.documents(invalid) + with self.assertRaises(TypeError): + contract.external_secret("forgejo", store_name="caller-store") + + def test_type_admitted_trailing_dash_is_preserved(self): + self.assertEqual(contract.external_secret("forgejo-")["metadata"]["name"], + "keycape-forgejo--client") + self.assertEqual(contract.documents("forgejo-")[0]["metadata"]["name"], + "openbao-keycape-forgejo--issuer") + + def test_delivery_document_is_only_external_and_detached(self): + external = contract.external_secret("forgejo") + self.assertEqual(external["kind"], "ExternalSecret") + external["spec"]["secretStoreRef"]["name"] = "tampered" + self.assertEqual(contract.external_secret("forgejo")["spec"]["secretStoreRef"]["name"], + "openbao-keycape-forgejo-issuer") + + +def mutate(resource, path, value): + parts = path.split(".") + for key in parts[:-1]: + resource = resource[key] + resource[parts[-1]] = copy.deepcopy(value) + + +if __name__ == "__main__": + unittest.main(verbosity=2)