235 lines
9.5 KiB
Python
235 lines
9.5 KiB
Python
|
|
#!/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)]
|