atlas-iac/scripts/ops/hermes_handoff_checks_access.py

340 lines
10 KiB
Python

#!/usr/bin/env python3
"""Acceptance checks for what Hermes may and may not do to the cluster.
Denials use Kubernetes authorization reviews only. Live denied attempts were
removed because a policy regression could turn a supposed probe into a real
credential read or mutation; the default harness never constructs such argv.
"""
from __future__ import annotations
from hermes_handoff_catalog import (
FLUX_PROJECTION,
OPERATOR,
SELF,
Targets,
check,
step,
)
from hermes_handoff_model import REVIEW, CheckSpec
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 binding inventory and in-pod authorization reviews are independent desired/enforced views.",
),
check(
"access.secrets-are-denied",
"Hermes cannot read Secrets cluster-wide",
"access-denied",
"review_denied",
[
step(
"review",
SELF,
"kubectl",
"auth",
"can-i",
"get",
"secrets",
"--all-namespaces",
kind=REVIEW,
)
],
rationale="A live Secret request is never constructed, even when an RBAC regression would allow it.",
),
check(
"access.service-account-tokens-are-denied",
"Hermes cannot mint a service-account token",
"access-denied",
"review_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,
),
],
rationale="Token creation has no credential-safe live probe, so both relevant grants must be denied by review.",
),
check(
"access.impersonation-is-denied",
"Hermes cannot impersonate another identity",
"access-denied",
"review_denied",
[
step(
"review",
SELF,
"kubectl",
"auth",
"can-i",
"impersonate",
"users",
"--all-namespaces",
kind=REVIEW,
)
],
rationale="The default harness never constructs an impersonated request.",
),
check(
"access.workload-mutation-is-denied",
"Hermes cannot mutate its own workload",
"access-denied",
"review_denied",
[
step(
"review",
SELF,
"kubectl",
"auth",
"can-i",
"patch",
"deployments",
"--namespace",
namespace,
kind=REVIEW,
)
],
rationale="Client-side and server-side dry-run mutation tricks are excluded structurally.",
),
check(
"access.pod-exec-is-denied",
"Hermes cannot exec into an arbitrary pod",
"access-denied",
"review_denied",
[
step(
"review",
SELF,
"kubectl",
"auth",
"can-i",
"create",
"pods/exec",
"--all-namespaces",
kind=REVIEW,
)
],
rationale="No cross-pod exec request is created by a read-only run.",
),
check(
"access.attach-portforward-and-kube-system-writes-are-denied",
"Hermes holds no attach, port-forward, or control-plane write authority",
"access-denied",
"review_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,
),
],
rationale="Every authority is reviewed without issuing the request it would authorize.",
),
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.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)]