From 2387409d08cc4292c453b14c7b828029128fbce7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 14:21:04 +0100 Subject: [PATCH] NK-WP-0056-T03 done: executor identity and authority ceiling, in source Executor credentials declared (planned) with executor_subject; service-identity gains convention executor (SI-15: approved-only grants, no person, not interactive) and --admissions, checking that an admission's ceiling lies inside the executor's declared grants (pilot covered). RBAC in sso-mfa/k8s/bounded-executor (not applied): ServiceAccount without automount, a Role limited to the authelia Deployment and ExternalSecret, no read of credential objects; tests tie Role, declaration and pilot ceiling. The pilot example's delivery check now reads the ExternalSecret status. railiance-apps and glas-harness answers recorded (T02). Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 994874@bnt-lap001 Assistant-Session: 6017c416-d619-47eb-bd1a-f670a02d7d87 --- Makefile | 7 +- WORK-RECORDS.md | 2 +- .../schemas/service-identity_v0.1.schema.json | 9 +- .../2026-10-31-nk56-t02-owner-requests.md | 4 +- .../bind-rotation-svc-authelia.yaml | 10 +-- .../bind-split-create-service-accounts.yaml | 4 +- .../bind-split-read-only-verify.yaml | 4 +- .../bind-split-switch-super-admin.yaml | 4 +- sso-mfa/k8s/bounded-executor/rbac.yaml | 52 ++++++++++++ sso-mfa/k8s/bounded-executor/test_rbac.py | 67 +++++++++++++++ .../invalid/human-intrinsic-step.yaml | 4 +- .../generate_examples.py | 4 +- .../declarations/bounded-executor.yaml | 84 +++++++++++++++++++ tools/service-identity/tests/test_validate.py | 50 +++++++++++ tools/service-identity/validate.py | 60 ++++++++++++- workplans/NK-WP-0056-bounded-executor-path.md | 15 +++- 16 files changed, 358 insertions(+), 22 deletions(-) create mode 100644 sso-mfa/k8s/bounded-executor/rbac.yaml create mode 100644 sso-mfa/k8s/bounded-executor/test_rbac.py create mode 100644 tools/service-identity/declarations/bounded-executor.yaml diff --git a/Makefile b/Makefile index 441e7b5..df24e7d 100644 --- a/Makefile +++ b/Makefile @@ -209,6 +209,11 @@ bounded-execution-admission-test: ## Run bounded-execution admission validator t bounded-execution-admission-check: ## Validate the example admissions (read-only, offline) python3 tools/bounded-execution-admission/validate.py validate examples/bounded-execution-admission/*.yaml +bounded-executor-test: ## Bounded executor RBAC and declaration tests; the pilot admission inside its declared grants (NK-WP-0056-T03) + python3 -m pytest sso-mfa/k8s/bounded-executor + python3 tools/service-identity/validate.py tools/service-identity/declarations/bounded-executor.yaml \ + --admissions examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml + bind-split-test: ## LLDAP bind-split helper tests on a throwaway pinned LLDAP (NK-WP-0054-T05; needs docker) uv run --with pytest --with ldap3 --with pyyaml python -m pytest sso-mfa/k8s/lldap/test_bind_split.py sso-mfa/k8s/lldap/test_identity_provisioner_reconcile.py @@ -390,7 +395,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a openbao-init-unseal openbao-init-unseal-dry-run \ iam-profile-conformance-test playbook-contract-test posture-feedback-test \ custody-record-test service-identity-test service-identity-check security-tools \ - bounded-execution-admission-test bounded-execution-admission-check \ + bounded-execution-admission-test bounded-execution-admission-check bounded-executor-test \ emission-cadence-profile-test \ posture-feedback-reference security-scenario-compose-lightweight \ security-bootstrap-console-test security-bootstrap-scripts-syntax \ diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 0e8ba0d..e4dffee 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -342,7 +342,7 @@ | task | NK-WP-0055-T16 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0056-T01 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T02 | wait | — | workplans/NK-WP-0056-bounded-executor-path.md | -| task | NK-WP-0056-T03 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | +| task | NK-WP-0056-T03 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T04 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T05 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T06 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | diff --git a/canon/schemas/service-identity_v0.1.schema.json b/canon/schemas/service-identity_v0.1.schema.json index 7d0a83c..edd868e 100644 --- a/canon/schemas/service-identity_v0.1.schema.json +++ b/canon/schemas/service-identity_v0.1.schema.json @@ -147,7 +147,8 @@ "items": { "enum": [ "bind", - "service-admin" + "service-admin", + "executor" ] }, "description": "Estate naming conventions for recurring grant shapes (standard section 2). More than one is itself a finding." @@ -330,6 +331,12 @@ "maxLength": 200 } } + }, + "executor_subject": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "description": "For a credential held by a bounded executor (NK-WP-0056): the subject the executor presents in its admissions (spec.executor.identity). Grants of all identities with the same subject form the executor's declared authority." } }, "allOf": [ diff --git a/docs/proposals/2026-10-31-nk56-t02-owner-requests.md b/docs/proposals/2026-10-31-nk56-t02-owner-requests.md index 893aa6c..67b0772 100644 --- a/docs/proposals/2026-10-31-nk56-t02-owner-requests.md +++ b/docs/proposals/2026-10-31-nk56-t02-owner-requests.md @@ -21,8 +21,8 @@ Every answer below is the owner's agreement. Nothing here assigns work to anothe | Executor credential lanes | secrets-engine | Lifecycle of the executor's OpenBao credentials | pending | | OpenBao roles and CCR types | railiance-platform | Kubernetes auth role and policies for the executor; CCR request types | pending | | Receipt holder | audit-core | Execution-attribution receipts from the executor | pending | -| Agent executors | glas-harness | Session contract so a harness-launched agent can be an executor | pending | -| Runtime package | railiance-apps | Where the new `rapp-*` package lives and who creates it | pending | +| Agent executors | glas-harness | Session contract so a harness-launched agent can be an executor | answered 2026-10-31 (glas-harness `92dd299`, `docs/bounded-execution-agent-executor.md`, GLAS-WP-0018-T07): not before GLAS-WP-0018-T04 (proposed; needs key-cape agent tokens with `act` and sandbox token delivery); a verifiable harness-launched assertion must be a key-cape-signed per-run token, claim set proposed; glas-harness proposes to own the agent-executor path later. Founder: approve GLAS-WP-0018 or not | +| Runtime package | railiance-apps | Where the new `rapp-*` package lives and who creates it | answered 2026-10-31 (railiance-apps `c733e86`, `docs/rapp-bounded-executor-intake.md`): not railiance-apps; `rmgr rapp wrap` (repo-manager) creates it locally, the Forgejo remote is the founder's; the image is built in the app repo (net-kingdom), the rapp pins its digest; deploy by `kubectl` server-side apply run by the founder; recommends one Job per admission. Founder decisions F1-F4 open | ## approval-engine diff --git a/examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml b/examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml index a6b7584..990bd9d 100644 --- a/examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml +++ b/examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml @@ -49,7 +49,7 @@ spec: plan: source: repo: net-kingdom - commit: b8d3dacc4195 + commit: 43a78907593a files: - path: tools/service-binds/bind-lane.py sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 @@ -90,7 +90,7 @@ spec: mode: OPERATE scope: k8s/sso/externalsecret/authelia-secrets checks_after: - - secret-equals-kv + - eso-synced revert: redeliver - id: restart operation: k8s.rollout-restart @@ -141,7 +141,7 @@ spec: requires: mode: OPERATE scope: k8s/sso/deployment/authelia - plan_digest: sha256:33129ab753da95f549fb27cfb5ba622e36fcba3c197f28b08fe3d4f9ff1d64a4 + plan_digest: sha256:47de5fcaa09fdadb4ea501e47f85fc12da5b6ce31b28c2ccad34a25ca9599d0f checks: - id: kv-advanced description: KV svc-authelia has exactly one new version, CAS on the previous one @@ -155,8 +155,8 @@ spec: sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 timeout_seconds: 30 expect: pass - - id: secret-equals-kv - description: 'bind-lane verify: ESO Ready and the delivered Secret equals KV, compared in memory' + - id: eso-synced + description: the ExternalSecret is Ready with a refresh after the step started; the executor never reads the Secret ref: tools/service-binds/bind-lane.py sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 timeout_seconds: 120 diff --git a/examples/bounded-execution-admission/bind-split-create-service-accounts.yaml b/examples/bounded-execution-admission/bind-split-create-service-accounts.yaml index cba8591..9ca9106 100644 --- a/examples/bounded-execution-admission/bind-split-create-service-accounts.yaml +++ b/examples/bounded-execution-admission/bind-split-create-service-accounts.yaml @@ -43,7 +43,7 @@ spec: plan: source: repo: net-kingdom - commit: b8d3dacc4195 + commit: 43a78907593a files: - path: sso-mfa/k8s/lldap/bind-split.py sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 @@ -93,7 +93,7 @@ spec: requires: mode: OPERATE scope: openbao/platform/workloads/net-kingdom/directory-binds - plan_digest: sha256:94910771c288437dbaeeba80e32a4f61ef1cdb7d438e7352d21a5e10fe92e77e + plan_digest: sha256:be02f6850b329e2e2262bc720a70f881a146183ff70fb3ac20ae72e3a5ae70e8 checks: - id: accounts-read-only description: each account exists in lldap_strict_readonly only and cannot write diff --git a/examples/bounded-execution-admission/bind-split-read-only-verify.yaml b/examples/bounded-execution-admission/bind-split-read-only-verify.yaml index 1a481b4..30557c3 100644 --- a/examples/bounded-execution-admission/bind-split-read-only-verify.yaml +++ b/examples/bounded-execution-admission/bind-split-read-only-verify.yaml @@ -46,7 +46,7 @@ spec: plan: source: repo: net-kingdom - commit: b8d3dacc4195 + commit: 43a78907593a files: - path: sso-mfa/k8s/lldap/bind-split.py sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 @@ -73,7 +73,7 @@ spec: scope: k8s/sso checks_after: - split-shape - plan_digest: sha256:e4867e97a9957d543d5a7e265e56678ce22f3bb87bab64022b381dbee3e2610a + plan_digest: sha256:c07dd58e6581049441d512edb60753bfe7102e3c89ea28d55081141373844d0d checks: - id: split-shape description: every consumer binds as its own svc- account; no service binds as admin diff --git a/examples/bounded-execution-admission/bind-split-switch-super-admin.yaml b/examples/bounded-execution-admission/bind-split-switch-super-admin.yaml index 406ddd9..3d7ab7a 100644 --- a/examples/bounded-execution-admission/bind-split-switch-super-admin.yaml +++ b/examples/bounded-execution-admission/bind-split-switch-super-admin.yaml @@ -43,7 +43,7 @@ spec: plan: source: repo: net-kingdom - commit: b8d3dacc4195 + commit: 43a78907593a files: - path: sso-mfa/k8s/lldap/bind-split.py sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 @@ -93,7 +93,7 @@ spec: requires: mode: OPERATE scope: k8s/sso/deployment/lldap - plan_digest: sha256:c15793ccaad212eaf24de12a1d13709970be84ee917a994463b68880e905dedf + plan_digest: sha256:60578e013b23fd3d68fead65956c939cde1eb9230d9f9fdbe1aaa677e8e0de11 checks: - id: ld-admin-administers description: ld-admin signs in and administers; admin keeps lldap_admin membership diff --git a/sso-mfa/k8s/bounded-executor/rbac.yaml b/sso-mfa/k8s/bounded-executor/rbac.yaml new file mode 100644 index 0000000..f17108d --- /dev/null +++ b/sso-mfa/k8s/bounded-executor/rbac.yaml @@ -0,0 +1,52 @@ +# Bounded executor identity in sso (NK-WP-0056-T03). PREPARED IN SOURCE, NOT APPLIED. +# +# The ServiceAccount the executor runs as, with exactly the pilot's Kubernetes authority (T06: rotate the +# svc-authelia bind): force-sync one ExternalSecret and restart one Deployment. No Secret, ConfigMap or Pod +# access, no create/delete, no wildcard, no cluster scope. Its OpenBao and LLDAP credentials are declared in +# tools/service-identity/declarations/bounded-executor.yaml; all of it acts only under a consumed admission +# (canon/standards/bounded-execution-admission_v0.1.md). Packaging and apply belong to rapp-bounded-executor +# (ADR-0015, founder decision 6d1adce9). Tests: sso-mfa/k8s/bounded-executor/test_rbac.py. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nk-bind-executor + namespace: sso + labels: + app.kubernetes.io/name: bounded-executor + app.kubernetes.io/part-of: net-kingdom-sso-mfa +automountServiceAccountToken: false # the executor pod mounts a projected, audience-bound token instead +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: nk-bind-executor + namespace: sso + labels: + app.kubernetes.io/name: bounded-executor +rules: + # restart: kubectl rollout restart patches the pod template annotation; rollout status reads and watches + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["authelia"] + verbs: ["get", "watch", "patch"] + # deliver: force-sync annotation, then read status (Ready, refreshTime) as the check; never the Secret + - apiGroups: ["external-secrets.io"] + resources: ["externalsecrets"] + resourceNames: ["authelia-secrets"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: nk-bind-executor + namespace: sso + labels: + app.kubernetes.io/name: bounded-executor +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: nk-bind-executor +subjects: + - kind: ServiceAccount + name: nk-bind-executor + namespace: sso diff --git a/sso-mfa/k8s/bounded-executor/test_rbac.py b/sso-mfa/k8s/bounded-executor/test_rbac.py new file mode 100644 index 0000000..64a3e78 --- /dev/null +++ b/sso-mfa/k8s/bounded-executor/test_rbac.py @@ -0,0 +1,67 @@ +"""The bounded executor's Kubernetes authority is exactly the pilot's (NK-WP-0056-T03). Source only, no cluster.""" + +from __future__ import annotations + +import pathlib +import unittest + +import yaml + +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[2] +DOCS = list(yaml.safe_load_all((HERE / "rbac.yaml").read_text())) +DECLARATION = yaml.safe_load((ROOT / "tools/service-identity/declarations/bounded-executor.yaml").read_text()) +PILOT = yaml.safe_load((ROOT / "examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml").read_text()) +KIND_SCOPE = {("apps", "deployments"): "deployment", ("external-secrets.io", "externalsecrets"): "externalsecret"} +FORBIDDEN_RESOURCES = {"secrets", "configmaps", "pods", "pods/exec", "pods/log", "serviceaccounts", "serviceaccounts/token", + "roles", "rolebindings", "clusterroles", "clusterrolebindings", "*"} + + +def by_kind(kind): + return [d for d in DOCS if d["kind"] == kind] + + +def role_scopes(): + (role,) = by_kind("Role") + return {f"k8s/sso/{KIND_SCOPE[(group, resource)]}/{name}" + for rule in role["rules"] for group in rule["apiGroups"] for resource in rule["resources"] + for name in rule["resourceNames"]} + + +class RbacTests(unittest.TestCase): + def test_only_namespaced_objects_in_sso(self): + self.assertEqual(sorted(d["kind"] for d in DOCS), ["Role", "RoleBinding", "ServiceAccount"]) + self.assertTrue(all(d["metadata"]["namespace"] == "sso" for d in DOCS)) + + def test_service_account_does_not_automount(self): + (sa,) = by_kind("ServiceAccount") + self.assertEqual(sa["metadata"]["name"], "nk-bind-executor") + self.assertIs(sa["automountServiceAccountToken"], False) + + def test_every_rule_is_named_and_narrow(self): + (role,) = by_kind("Role") + for rule in role["rules"]: + with self.subTest(rule=rule["resources"]): + self.assertTrue(rule.get("resourceNames"), "every rule names its objects") + for field in ("apiGroups", "resources", "verbs", "resourceNames"): + self.assertNotIn("*", rule[field]) + self.assertLessEqual(set(rule["verbs"]), {"get", "watch", "patch"}) + self.assertFalse(set(rule["resources"]) & FORBIDDEN_RESOURCES) + self.assertTrue(set(rule["resources"]) <= {r for _, r in KIND_SCOPE}) + + def test_binding_binds_only_the_executor(self): + (binding,) = by_kind("RoleBinding") + self.assertEqual(binding["roleRef"], {"apiGroup": "rbac.authorization.k8s.io", "kind": "Role", "name": "nk-bind-executor"}) + self.assertEqual(binding["subjects"], [{"kind": "ServiceAccount", "name": "nk-bind-executor", "namespace": "sso"}]) + + def test_role_equals_declaration_equals_pilot_ceiling(self): + (sa_identity,) = [i for i in DECLARATION["identities"] if i["name"] == "nk-bind-executor"] + declared = {g["scope"] for g in sa_identity["grants"]} + ceiling = {g["scope"] for g in PILOT["spec"]["scope_ceiling"] if g["scope"].startswith("k8s/")} + self.assertEqual(role_scopes(), declared) + self.assertEqual(declared, ceiling) + self.assertEqual(PILOT["spec"]["executor"]["identity"], sa_identity["executor_subject"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml b/tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml index f146e70..1c9c543 100644 --- a/tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml +++ b/tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml @@ -42,7 +42,7 @@ spec: plan: source: repo: net-kingdom - commit: b8d3dacc4195 + commit: 43a78907593a files: - path: sso-mfa/k8s/lldap/bind-split.py sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 @@ -104,7 +104,7 @@ spec: requires: mode: OPERATE scope: k8s/sso/deployment/lldap - plan_digest: sha256:eb53d0d611a22d43214f7e4afb450e2a1c858110c9d5330b7d562fc98a1c8fe8 + plan_digest: sha256:33063248bed00c84b640a37344a4c9e52285e76cacb68b92d08872cd9b7c77db checks: - id: ld-admin-administers description: ld-admin signs in and administers; admin keeps lldap_admin membership diff --git a/tools/bounded-execution-admission/generate_examples.py b/tools/bounded-execution-admission/generate_examples.py index cb772e7..3da9f79 100644 --- a/tools/bounded-execution-admission/generate_examples.py +++ b/tools/bounded-execution-admission/generate_examples.py @@ -59,7 +59,7 @@ rotation = doc( [LANE, JOURNEY], [step("generate", "openbao.kv.write-next-version", "openbao:" + KV[len("openbao/"):], "authelia", "delegable", True, "OPERATE", KV, ["kv-advanced"], "restore-kv"), step("set-directory", "lldap.user.set-password-from-kv", "lldap:svc-authelia", "authelia", "delegable", True, "ADMINISTER", "lldap/people/svc-authelia", ["bind-signs-in"], "restore-directory"), - step("deliver", "eso.force-sync", "k8s:sso/externalsecret/authelia-secrets", "authelia", "delegable", True, "OPERATE", "k8s/sso/externalsecret/authelia-secrets", ["secret-equals-kv"], "redeliver"), + step("deliver", "eso.force-sync", "k8s:sso/externalsecret/authelia-secrets", "authelia", "delegable", True, "OPERATE", "k8s/sso/externalsecret/authelia-secrets", ["eso-synced"], "redeliver"), step("restart", "k8s.rollout-restart", "k8s:sso/deployment/authelia", "authelia", "delegable", True, "OPERATE", "k8s/sso/deployment/authelia", ["synthetic-journey"], "restart-again")], [step("restore-kv", "openbao.kv.write-previous-value-as-next-version", "openbao:" + KV[len("openbao/"):], "authelia", "delegable", True, "OPERATE", KV), step("restore-directory", "lldap.user.set-password-from-kv", "lldap:svc-authelia", "authelia", "delegable", True, "ADMINISTER", "lldap/people/svc-authelia"), @@ -67,7 +67,7 @@ rotation = doc( step("restart-again", "k8s.rollout-restart", "k8s:sso/deployment/authelia", "authelia", "delegable", True, "OPERATE", "k8s/sso/deployment/authelia")], [check("kv-advanced", "KV svc-authelia has exactly one new version, CAS on the previous one", LANE, 30), check("bind-signs-in", "the new KV value signs in to LLDAP as svc-authelia", LANE, 30), - check("secret-equals-kv", "bind-lane verify: ESO Ready and the delivered Secret equals KV, compared in memory", LANE, 120), + check("eso-synced", "the ExternalSecret is Ready with a refresh after the step started; the executor never reads the Secret", LANE, 120), check("synthetic-journey", "syn-journey signs in through KeyCape, Authelia and privacyIDEA at aal2", JOURNEY, 90)], {"on_check_failure": "revert", "on_budget_exhausted": "revert", "on_unknown": "stop", "on_revert_failure": "stop-and-alert", "alert": "founder via State Hub message and audit-core"}, diff --git a/tools/service-identity/declarations/bounded-executor.yaml b/tools/service-identity/declarations/bounded-executor.yaml new file mode 100644 index 0000000..78e41e7 --- /dev/null +++ b/tools/service-identity/declarations/bounded-executor.yaml @@ -0,0 +1,84 @@ +schema_version: "0.1" +service: bounded-executor +owner: "NetKingdom (executor source); runtime package rapp-bounded-executor (NK-WP-0056-T02, founder decision 6d1adce9)" +declared_at: 2026-10-31 +attestation: + authored_by: claude-code + basis: [canon/standards/bounded-execution-admission_v0.1.md, examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml, sso-mfa/k8s/bounded-executor/rbac.yaml, workplans/NK-WP-0056-bounded-executor-path.md] + owner_confirmed: false +complete: false +# Every credential the bounded executor holds carries executor_subject service:nk-bind-executor, the identity +# its admissions name. Together they are its declared authority; `validate.py --admissions` checks that an +# admission's scope ceiling lies inside them. All grants are APPROVED: they act only during a consumed +# admission (SI-15). Pilot scope (T06): rotate the svc-authelia bind, one consumer. +identities: + - name: nk-bind-executor + principal_type: WORKLOAD + conventions: [executor] + executor_subject: "service:nk-bind-executor" + purpose: "Kubernetes ServiceAccount sso/nk-bind-executor: force-sync the authelia-secrets ExternalSecret and restart the authelia Deployment; no Secret read" + grants: + - {mode: OPERATE, scope: k8s/sso/deployment/authelia, activation: APPROVED, environments: [PRODUCTION], note: "Role: get, watch, patch on deployments/authelia"} + - {mode: OPERATE, scope: k8s/sso/externalsecret/authelia-secrets, activation: APPROVED, environments: [PRODUCTION], note: "Role: get, patch on externalsecrets/authelia-secrets"} + interactive_login: false + second_factor: not_applicable + custody: [delivered-secret] + consumers: [{name: bounded-executor, kind: service}] + owner: "NetKingdom" + rotation: {path: "Projected ServiceAccount token, short-lived; nothing to rotate by hand", coupling: [bounded-executor]} + status: planned + - name: net-kingdom-bounded-executor + principal_type: WORKLOAD + conventions: [executor] + executor_subject: "service:nk-bind-executor" + purpose: "OpenBao Kubernetes-auth role for sso/nk-bind-executor: write the next version of the svc-authelia bind (CAS) and read it for checks; lane shape pending secrets-engine and railiance-platform answers (T02)" + grants: + - {mode: OPERATE, scope: openbao/platform/workloads/net-kingdom/directory-binds/svc-authelia, activation: APPROVED, environments: [PRODUCTION], note: "KV data read and CAS write; 15 min TTL"} + interactive_login: false + second_factor: not_applicable + custody: [openbao-lease] + consumers: [{name: bounded-executor, kind: service}] + owner: "railiance-platform (OpenBao role) for NetKingdom" + rotation: {path: "OpenBao lease per run; no standing secret", coupling: [bounded-executor]} + status: planned + - name: svc-executor-pw + principal_type: SERVICE + conventions: [executor] + executor_subject: "service:nk-bind-executor" + purpose: "LLDAP account in lldap_password_manager: set svc-authelia's password from KV. LLDAP cannot narrow it to one account (it can reset any non-admin user); each admission narrows it to the accounts it names" + grants: + - {mode: ADMINISTER, scope: lldap/people, activation: APPROVED, environments: [PRODUCTION], note: "lldap_password_manager; admission ceiling narrows to lldap/people/"} + interactive_login: false + second_factor: not_applicable + custody: [openbao-lease] + consumers: [{name: bounded-executor, kind: service}] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "Its own password held in OpenBao; lane shape pending secrets-engine (T02)", coupling: [bounded-executor]} + status: planned + - name: svc-executor-read + principal_type: SERVICE + conventions: [bind] + executor_subject: "service:nk-bind-executor" + purpose: "Read-only directory bind for plan and verify steps (users, groups, bind identities); replaces the founder signing in as admin for reads" + grants: [{mode: USE, scope: directory, activation: STANDING, environments: [PRODUCTION], read_only: true}] + interactive_login: false + second_factor: not_applicable + custody: [openbao-lease] + consumers: [{name: bounded-executor, kind: service}] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "Bind password in OpenBao, rotated by the executor itself under an admission", coupling: [bounded-executor]} + status: planned + - name: svc-executor-admin + principal_type: SERVICE + conventions: [executor] + executor_subject: "service:nk-bind-executor" + purpose: "lldap_admin, only for root-class steps (create svc-* accounts, change the configured super-admin) under a high-risk admission; how membership is granted per run and removed afterwards is open" + grants: + - {mode: ADMINISTER, scope: lldap, activation: APPROVED, environments: [PRODUCTION], note: "root-class only; no narrower LLDAP group (SI-X6)"} + interactive_login: false + second_factor: not_applicable + custody: [openbao-lease] + consumers: [{name: bounded-executor, kind: service}] + owner: "NetKingdom directory and User Engine owners" + rotation: {path: "Per-run activation to be designed; not part of the T06 pilot", coupling: [bounded-executor]} + status: planned diff --git a/tools/service-identity/tests/test_validate.py b/tools/service-identity/tests/test_validate.py index 9fc4b88..127861b 100644 --- a/tools/service-identity/tests/test_validate.py +++ b/tools/service-identity/tests/test_validate.py @@ -337,3 +337,53 @@ class CommandLineTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class ExecutorTests(unittest.TestCase): + """SI-15 for bounded-executor credentials and the admission cross-check (NK-WP-0056-T03).""" + + def executor(self, **over): + base = identity(name="nk-exec", principal_type="WORKLOAD", conventions=["executor"], + executor_subject="service:nk-exec", + grants=[grant("OPERATE", "k8s/sso/deployment/x", activation="APPROVED")]) + base.update(over) + return base + + def test_conformant_executor(self): + broken, unknown = V._violations(self.executor()) + self.assertEqual((broken, unknown), (set(), [])) + + def test_executor_refusals(self): + cases = { + "standing grant": self.executor(grants=[grant("OPERATE", "k8s/sso/deployment/x")]), + "human": self.executor(principal_type="HUMAN"), + "interactive": self.executor(interactive_login=True), + "person consumer": self.executor(consumers=[{"name": "founder", "kind": "person"}]), + "no subject": {k: v for k, v in self.executor().items() if k != "executor_subject"}, + "govern": self.executor(grants=[grant("GOVERN", "lldap", activation="APPROVED")]), + } + for label, ident in cases.items(): + with self.subTest(label): + self.assertIn("SI-15", V._violations(ident)[0]) + + def test_checked_in_executor_declaration_has_no_open_rules(self): + document = V.load_declaration(DECLARATIONS / "bounded-executor.yaml") + self.assertEqual(V.structural_errors(document, SCHEMA), []) + report = V.evaluate(document, NOW) + self.assertTrue(all(not i["rules"] for i in report["identities"]), report) + + def test_admission_coverage(self): + document = V.load_declaration(DECLARATIONS / "bounded-executor.yaml") + examples = HERE.parents[2] / "examples/bounded-execution-admission" + pilot = V.load_declaration(examples / "bind-rotation-svc-authelia.yaml") + (result,) = V.admission_coverage([document], [pilot]) + self.assertTrue(result["covered"], result) + wider = copy.deepcopy(pilot) + wider["spec"]["scope_ceiling"].append({"mode": "OPERATE", "scope": "k8s/sso/deployment/keycape", + "environments": ["PRODUCTION"]}) + (result,) = V.admission_coverage([document], [wider]) + self.assertEqual(result["outside_declared_grants"], ["spec/scope_ceiling/4"]) + stranger = copy.deepcopy(pilot) + stranger["spec"]["executor"]["identity"] = "service:unknown" + (result,) = V.admission_coverage([document], [stranger]) + self.assertFalse(result["executor_declared"]) diff --git a/tools/service-identity/validate.py b/tools/service-identity/validate.py index 46ff065..2829c88 100644 --- a/tools/service-identity/validate.py +++ b/tools/service-identity/validate.py @@ -148,6 +148,16 @@ def _violations(identity: dict[str, Any]) -> tuple[set[str], list[str]]: # SI-7 CONSTRUCT (build mode, development) is never available in production. if grant["mode"] == "CONSTRUCT" and prod: broken.add("SI-7") + # SI-15 a bounded executor's credentials act only under an admission (NK-WP-0056): never a person, never + # interactive, every grant APPROVED, never sovereign, rule-changing or build-mode authority. + if "executor" in conventions: + if principal not in ("WORKLOAD", "SERVICE", "AGENT") or identity["interactive_login"] or persons: + broken.add("SI-15") + if not identity.get("executor_subject"): + broken.add("SI-15") + for grant in grants: + if grant.get("activation") != "APPROVED" or grant["mode"] in ("SOVEREIGN", "GOVERN", "CONSTRUCT"): + broken.add("SI-15") # SI-8 assurance: people and service admins need a second factor; admin roles bind the claim. if principal == "HUMAN" or "service-admin" in conventions: if second == "unknown": @@ -264,12 +274,41 @@ def run(paths: list[pathlib.Path], as_of: dt.date) -> tuple[list[dict[str, Any]] return reports, rejected +def _covers(grant: dict[str, Any], need: dict[str, Any]) -> bool: + return grant["mode"] == need["mode"] and (need["scope"] == grant["scope"] + or need["scope"].startswith(grant["scope"] + "/")) + + +def admission_coverage(documents: list[dict[str, Any]], admissions: list[dict[str, Any]]) -> list[dict[str, Any]]: + """SI-15: each admission's scope ceiling lies inside the declared grants of its executor's credentials. + + The executor's authority is the union of grants of every declared identity whose executor_subject is the + admission's spec.executor.identity, in the admission's environments. Locations only, never values.""" + results = [] + for admission in admissions: + spec = admission.get("spec", {}) + subject = spec.get("executor", {}).get("identity") + held = [g for d in documents for i in d["identities"] if i.get("executor_subject") == subject + for g in i["grants"]] + outside = [] + for index, need in enumerate(spec.get("scope_ceiling", [])): + ok = any(_covers(g, need) and set(need.get("environments", [])) <= set(g.get("environments", [])) + for g in held) + if not ok: + outside.append(f"spec/scope_ceiling/{index}") + results.append({"admission": spec.get("id"), "executor_declared": bool(held), "outside_declared_grants": outside, + "covered": bool(held) and not outside}) + return results + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("paths", nargs="+", type=pathlib.Path) parser.add_argument("--as-of", default=dt.date.today().isoformat(), help="evaluation date, YYYY-MM-DD") parser.add_argument("--json", action="store_true", help="machine-readable output") parser.add_argument("--fail-on-findings", action="store_true", help="exit 2 when anything is non-conformant or unknown") + parser.add_argument("--admissions", nargs="+", type=pathlib.Path, default=[], + help="bounded-execution admissions whose ceilings must lie inside the declared executor grants (SI-15)") args = parser.parse_args(argv) try: as_of = parse_date(args.as_of) @@ -277,6 +316,18 @@ def main(argv: list[str] | None = None) -> int: print("service identity: invalid --as-of", file=sys.stderr) return 1 reports, rejected = run(args.paths, as_of) + coverage = [] + if args.admissions: + schema = load_schema() + documents = [] + for path in args.paths: + try: + document = load_declaration(path) + except (OSError, ValueError, yaml.YAMLError): + continue + if not structural_errors(document, schema): + documents.append(document) + coverage = admission_coverage(documents, [load_declaration(p) for p in args.admissions]) for line in rejected: print(f"service identity: {line}", file=sys.stderr) counts: dict[str, int] = {k: 0 for k in ORDER} @@ -284,7 +335,8 @@ def main(argv: list[str] | None = None) -> int: for item in report["identities"]: counts[item["result"]] += 1 if args.json: - print(json.dumps({"as_of": as_of.isoformat(), "counts": counts, "reports": reports, "rejected": len(rejected)}, indent=2)) + print(json.dumps({"as_of": as_of.isoformat(), "counts": counts, "reports": reports, "rejected": len(rejected), + **({"admission_coverage": coverage} if args.admissions else {})}, indent=2)) else: for report in reports: for item in sorted(report["identities"], key=lambda i: (ORDER[i["result"]], i["identity"])): @@ -294,8 +346,14 @@ def main(argv: list[str] | None = None) -> int: for note in report["advisories"]: print(f"{report['service']}: advisory: {note}") print("summary: " + ", ".join(f"{counts[k]} {k}" for k in ORDER) + f" (as of {as_of.isoformat()})") + for item in coverage: + state = "covered" if item["covered"] else ("no declared executor" if not item["executor_declared"] + else "outside declared grants: " + ", ".join(item["outside_declared_grants"])) + print(f"admission {item['admission']}: {state}") if rejected: return 1 + if any(not item["covered"] for item in coverage): + return 2 if args.fail_on_findings and (counts["non-conformant"] or counts["unknown"]): return 2 return 0 diff --git a/workplans/NK-WP-0056-bounded-executor-path.md b/workplans/NK-WP-0056-bounded-executor-path.md index c26cd68..0f980a9 100644 --- a/workplans/NK-WP-0056-bounded-executor-path.md +++ b/workplans/NK-WP-0056-bounded-executor-path.md @@ -117,7 +117,7 @@ railiance-platform, audit-core, glas-harness and railiance-apps ```task id: NK-WP-0056-T03 -status: todo +status: done priority: high assignee: claude-code depends_on: [NK-WP-0056-T02] @@ -132,6 +132,19 @@ it. **Done when:** the declaration validates and the Role is prepared in source with tests, not applied. +**Done 2026-10-31.** `tools/service-identity/declarations/bounded-executor.yaml` declares the executor's credentials +(all `planned`, all sharing `executor_subject: service:nk-bind-executor`): the Kubernetes ServiceAccount, its OpenBao +role, `svc-executor-pw` (`lldap_password_manager`: LLDAP cannot narrow to one account, the admission narrows it), +the read-only bind `svc-executor-read`, and `svc-executor-admin` (`lldap_admin`, root-class only, per-run activation +open). `tools/service-identity` gained convention `executor` with rule SI-15 (no person, not interactive, every grant +`APPROVED`, no `SOVEREIGN`/`GOVERN`/`CONSTRUCT`) and `--admissions`, which checks that an admission's ceiling lies inside +the executor's declared grants: the pilot (BEA-2026-0001) is covered; the root-class and read-only examples are +correctly reported as needing grants the pilot does not hold. `sso-mfa/k8s/bounded-executor/rbac.yaml` (not applied): +ServiceAccount without automount, a Role with `get/watch/patch` on the authelia Deployment and `get/patch` on the +authelia ExternalSecret only (no read of credential objects: the pilot's delivery check became "ExternalSecret Ready +and refreshed"), and its RoleBinding; `test_rbac.py` ties Role = declaration = pilot ceiling. 44 identity tests, +5 RBAC tests. + ## Service credentials into OpenBao ```task