#!/usr/bin/env python3 """Acceptance checks for what Hermes may and may not do to the cluster. Every deny check pairs an in-pod authorization review with a real request that must be refused, because a review reports what RBAC says and only an attempt proves the API server enforces it. Mutating attempts go through a server dry run: the API server authorises the request and discards it, so the refusal is genuine and nothing is written either way. """ from __future__ import annotations from hermes_handoff_catalog import ( FLUX_PROJECTION, OPERATOR, SELF, Targets, check, deny, step, ) from hermes_handoff_model import ATTEMPT, REVIEW, CheckSpec from hermes_handoff_policy import shell def _access(targets: Targets) -> list[CheckSpec]: namespace = targets.namespace return [ check( "access.no-cluster-admin-binding-for-agent", "No ClusterRoleBinding grants the agent service account cluster-admin", "access-denied", "names_absent", [step("bindings", OPERATOR, "kubectl", "get", "clusterrolebindings", "-o", "name")], {"step": "bindings", "contains": ("hermes-agent-cluster-admin",)}, rationale="The operator half of the pair; the in-pod attempts below are the enforcement half.", ), deny( "access.secrets-are-denied", "Hermes cannot read Secrets cluster-wide", ("kubectl", "auth", "can-i", "get", "secrets", "--all-namespaces"), ("kubectl", "get", "secrets", "--all-namespaces", "-o", "name"), "Even a regression that granted this could only return object names: the probe is pinned to -o name.", ), check( "access.service-account-tokens-are-denied", "Hermes cannot mint a service-account token", "access-denied", "denied", [ step( "review-token", SELF, "kubectl", "auth", "can-i", "create", "serviceaccounts/token", "--namespace", namespace, kind=REVIEW, ), step( "review-serviceaccount", SELF, "kubectl", "auth", "can-i", "create", "serviceaccounts", "--namespace", namespace, kind=REVIEW, ), step( "attempt", SELF, "kubectl", "--namespace", namespace, "create", "serviceaccount", "hermes-acceptance-probe", "--dry-run=server", kind=ATTEMPT, ), ], rationale="TokenRequest has no side-effect-free live attempt — a successful one would mint a real credential — so it is asserted by review alongside a refused write in the same RBAC family.", ), deny( "access.impersonation-is-denied", "Hermes cannot impersonate another identity", ("kubectl", "auth", "can-i", "impersonate", "users", "--all-namespaces"), ("kubectl", "get", "namespaces", "--as", "system:admin", "-o", "name"), "`--as` is reachable only from inside the pod, where the identity is testing its own refusal.", ), deny( "access.workload-mutation-is-denied", "Hermes cannot mutate its own workload", ("kubectl", "auth", "can-i", "patch", "deployments", "--namespace", namespace), ( "kubectl", "--namespace", namespace, "patch", f"deploy/{targets.agent_deployment}", "--type=merge", "--patch", '{"metadata":{"annotations":{"acceptance.bstein.dev/probe":"deny"}}}', "--dry-run=server", ), "A server dry run is authorised and then discarded, so the refusal is real and nothing is written.", ), deny( "access.pod-exec-is-denied", "Hermes cannot exec into an arbitrary pod", ("kubectl", "auth", "can-i", "create", "pods/exec", "--all-namespaces"), ( "kubectl", "--namespace", namespace, "exec", f"deploy/{targets.switchyard_deployment}", "--container", targets.switchyard_container, "--", *shell("path_readable", path="/tmp"), ), "Exec is the one subresource with a harmless real attempt; attach and port-forward are covered by their own reviews.", ), check( "access.attach-portforward-and-kube-system-writes-are-denied", "Hermes holds no attach, port-forward, or control-plane write authority", "access-denied", "denied", [ step("review-attach", SELF, "kubectl", "auth", "can-i", "create", "pods/attach", "--all-namespaces", kind=REVIEW), step("review-portforward", SELF, "kubectl", "auth", "can-i", "create", "pods/portforward", "--all-namespaces", kind=REVIEW), step("review-create", SELF, "kubectl", "auth", "can-i", "create", "configmaps", "--namespace", "kube-system", kind=REVIEW), step( "attempt", SELF, "kubectl", "--namespace", "kube-system", "create", "configmap", "hermes-acceptance-probe", "--dry-run=server", kind=ATTEMPT, ), ], rationale="Attach and port-forward have no side-effect-free live attempt, so their reviews ride alongside a real, refused control-plane write.", ), check( "access.required-reads-are-allowed", "Hermes retains the reads its operator role depends on", "access-allowed", "allowed", [ step("watch-review", SELF, "kubectl", "auth", "can-i", "watch", "pods", "--namespace", namespace, kind=REVIEW), step("pods", SELF, "kubectl", "--namespace", namespace, "get", "pods", "-o", "name"), step("namespaces", SELF, "kubectl", "get", "namespaces", "-o", "name"), step("deployments", SELF, "kubectl", "--namespace", namespace, "get", "deployments", "-o", "name"), step("nodes", SELF, "kubectl", "get", "nodes", "-o", "name"), ], ), check( "access.pod-logs-are-allowed", "Hermes can still read pod logs", "access-allowed", "allowed", [ step( "logs", SELF, "kubectl", "--namespace", targets.namespace, "logs", f"deploy/{targets.switchyard_deployment}", "--container", targets.switchyard_container, "--tail", "1", record=False, ) ], ), check( "access.flux-and-helm-status-are-allowed", "Hermes can read Flux and Helm reconciliation status", "access-allowed", "allowed", [ step("kustomizations", SELF, "kubectl", "get", "kustomizations.kustomize.toolkit.fluxcd.io", "--all-namespaces", "-o", "name", record=False), step("helmreleases", SELF, "kubectl", "get", "helmreleases.helm.toolkit.fluxcd.io", "--all-namespaces", "-o", "name", record=False), ], ), check( "access.namespace-view-agrees-across-vantages", "The operator and in-pod vantages see the same namespace inventory", "access-allowed", "vantages_agree", [ step("operator", OPERATOR, "kubectl", "get", "namespaces", "-o", "name", record=False), step("self", SELF, "kubectl", "get", "namespaces", "-o", "name", record=False), ], {"steps": ("operator", "self")}, rationale="A disagreement here means one vantage is not seeing the cluster the other is certifying.", ), ] def _gitops(targets: Targets) -> list[CheckSpec]: return [ check( f"gitops.{kind.split('.')[0]}-reconcile-cleanly", f"No unexpected {label} suspension or unhealthy reconciliation", "gitops", "flux_health", [step("objects", OPERATOR, "kubectl", "get", kind, "--all-namespaces", "-o", FLUX_PROJECTION, record=False)], {"step": "objects", "expected_suspensions": targets.expected_suspensions}, ) for kind, label in ( ("kustomizations.kustomize.toolkit.fluxcd.io", "Kustomization"), ("helmreleases.helm.toolkit.fluxcd.io", "HelmRelease"), ) ] def access_checks(targets: Targets) -> list[CheckSpec]: """Return the access-denied, access-allowed, and GitOps health checks.""" return [*_access(targets), *_gitops(targets)]