From 9c73a5fa0967bf27e00162a44ec55dfc23296dd1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 6 Nov 2026 00:52:35 +0100 Subject: [PATCH] NK-WP-0058-T05: proposal for a read-only agent status grant (not applied), with guard tests Dedicated ServiceAccount sso/nk-agent-status (SI-14), read-only Jobs/pods/Deployments/ExternalSecrets in sso, one named ConfigMap in informed-decision and one Argo Application; no Secrets, logs or exec. Options A-D for the founder decision; tests keep the grant narrow and sso workloads free of literal secrets. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 943911@bnt-lap001 Assistant-Session: fc322940-1c8e-4bed-ab39-86cec99f23a0 --- .../README.md | 69 +++++++++++++++ .../rbac.yaml | 88 +++++++++++++++++++ .../tests/test_agent_status_grant.py | 75 ++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 docs/proposals/2026-11-06-nk58-t05-agent-status-grant/README.md create mode 100644 docs/proposals/2026-11-06-nk58-t05-agent-status-grant/rbac.yaml create mode 100644 tools/contract-tests/tests/test_agent_status_grant.py diff --git a/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/README.md b/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/README.md new file mode 100644 index 0000000..cc35f49 --- /dev/null +++ b/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/README.md @@ -0,0 +1,69 @@ +# NK-WP-0058-T05: read-only status for the agent identity (proposal, 2026-11-06) + +**Status: proposed, for founder decision.** Nothing is applied. + +## Why + +On 2026-11-04/05, every status the agent needed was asked of the founder: Job and pod status, the run's result +line, ExternalSecret `Ready`, KeyCape and Authelia rollouts, and the live informed-decision registry. The agent's +Kubernetes identity today is `ops-mason-builder`, ops-mason's builder account. It can read NetworkPolicies in the +path's namespaces and nothing else that matters here (`kubectl auth can-i`, 2026-11-05; contract table part 3). + +## What SI-14 asks + +`canon/standards/service-identity-privilege_v0.1.md` SI-14: +- an agent presents as itself (`AGENT`); +- its tokens last at most 60 minutes; +- its effective authority is everything its tools can reach. + +So widening `ops-mason-builder` is the wrong place: it would give ops-mason's builder status reads it does not +need, and the agent would still act as ops-mason. + +## Options + +| Option | Grant | Pod logs | Assessment | +| --- | --- | --- | --- | +| **A (recommended)** | ServiceAccount `sso/nk-agent-status` with [`rbac.yaml`](rbac.yaml): read Jobs, pods, Deployments, ExternalSecrets in `sso`; one ConfigMap in `informed-decision`; one Argo Application | founder-only | Covers every status but logs. `bx status` already carries the run's result, and `--preflight-only` replaces diagnostic pods | +| B | A plus `pods/log` in `sso` | all `sso` pods | RBAC cannot limit logs to the executor's pods by label: this would also read KeyCape, Authelia, LLDAP and privacyIDEA logs. Not recommended | +| C | widen `ops-mason-builder` | — | against SI-14 (two actors on one identity). Not recommended | +| D | no grant | founder-only | everything stays as on 2026-11-05 | + +## Option A in detail + +**Readable after the grant:** + +| Status asked of the founder on 2026-11-04/05 | Readable with A | +| --- | --- | +| Run and request Job status, pod phase and exit code | yes (`sso` Jobs, pods) | +| The run's result line | no, it is in the pod log. `bx status` and the result receipt carry it | +| Diagnostic pod output | not needed: `make preflight` reports every check | +| ExternalSecret `Ready` for both custody deliveries | yes | +| KeyCape and Authelia rollout status | yes (`sso` Deployments) | +| Live NetworkPolicies | yes, already today | +| informed-decision registry and routing, live | yes (`informed-decision-workload-registry`, by name) | +| Argo pin vs live for informed-decision | yes (the `informed-decision` Application, by name) | + +**Stays founder-only:** +- OpenBao: the write role, audit and custody, under `platform-admin`; +- Secrets, pod logs, exec and port-forward; +- any write. + +**Pod specs are readable,** because `get` on a pod or Deployment includes its env. `sso`'s workloads hold no +literal secret values (only file paths, references and URLs). `tools/contract-tests/tests/test_agent_status_grant.py` +keeps it that way and keeps the grant read-only and narrow. + +**Tokens:** at most 1 hour (SI-14), minted by the founder once per working session. This is read-only, so it isn't a +warden lane: + +```bash +kubectl --kubeconfig ~/.kube/config-railiance01 -n sso create token nk-agent-status --duration=1h \ + > ~/.kube/nk-agent-status.token && chmod 600 ~/.kube/nk-agent-status.token +``` + +The agent's kubeconfig `~/.kube/config-agent-status` names the cluster and `tokenFile: ~/.kube/nk-agent-status.token`. +It holds no token itself. A longer token is a deviation from SI-14 and would need its own decision. + +**Who applies it:** railiance-cluster owns cluster RBAC, so after the decision the change goes there as a reviewed +change. The founder applies it, server-side. Verification: +- `kubectl auth can-i --list` as `nk-agent-status` shows only these rules; +- `can-i get secrets -n sso` and `can-i get pods --subresource=log -n sso` both answer `no`. diff --git a/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/rbac.yaml b/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/rbac.yaml new file mode 100644 index 0000000..c249532 --- /dev/null +++ b/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/rbac.yaml @@ -0,0 +1,88 @@ +# NK-WP-0058-T05 proposal: read-only status for the agent's own Kubernetes identity. PROPOSED, NOT APPLIED. +# Owner of the change: railiance-cluster (cluster RBAC), reviewed with railiance-platform. Founder decision first. +# +# Option A (recommended): status of the bounded-execution path, no pod logs. A dedicated ServiceAccount, not a +# wider ops-mason-builder: SI-14 wants the agent to present as itself, with tokens of at most 60 minutes. +# Never: Secrets, pod logs, exec, port-forward, create, update, patch, delete, or anything cluster-wide. +# Tests: tools/contract-tests/tests/test_agent_status_grant.py. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nk-agent-status + namespace: sso + labels: + app.kubernetes.io/name: nk-agent-status + app.kubernetes.io/part-of: net-kingdom +automountServiceAccountToken: false # no pod runs as it; the founder mints a 1 h token per session +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: nk-agent-status + namespace: sso +rules: + # executor Jobs and their pods (phase, conditions, exit codes); Deployments for KeyCape/Authelia rollouts + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch"] + # Ready condition of the custody deliveries; an ExternalSecret holds references, never values + - apiGroups: ["external-secrets.io"] + resources: ["externalsecrets"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: nk-agent-status + namespace: sso +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: nk-agent-status} +subjects: + - {kind: ServiceAccount, name: nk-agent-status, namespace: sso} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: nk-agent-status + namespace: informed-decision +rules: + # the live routing and submitter registry (review-groups.json, intake-submitters.json): non-secret configuration + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["informed-decision-workload-registry"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: nk-agent-status + namespace: informed-decision +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: nk-agent-status} +subjects: + - {kind: ServiceAccount, name: nk-agent-status, namespace: sso} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: nk-agent-status + namespace: argocd +rules: + # the informed-decision Application's sync revision and status: is live the pinned revision? (contract row O1) + - apiGroups: ["argoproj.io"] + resources: ["applications"] + resourceNames: ["informed-decision"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: nk-agent-status + namespace: argocd +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: nk-agent-status} +subjects: + - {kind: ServiceAccount, name: nk-agent-status, namespace: sso} diff --git a/tools/contract-tests/tests/test_agent_status_grant.py b/tools/contract-tests/tests/test_agent_status_grant.py new file mode 100644 index 0000000..02443b9 --- /dev/null +++ b/tools/contract-tests/tests/test_agent_status_grant.py @@ -0,0 +1,75 @@ +"""NK-WP-0058-T05: the proposed agent status grant stays read-only and narrow, and what it can read holds no secret. + +The grant (docs/proposals/2026-11-06-nk58-t05-agent-status-grant/rbac.yaml) is source only until decided. These +checks keep it from widening by accident, and keep the sso workloads it can read free of literal secret values: +`get` on a pod or Deployment returns its env, so a secret written there would become readable. +""" +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parents[3] +GRANT = REPO / 'docs/proposals/2026-11-06-nk58-t05-agent-status-grant/rbac.yaml' +READ = {'get', 'list', 'watch'} +NEVER = {'secrets', 'pods/log', 'pods/exec', 'pods/attach', 'pods/portforward', 'serviceaccounts/token'} +ALLOWED = {('sso', 'batch', 'jobs'), ('sso', '', 'pods'), ('sso', 'apps', 'deployments'), + ('sso', 'external-secrets.io', 'externalsecrets'), ('informed-decision', '', 'configmaps'), + ('argocd', 'argoproj.io', 'applications')} +SECRET_WORDS = {'PASS', 'PASSWORD', 'SECRET', 'TOKEN', 'KEY', 'SEED', 'CREDENTIAL', 'CREDENTIALS'} +NOT_A_VALUE = {'URL', 'URLS', 'ORIGINS', 'SECONDS', 'RETURNS', 'FILE', 'PATH', 'ENABLED'} # what the value is + + +def secret_named(name): + parts = name.upper().split('_') + return bool(SECRET_WORDS & set(parts)) and parts[-1] not in NOT_A_VALUE + + +def docs(): + return [d for d in yaml.safe_load_all(GRANT.read_text()) if d] + + +def test_only_namespaced_read_rules_on_the_listed_resources(): + kinds = {d['kind'] for d in docs()} + assert kinds == {'ServiceAccount', 'Role', 'RoleBinding'} # nothing cluster-wide + for role in (d for d in docs() if d['kind'] == 'Role'): + ns = role['metadata']['namespace'] + for rule in role['rules']: + assert set(rule['verbs']) <= READ, rule + for group in rule['apiGroups']: + for resource in rule['resources']: + assert resource not in NEVER and '*' not in (group, resource) + assert (ns, group, resource) in ALLOWED, (ns, group, resource) + if ns != 'sso': + assert rule.get('resourceNames'), f'{ns}: outside sso the grant names its objects' + + +def test_bound_only_to_the_dedicated_identity(): + sa = next(d for d in docs() if d['kind'] == 'ServiceAccount') + assert sa['metadata'] == {**sa['metadata'], 'name': 'nk-agent-status', 'namespace': 'sso'} + assert sa['automountServiceAccountToken'] is False + for binding in (d for d in docs() if d['kind'] == 'RoleBinding'): + assert binding['subjects'] == [{'kind': 'ServiceAccount', 'name': 'nk-agent-status', 'namespace': 'sso'}] + assert binding['roleRef']['kind'] == 'Role' + + +def test_sso_workloads_carry_no_literal_secret_values(): + """Values of secret-named env vars in sso workloads are references or file paths, never the secret itself.""" + found = [] + for path in sorted((REPO / 'sso-mfa/k8s').glob('*/*.yaml')): + for doc in yaml.safe_load_all(path.read_text()): + if not isinstance(doc, dict) or doc.get('metadata', {}).get('namespace') != 'sso': + continue + spec = doc.get('spec') or {} + pod = (spec.get('template') or {}).get('spec') or {} + for container in (pod.get('containers') or []) + (pod.get('initContainers') or []): + for env in container.get('env') or []: + value = env.get('value') + if value is not None and secret_named(env['name']) and not str(value).startswith('/'): + found.append(f"{path.relative_to(REPO)}:{env['name']}") + assert found == [], found + + +def test_secret_named(): + assert secret_named('USER_ENGINE_TENANT_MANAGEMENT_TOKEN') and secret_named('SMTP_PASSWORD') + assert not secret_named('KEYCAPE_ACCOUNT_PORTAL_URL') and not secret_named('PASSWORD_SETUP_TTL_SECONDS') + assert not secret_named('AUTHELIA_JWT_SECRET_FILE')