diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 40eb0c3..8330298 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -41,6 +41,7 @@ | workplan | RPF-WP-0059 | active | — | workplans/RPF-WP-0059-hourly-cadence-receiver-custody.md | | workplan | RPF-WP-0060 | active | — | workplans/RPF-WP-0060-browser-case-platform-bindings.md | | workplan | RPF-WP-0061 | active | — | workplans/RPF-WP-0061-self-service-ccr-intake.md | +| workplan | RPF-WP-0062 | active | — | workplans/RPF-WP-0062-custodian-agent-observer-identity.md | | task | RPF-WP-ADHOC-2026-10-10-T01 | done | — | workplans/ADHOC-2026-10-10.md | | task | RPF-WP-ADHOC-2026-10-11-T01 | done | — | workplans/ADHOC-2026-10-11.md | | task | RPF-WP-ADHOC-2026-10-11-T02 | done | — | workplans/ADHOC-2026-10-11.md | @@ -185,6 +186,9 @@ | task | RPF-WP-0061-T03 | done | — | workplans/RPF-WP-0061-self-service-ccr-intake.md | | task | RPF-WP-0061-T04 | done | — | workplans/RPF-WP-0061-self-service-ccr-intake.md | | task | RPF-WP-0061-T05 | wait | — | workplans/RPF-WP-0061-self-service-ccr-intake.md | +| task | RPF-WP-0062-T01 | done | — | workplans/RPF-WP-0062-custodian-agent-observer-identity.md | +| task | RPF-WP-0062-T02 | todo | — | workplans/RPF-WP-0062-custodian-agent-observer-identity.md | +| task | RPF-WP-0062-T03 | wait | — | workplans/RPF-WP-0062-custodian-agent-observer-identity.md | | intake | RPF-IN-0001 | closed | — | intakes/intakes.md | | intake | RPF-IN-0002 | open | — | intakes/intakes.md | | intake | RPF-IN-0003 | open | — | intakes/intakes.md | diff --git a/argocd/platform-addons/custodian-agents/clusterrole.yaml b/argocd/platform-addons/custodian-agents/clusterrole.yaml new file mode 100644 index 0000000..00a62e8 --- /dev/null +++ b/argocd/platform-addons/custodian-agents/clusterrole.yaml @@ -0,0 +1,53 @@ +# Curated read-only observer (founder decision 2026-10-20, CUST-WP-0073-T02). +# Verbs: get/list/watch only. Explicitly excluded: secrets, configmaps, +# pods/log, pods/exec, pods/attach, pods/portforward, serviceaccounts/token, +# nodes/proxy, any write verb, any wildcard. Privileged actions go through the +# separate gated executor; nothing here grants writes. +# Enforced offline by tests/test_custodian_agent_rbac.py. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: custodian-supervised-observer + labels: + app.kubernetes.io/part-of: railiance-gitops + railiance-platform/component: custodian-agent-identity +rules: + - apiGroups: [""] + resources: + - namespaces + - nodes + - pods + - services + - endpoints + - events + - persistentvolumeclaims + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: + - deployments + - statefulsets + - daemonsets + - replicasets + verbs: ["get", "list", "watch"] + - apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["events.k8s.io"] + resources: ["events"] + verbs: ["get", "list", "watch"] + - apiGroups: ["argoproj.io"] + resources: + - applications + - appprojects + verbs: ["get", "list", "watch"] + - apiGroups: ["postgresql.cnpg.io"] + resources: + - clusters + - backups + - scheduledbackups + verbs: ["get", "list", "watch"] + - nonResourceURLs: ["/version", "/healthz", "/livez", "/readyz"] + verbs: ["get"] diff --git a/argocd/platform-addons/custodian-agents/clusterrolebinding.yaml b/argocd/platform-addons/custodian-agents/clusterrolebinding.yaml new file mode 100644 index 0000000..4cd74a9 --- /dev/null +++ b/argocd/platform-addons/custodian-agents/clusterrolebinding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: custodian-agent-supervised-observer + labels: + app.kubernetes.io/part-of: railiance-gitops + railiance-platform/component: custodian-agent-identity +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: custodian-supervised-observer +subjects: + - kind: ServiceAccount + name: custodian-agent + namespace: custodian-agents diff --git a/argocd/platform-addons/custodian-agents/kustomization.yaml b/argocd/platform-addons/custodian-agents/kustomization.yaml new file mode 100644 index 0000000..647c117 --- /dev/null +++ b/argocd/platform-addons/custodian-agents/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# RPF-WP-0062 / CUST-WP-0073-T02. Read only by +# argocd/railiance01/applications/custodian-agents.application.yaml. +# Read-only observer identity for supervised custodian agents. No Secret, no +# static token, no write verb. Credentials are short-lived TokenRequest tokens +# issued by the supervisor (see workplans/RPF-WP-0062-custodian-agent-observer-identity.md). + +resources: + - namespace.yaml + - serviceaccount.yaml + - clusterrole.yaml + - clusterrolebinding.yaml diff --git a/argocd/platform-addons/custodian-agents/namespace.yaml b/argocd/platform-addons/custodian-agents/namespace.yaml new file mode 100644 index 0000000..ee64b53 --- /dev/null +++ b/argocd/platform-addons/custodian-agents/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: custodian-agents + labels: + app.kubernetes.io/part-of: railiance-gitops + railiance-platform/component: custodian-agent-identity diff --git a/argocd/platform-addons/custodian-agents/serviceaccount.yaml b/argocd/platform-addons/custodian-agents/serviceaccount.yaml new file mode 100644 index 0000000..648d6dd --- /dev/null +++ b/argocd/platform-addons/custodian-agents/serviceaccount.yaml @@ -0,0 +1,11 @@ +# No token Secret is declared for this ServiceAccount, and none may be added. +# Tokens are issued per session by the supervisor via TokenRequest. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: custodian-agent + namespace: custodian-agents + labels: + app.kubernetes.io/part-of: railiance-gitops + railiance-platform/component: custodian-agent-identity +automountServiceAccountToken: false diff --git a/tests/test_custodian_agent_rbac.py b/tests/test_custodian_agent_rbac.py new file mode 100644 index 0000000..f587d0a --- /dev/null +++ b/tests/test_custodian_agent_rbac.py @@ -0,0 +1,110 @@ +"""Offline guard for the custodian agent observer identity (RPF-WP-0062, CUST-WP-0073-T02). + +Parses the GitOps manifests and asserts the founder's 2026-10-20 boundary: +read-only verbs, curated resources, no forbidden resources, no wildcards, +no static token Secret, no token automount, and a manual, pinned Application. +""" +from pathlib import Path +import re + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[1] +ADDON = ROOT / "argocd/platform-addons/custodian-agents" +APP = ROOT / "argocd/railiance01/applications/custodian-agents.application.yaml" + +READ_VERBS = {"get", "list", "watch"} +FORBIDDEN_RESOURCES = { + "secrets", "configmaps", "pods/log", "pods/exec", "pods/attach", + "pods/portforward", "serviceaccounts/token", "serviceaccounts", "nodes/proxy", + "services/proxy", "pods/proxy", "tokenreviews", "subjectaccessreviews", + "clusterroles", "clusterrolebindings", "roles", "rolebindings", +} +EXPECTED = { + "": {"namespaces", "nodes", "pods", "services", "endpoints", "events", + "persistentvolumeclaims"}, + "apps": {"deployments", "statefulsets", "daemonsets", "replicasets"}, + "discovery.k8s.io": {"endpointslices"}, + "networking.k8s.io": {"ingresses"}, + "events.k8s.io": {"events"}, + "argoproj.io": {"applications", "appprojects"}, + "postgresql.cnpg.io": {"clusters", "backups", "scheduledbackups"}, +} +EXPECTED_NON_RESOURCE = {"/version", "/healthz", "/livez", "/readyz"} + + +def _docs(): + kust = yaml.safe_load((ADDON / "kustomization.yaml").read_text()) + docs = [] + for res in kust["resources"]: + docs.extend(d for d in yaml.safe_load_all((ADDON / res).read_text()) if d) + return docs + + +def _one(kind): + found = [d for d in _docs() if d["kind"] == kind] + assert len(found) == 1, kind + return found[0] + + +def test_only_expected_kinds_no_secret(): + kinds = sorted(d["kind"] for d in _docs()) + assert kinds == ["ClusterRole", "ClusterRoleBinding", "Namespace", "ServiceAccount"] + for path in ADDON.iterdir(): + assert "kind: Secret" not in path.read_text(), path + + +def test_service_account_names_and_no_automount(): + sa = _one("ServiceAccount") + assert sa["metadata"] == {**sa["metadata"], "name": "custodian-agent", + "namespace": "custodian-agents"} + assert sa["automountServiceAccountToken"] is False + assert "secrets" not in sa + assert _one("Namespace")["metadata"]["name"] == "custodian-agents" + + +def test_cluster_role_read_only_curated_no_wildcards(): + role = _one("ClusterRole") + assert role["metadata"]["name"] == "custodian-supervised-observer" + assert "aggregationRule" not in role + granted, non_resource = {}, set() + for rule in role["rules"]: + verbs = set(rule["verbs"]) + assert verbs <= READ_VERBS, rule + values = [*rule["verbs"], *rule.get("apiGroups", []), *rule.get("resources", []), + *rule.get("nonResourceURLs", []), *rule.get("resourceNames", [])] + assert not any("*" in v for v in values), rule + if "nonResourceURLs" in rule: + assert verbs == {"get"} and not rule.get("resources"), rule + non_resource |= set(rule["nonResourceURLs"]) + continue + assert verbs == READ_VERBS, rule + for group in rule["apiGroups"]: + for resource in rule["resources"]: + assert resource not in FORBIDDEN_RESOURCES, resource + assert "/" not in resource, f"no subresources: {resource}" + granted.setdefault(group, set()).add(resource) + assert granted == EXPECTED + assert non_resource == EXPECTED_NON_RESOURCE + + +def test_binding_targets_only_the_agent(): + binding = _one("ClusterRoleBinding") + assert binding["roleRef"] == {"apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "custodian-supervised-observer"} + assert binding["subjects"] == [{"kind": "ServiceAccount", "name": "custodian-agent", + "namespace": "custodian-agents"}] + + +def test_application_manual_and_pinned(): + if not APP.exists(): + pytest.skip("Application not yet placed") + app = yaml.safe_load(APP.read_text()) + spec = app["spec"] + assert spec["project"] == "railiance-platform-addons" + assert spec["source"]["path"] == "argocd/platform-addons/custodian-agents" + assert re.fullmatch(r"[0-9a-f]{40}", spec["source"]["targetRevision"]) + assert "automated" not in spec.get("syncPolicy", {}) + assert "finalizers" not in app["metadata"] diff --git a/workplans/RPF-WP-0062-custodian-agent-observer-identity.md b/workplans/RPF-WP-0062-custodian-agent-observer-identity.md new file mode 100644 index 0000000..8048f6b --- /dev/null +++ b/workplans/RPF-WP-0062-custodian-agent-observer-identity.md @@ -0,0 +1,105 @@ +--- +id: RPF-WP-0062 +type: workplan +title: "Custodian agent observer identity: read-only ServiceAccount and curated ClusterRole on railiance01" +domain: financials +repo: railiance-platform +status: active +owner: claude +created: "2026-10-20" +updated: "2026-10-20" +flavor: implementation +origin: cross-repo +origin_ref: the-custodian CUST-WP-0073-T02 (founder decision 2026-10-20) +--- + +Demand: CUST-WP-0073-T02 needs a supervised agent identity on railiance01 that +can observe cluster state without reaching credentials or writing anything. +This repo owns the RBAC half. The earlier health-only candidate +(`the-custodian/docs/changes/CUST-WP-0073/credential-boundary-r1/identity-rbac.yaml`) +is superseded by the founder's 2026-10-20 decision. + +Founder decision (Bernd Worsch, 2026-10-20): + +- Namespace `custodian-agents`, ServiceAccount `custodian-agent`, + `automountServiceAccountToken: false`. No static token Secret. +- Credentials are short-lived TokenRequest tokens issued by the supervisor. +- ClusterRole `custodian-supervised-observer`: get/list/watch on namespaces, + nodes, pods, deployments/statefulsets/daemonsets/replicasets, services, + endpoints/endpointslices, ingresses, events (core + events.k8s.io), + persistentvolumeclaims, Argo CD applications/appprojects, CNPG + clusters/backups/scheduledbackups; get on `/version /healthz /livez /readyz`. +- Explicitly not: secrets, configmaps, pods/log, pods/exec, pods/attach, + pods/portforward, serviceaccounts/token, nodes/proxy, any write verb, any + wildcard. Privileged actions go through the separate gated executor. + +Layout: + +- Manifests: `argocd/platform-addons/custodian-agents/` +- Child Application (manual sync, pinned, no finalizer): + `argocd/railiance01/applications/custodian-agents.application.yaml`, + project `railiance-platform-addons` (already whitelists Namespace, + ClusterRole, ClusterRoleBinding, ServiceAccount). +- Offline guard: `tests/test_custodian_agent_rbac.py`. + +The railiance01 root `railiance-apps-root` has no `automated` block, so a push +to `main` changes nothing live. Going live is an attended founder action. + +## Token issuance (supervisor only) + +Recommended TTL 24h (shorter is fine; the API server may cap it via +`--service-account-max-token-expiration`). Audience is the API server's own +service-account audience, so the token works against the API and nothing else: + +```bash +kubectl -n custodian-agents create token custodian-agent \ + --duration=24h --audience=https://kubernetes.default.svc.cluster.local +``` + +The token is handed to the agent's restricted kubeconfig by the supervisor; it +is never committed, logged, or stored in the State Hub. It cannot be renewed by +the agent (no `serviceaccounts/token` grant). + +## Place the identity manifests and offline guard + +```task +id: RPF-WP-0062-T01 +status: done +priority: high +``` + +Namespace, ServiceAccount, ClusterRole and ClusterRoleBinding written; the +pytest guard parses the kustomization and asserts read-only verbs, the exact +curated resource set, no forbidden resource, no subresource, no wildcard, no +Secret, no automount, and a manual pinned Application. + +## Place the pinned child Application + +```task +id: RPF-WP-0062-T02 +status: todo +priority: high +``` + +`custodian-agents.application.yaml` pins `targetRevision` to the commit that +introduced the manifests. No `automated` block, no finalizer. + +## Attended sync and identity proof on railiance01 + +```task +id: RPF-WP-0062-T03 +status: wait +priority: high +needs_human: true +blocking_reason: "Founder runs the attended root sync for the custodian-agents child, issues a token and runs the can-i proof; live production change." +``` + +1. `make argocd-pin-drift` — stop on any `DRIFT` line. +2. Sync only this child out of the root at the reviewed revision, then the child + (commands in the CUST-WP-0073-T02 handoff / this repo's commit log). +3. Issue a token (above) and, as the agent identity, prove: + `kubectl auth can-i get secrets -A` → no; `can-i create pods/exec -A` → no; + `can-i get pods/log -A` → no; `can-i get configmaps -A` → no; + `can-i list pods -A` → yes; `kubectl auth whoami` shows + `system:serviceaccount:custodian-agents:custodian-agent`, no `system:masters`. +4. Report the result to CUST-WP-0073-T02.