diff --git a/catalog/render.py b/catalog/render.py index 9fd868b..8205b14 100644 --- a/catalog/render.py +++ b/catalog/render.py @@ -4,7 +4,8 @@ Consumes ONE ServiceDefinition (schemas/servicedefinition.schema.json, catalog/*.yaml) and emits the concrete manifest set the renderer contract specifies (schemas/renderer-contract.md): Namespace + default-deny baseline, Deployment/Service or -KubeVirt VirtualMachine + CDI DataVolume, PVCs, per-dependency CiliumNetworkPolicy allows, +KubeVirt VirtualMachine + CDI DataVolume, PVCs, per-dependency and per-in-cluster-client +CiliumNetworkPolicy allows, egress-class steering label, HTTPRoute(s), ServiceMonitor/alerts, Flux image-automation, and the auth-mode / backup wiring hints. @@ -116,6 +117,7 @@ def apply_defaults(sd: dict) -> dict: s.setdefault("storage", []) s.setdefault("dependencies", []) s.setdefault("ingressRoutes", []) + s.setdefault("inClusterClients", []) # auth.mode default posture: forward-auth for any exposed UI, else none (internal, no # UI). An explicit auth block always wins (R-IDP-02). @@ -141,6 +143,13 @@ def apply_defaults(sd: dict) -> dict: s["storage"] = [{**{"class": "longhorn", "readOnly": False}, **v} for v in s["storage"]] # dependency protocol default is tcp. s["dependencies"] = [{**{"protocol": "tcp"}, **d} for d in s["dependencies"]] + # in-cluster client protocol default is tcp (as for a dependency); its `name` — which only + # ever appears in the rendered POLICY name — defaults to the namespace it admits, so the + # common case declares just `namespace` + `port` and reads like a dependency edge. + s["inClusterClients"] = [ + {**{"protocol": "tcp", "name": c.get("namespace", "")}, **c} + for c in s["inClusterClients"] + ] return s @@ -700,6 +709,82 @@ def r_gateway_ingress_policy(s: dict): } +def r_in_cluster_client_policies(s: dict) -> list: + """East-west allow: let a declared IN-CLUSTER client reach this service DIRECTLY (R-NET-02). + + `ingressRoutes` opens the NORTH-SOUTH path only — r_gateway_ingress_policy admits the + reserved `ingress` identity (the Gateway proxy) and nothing else — and `dependencies` + compiles edges only BETWEEN CATALOG SERVICES (both halves are rendered from a catalog + entry's own declaration). A PLATFORM CONTROLLER that dials this service's ClusterIP + directly therefore had no authorable form at all, and under default-deny (R-NET-01) it is + DROPPED. + + That gap is not theoretical: prod's `flux-system` source-controller clones the platform's + own repo from the in-cluster Gitea Service (clusters/prod/kustomization.yaml pins + `url: http://gitea.gitea.svc.cluster.local:3000/...` so prod's GitOps depends on neither + public DNS, Cloudflare, its own Gateway nor ${BASE_DOMAIN}). gitea-allow-gateway-ingress + admits `fromEntities: [ingress]` only, so the clone was denied, the source could not + reconcile — and the HTTPRoute that would have served a public name instead lives in a + DOWNSTREAM tier that cannot apply while the source is down. Source down -> routes never + rename -> source stays down. It had to be broken with a live `kubectl apply`, i.e. with a + cluster object outside git, which one `kubectl delete` or one rebuild would take away. + + Each entry renders ONE least-privilege ingress CiliumNetworkPolicy, shaped exactly like the + reciprocal-dependency half (r_reverse_dependency_policies) but selecting the client by + NAMESPACE alone, because a platform controller's pod labels are the upstream chart's to + change and are not part of this platform's contract: `fromEndpoints` carrying ONLY the + reserved `k8s:io.kubernetes.pod.namespace` label, `toPorts` carrying ONLY the declared + port/protocol. Never `fromEntities: all`, and never the whole service. Additive: the + namespace default-deny baseline keeps denying every undeclared source and port. + + The policy NAME is `-allow--ingress`, `name` defaulting to the namespace — + it is operator-facing (`kubectl get cnp`, a Hubble drop), and letting an author pin it is + what allows an allowance that already had to be applied live to be authored under its + EXISTING name, so Flux adopts that object in place rather than creating a near-duplicate + beside it and leaving the live one unmanaged. + """ + out = [] + for client in s["inClusterClients"]: + proto = client.get("protocol", "tcp").upper() + out.append( + { + "apiVersion": "cilium.io/v2", + "kind": "CiliumNetworkPolicy", + "metadata": { + "name": f"{s['name']}-allow-{client['name']}-ingress", + "namespace": s["name"], + "labels": {"app.kubernetes.io/name": s["name"]}, + }, + "spec": { + "endpointSelector": { + "matchLabels": {"app.kubernetes.io/name": s["name"]} + }, + "ingress": [ + { + "fromEndpoints": [ + { + "matchLabels": { + "k8s:io.kubernetes.pod.namespace": client[ + "namespace" + ] + } + } + ], + "toPorts": [ + { + "ports": [ + {"port": str(client["port"]), "protocol": proto} + ] + } + ], + } + ], + }, + } + ) + return out + + def _route_meta(s: dict, route: dict) -> dict: meta = { "name": f"{s['name']}-{route['port']}", @@ -1292,6 +1377,15 @@ def render(sd: dict, image_automation: bool = True, inbound: list | None = None) ) ) + for cnp in r_in_cluster_client_policies(s): + docs.append( + ( + "CiliumNetworkPolicy in-cluster client ingress allow (R-NET-02): " + f"{cnp['metadata']['name']}", + cnp, + ) + ) + ie = r_internet_egress(s) docs.append( ( @@ -1808,6 +1902,101 @@ def _selftest() -> int: "a non-exposed service must not render a gateway-ingress allow (least privilege)", ) + # 4c) inClusterClients -> one ADDITIVE, least-privilege ingress allow per entry that admits a + # PLATFORM controller's NAMESPACE (not the reserved `ingress` identity, not `all`) to ONE + # declared port. This is what keeps a cluster whose GitOps source is an in-cluster Service + # reconcilable: fromEntities[ingress] does not cover a controller dialling the ClusterIP. + icc_docs = render( + { + "name": "g", + "source": {"image": "a:1"}, + "ingressRoutes": [{"host": "g.home.keithjackson.dev", "port": 3000}], + "auth": {"mode": "none"}, + "inClusterClients": [ + {"name": "flux", "namespace": "flux-system", "port": 3000} + ], + } + ) + icc = [ + d[1] + for d in icc_docs + if d[1]["kind"] == "CiliumNetworkPolicy" + and d[1]["metadata"]["name"] == "g-allow-flux-ingress" + ] + check( + len(icc) == 1, + "an inClusterClients entry must render one ingress CNP named " + "-allow--ingress", + ) + if icc: + p = icc[0] + check( + p["metadata"]["namespace"] == "g" + and p["spec"]["endpointSelector"]["matchLabels"] + == {"app.kubernetes.io/name": "g"}, + "in-cluster client allow must live in the service's namespace and select the service", + ) + ing = p["spec"]["ingress"] + check( + ing[0]["fromEndpoints"] + == [{"matchLabels": {"k8s:io.kubernetes.pod.namespace": "flux-system"}}], + "in-cluster client allow must match the client NAMESPACE only (reserved k8s: label)", + ) + check( + "fromEntities" not in ing[0], + "in-cluster client allow must not widen to an entity (never fromEntities: all)", + ) + icc_ports = { + (pt["port"], pt["protocol"]) + for r in ing + for tp in r["toPorts"] + for pt in tp["ports"] + } + check( + icc_ports == {("3000", "TCP")}, + "in-cluster client allow must be least-privilege (only the declared port/protocol)", + ) + # ADDITIVE: default-deny ingress stays [] and the gateway allow is untouched. + icc_dd = next(d[1] for d in icc_docs if d[1]["kind"] == "NetworkPolicy") + check( + icc_dd["spec"]["ingress"] == [], + "in-cluster client allow must be ADDITIVE — default-deny ingress stays [] (R-NET-01)", + ) + check( + any( + d[1]["metadata"].get("name") == "g-allow-gateway-ingress" + for d in icc_docs + if d[1]["kind"] == "CiliumNetworkPolicy" + ), + "in-cluster client allow must not displace the north-south gateway allow", + ) + # `name` is OPTIONAL and defaults to the namespace, so the common case is namespace + port. + icc_default = render( + { + "name": "g", + "source": {"image": "a:1"}, + "inClusterClients": [{"namespace": "monitoring", "port": 9090}], + } + ) + check( + any( + d[1]["metadata"].get("name") == "g-allow-monitoring-ingress" + for d in icc_default + if d[1]["kind"] == "CiliumNetworkPolicy" + ), + "inClusterClients.name must default to the admitted namespace", + ) + # A service that declares none must render NO such policy (grant-nothing-extra). + check( + not any( + d[1]["kind"] == "CiliumNetworkPolicy" + and d[1]["metadata"]["name"].endswith("-ingress") + and "fromEndpoints" in d[1]["spec"].get("ingress", [{}])[0] + for d in render({"name": "q", "source": {"image": "a:1"}}) + ), + "a service declaring no inClusterClients must render no in-cluster client allow", + ) + # 5) vm kind -> VirtualMachine + standalone DataVolume. vm_docs = render( { diff --git a/schemas/renderer-contract.md b/schemas/renderer-contract.md index 01d53a7..991ffca 100644 --- a/schemas/renderer-contract.md +++ b/schemas/renderer-contract.md @@ -21,6 +21,7 @@ platform must render, and cites the requirement each mapping satisfies. Changing | `egress` | `internet` | R-NET-05 | | `storage` | `[]` (config PVC still expected for stateful svc) | R-STATE-02 | | `dependencies` | `[]` → reaches **only DNS** (default-deny) | R-NET-01 | +| `inClusterClients` | `[]` → no direct east-west client; only the Gateway may reach an exposed port | R-NET-01/02 | | `auth.mode` | `forward-auth` for any exposed UI | R-IDP-02 | | `update.channel` | `stable` | R-UPD-01 | | `update.autoRollbackOnFailedHealth` | `true` | R-UPD-03 | @@ -130,6 +131,33 @@ platform must render, and cites the requirement each mapping satisfies. Changing - The `host` pattern is enforced to `