diff --git a/argocd/platform-addons/ops-mason-builder/clusterrole.yaml b/argocd/platform-addons/ops-mason-builder/clusterrole.yaml new file mode 100644 index 0000000..9b9f8fa --- /dev/null +++ b/argocd/platform-addons/ops-mason-builder/clusterrole.yaml @@ -0,0 +1,29 @@ +# Scoped ops-mason builder (founder decision 2026-10-22, MASON-IN-0006 (b)). +# Kinds kubernetes_plane applies, plus get/list/watch on services for dependency +# reads, plus create on self-subject access reviews so kubectl auth can-i works. +# Excluded: pods, secrets, configmaps, serviceaccounts/token, impersonate, +# roles, rolebindings, clusterroles, clusterrolebindings, nodes/proxy, pods/exec, +# escalate, bind, wildcards. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: ops-mason-builder + labels: + app.kubernetes.io/part-of: railiance-gitops + railiance-platform/component: ops-mason-builder +rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch"] + - apiGroups: ["authorization.k8s.io"] + resources: ["selfsubjectaccessreviews", "selfsubjectrulesreviews"] + verbs: ["create"] diff --git a/argocd/platform-addons/ops-mason-builder/clusterrolebinding.yaml b/argocd/platform-addons/ops-mason-builder/clusterrolebinding.yaml new file mode 100644 index 0000000..41ec5d6 --- /dev/null +++ b/argocd/platform-addons/ops-mason-builder/clusterrolebinding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: ops-mason-builder + labels: + app.kubernetes.io/part-of: railiance-gitops + railiance-platform/component: ops-mason-builder +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ops-mason-builder +subjects: + - kind: User + name: ops-mason-builder + apiGroup: rbac.authorization.k8s.io diff --git a/argocd/platform-addons/ops-mason-builder/kustomization.yaml b/argocd/platform-addons/ops-mason-builder/kustomization.yaml new file mode 100644 index 0000000..a37d052 --- /dev/null +++ b/argocd/platform-addons/ops-mason-builder/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# MASON-IN-0006 choice (b), decision 30d29060-8d14-4a8e-9cde-34aa826eb8d1. +# Scoped builder for ops-mason. User ops-mason-builder, no ServiceAccount token, +# no Secret. Applied by argocd/railiance01/applications/ops-mason-builder.application.yaml. +# Manual sync. targetRevision is pinned to the commit that introduced these manifests. + +resources: + - clusterrole.yaml + - clusterrolebinding.yaml diff --git a/tests/test_ops_mason_builder_rbac.py b/tests/test_ops_mason_builder_rbac.py new file mode 100644 index 0000000..1ab0f33 --- /dev/null +++ b/tests/test_ops_mason_builder_rbac.py @@ -0,0 +1,89 @@ +"""Offline guard for the ops-mason builder identity (MASON-IN-0006 choice (b)). + +Parses the GitOps manifests and asserts the founder's 2026-10-22 boundary: +the five granted rules only, no forbidden resources, no escalate/bind/impersonate, +no wildcards, and a User subject with no ServiceAccount. +""" +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +ADDON = ROOT / "argocd/platform-addons/ops-mason-builder" + +WRITE_VERBS = ["create", "get", "list", "watch", "update", "patch", "delete"] +READ_VERBS = ["get", "list", "watch"] +FORBIDDEN_RESOURCES = { + "secrets", "pods", "serviceaccounts/token", "configmaps", "roles", + "rolebindings", "clusterroles", "clusterrolebindings", "nodes", + "nodes/proxy", "pods/exec", "pods/log", +} +FORBIDDEN_VERBS = {"escalate", "bind", "impersonate"} +EXPECTED_RULES = [ + {"apiGroups": [""], "resources": ["namespaces"], "verbs": WRITE_VERBS}, + {"apiGroups": [""], "resources": ["serviceaccounts"], "verbs": WRITE_VERBS}, + {"apiGroups": ["networking.k8s.io"], "resources": ["networkpolicies"], + "verbs": WRITE_VERBS}, + {"apiGroups": [""], "resources": ["services"], "verbs": READ_VERBS}, + {"apiGroups": ["authorization.k8s.io"], + "resources": ["selfsubjectaccessreviews", "selfsubjectrulesreviews"], + "verbs": ["create"]}, +] + + +def _docs(): + kust = yaml.safe_load((ADDON / "kustomization.yaml").read_text()) + docs = [] + for res in kust["resources"]: + docs.extend(d for d in yaml.safe_load_all((ADDON / res).read_text()) if d) + return docs + + +def _one(kind): + found = [d for d in _docs() if d["kind"] == kind] + assert len(found) == 1, kind + return found[0] + + +def test_only_expected_kinds_no_secret(): + kinds = sorted(d["kind"] for d in _docs()) + assert kinds == ["ClusterRole", "ClusterRoleBinding"] + for path in ADDON.iterdir(): + if path.is_file(): + assert "kind: Secret" not in path.read_text(), path + + +def test_cluster_role_exact_rules_no_wildcards(): + role = _one("ClusterRole") + assert role["metadata"]["name"] == "ops-mason-builder" + assert "aggregationRule" not in role + assert role["rules"] == EXPECTED_RULES + granted = set() + for rule in role["rules"]: + values = [*rule["verbs"], *rule.get("apiGroups", []), *rule.get("resources", [])] + assert not any("*" in v for v in values), rule + assert FORBIDDEN_VERBS.isdisjoint(rule["verbs"]), rule + for resource in rule["resources"]: + assert resource not in FORBIDDEN_RESOURCES, resource + granted.add(resource) + assert granted == { + "namespaces", "serviceaccounts", "networkpolicies", "services", + "selfsubjectaccessreviews", "selfsubjectrulesreviews", + } + by_resource = {r["resources"][0]: r["verbs"] for r in role["rules"] if len(r["resources"]) == 1} + assert by_resource["namespaces"] == WRITE_VERBS + assert by_resource["serviceaccounts"] == WRITE_VERBS + assert by_resource["networkpolicies"] == WRITE_VERBS + assert by_resource["services"] == READ_VERBS + review = next(r for r in role["rules"] if "selfsubjectaccessreviews" in r["resources"]) + assert review["verbs"] == ["create"] + assert review["resources"] == ["selfsubjectaccessreviews", "selfsubjectrulesreviews"] + + +def test_binding_targets_only_the_user(): + binding = _one("ClusterRoleBinding") + assert binding["roleRef"]["name"] == "ops-mason-builder" + assert binding["roleRef"]["kind"] == "ClusterRole" + assert binding["subjects"] == [{"kind": "User", "name": "ops-mason-builder", + "apiGroup": "rbac.authorization.k8s.io"}] + assert all(s["kind"] != "ServiceAccount" for s in binding["subjects"])