RPF-WP-0062-T01: custodian agent observer identity manifests (CUST-WP-0073-T02)
Namespace custodian-agents, ServiceAccount custodian-agent (no automount, no token Secret), curated read-only ClusterRole custodian-supervised-observer and its binding, per the founder decision of 2026-10-20. Offline pytest guard asserts no forbidden resource, no write verb, no wildcard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 605711@bnt-lap001 Assistant-Session: 1f5e2256-181f-49ed-b7b1-a1c34bf37a61
This commit is contained in:
parent
acd1ade59a
commit
567d2c671b
8 changed files with 319 additions and 0 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
53
argocd/platform-addons/custodian-agents/clusterrole.yaml
Normal file
53
argocd/platform-addons/custodian-agents/clusterrole.yaml
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
14
argocd/platform-addons/custodian-agents/kustomization.yaml
Normal file
14
argocd/platform-addons/custodian-agents/kustomization.yaml
Normal file
|
|
@ -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
|
||||
7
argocd/platform-addons/custodian-agents/namespace.yaml
Normal file
7
argocd/platform-addons/custodian-agents/namespace.yaml
Normal file
|
|
@ -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
|
||||
11
argocd/platform-addons/custodian-agents/serviceaccount.yaml
Normal file
11
argocd/platform-addons/custodian-agents/serviceaccount.yaml
Normal file
|
|
@ -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
|
||||
110
tests/test_custodian_agent_rbac.py
Normal file
110
tests/test_custodian_agent_rbac.py
Normal file
|
|
@ -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"]
|
||||
105
workplans/RPF-WP-0062-custodian-agent-observer-identity.md
Normal file
105
workplans/RPF-WP-0062-custodian-agent-observer-identity.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue