From 004f1c4dc1b6bfba6abd4441f300be66e73846ba Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 22 Aug 2026 12:34:25 +0200 Subject: [PATCH] Enforce private-by-default rail exposure Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa --- docs/create-overlay-command.md | 5 + tests/test_private_exposure.py | 133 ++++++++++++++++++ tools/cmd/railiance-stage2 | 32 +++++ tools/create_railiance_overlay_repo.sh | 36 ++++- tools/exposure.py | 110 +++++++++++++++ tools/validate_exposure.py | 34 +++++ ...S-WP-0003-private-by-default-networking.md | 25 +++- 7 files changed, 368 insertions(+), 7 deletions(-) create mode 100644 tests/test_private_exposure.py create mode 100644 tools/exposure.py create mode 100755 tools/validate_exposure.py diff --git a/docs/create-overlay-command.md b/docs/create-overlay-command.md index 325d564..daa031d 100644 --- a/docs/create-overlay-command.md +++ b/docs/create-overlay-command.md @@ -24,6 +24,11 @@ bin/railiance create-overlay \ --init-git ``` +The generated Service is explicitly `ClusterIP`, a default-deny ingress +NetworkPolicy is included, and Stage 2 does not emit an Ingress. Enabling an +Ingress later requires matching rapp and reef declaration files at the +Stage 2 deploy gate; an Ingress object is not itself an ADR-0008 grant. + Required arguments: - `--app-id` diff --git a/tests/test_private_exposure.py b/tests/test_private_exposure.py new file mode 100644 index 0000000..14f3986 --- /dev/null +++ b/tests/test_private_exposure.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +from exposure import ExposureError, validate_rendered_exposure # noqa: E402 + + +PRIVATE = """apiVersion: v1 +kind: Service +metadata: {name: example} +spec: {type: ClusterIP} +""" + +PUBLIC = """apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: {name: example} +spec: + rules: + - host: app.example.test +""" + + +class ExposureTests(unittest.TestCase): + def declarations(self, rapp: str, reef: str): + temp = tempfile.TemporaryDirectory() + root = Path(temp.name) + rapp_path = root / "rapp.yaml" + reef_path = root / "reef.yaml" + rapp_path.write_text(rapp, encoding="utf-8") + reef_path.write_text(reef, encoding="utf-8") + return rapp_path, reef_path, temp + + def test_private_manifest_needs_no_declarations(self) -> None: + result = validate_rendered_exposure(PRIVATE) + self.assertEqual("private", result["posture"]) + + def test_public_manifest_fails_without_declarations(self) -> None: + with self.assertRaisesRegex(ExposureError, "rapp-declaration"): + validate_rendered_exposure(PUBLIC) + + def test_matching_grants_pass(self) -> None: + rapp, reef, temp = self.declarations( + """exposure: + posture: public + binding_admission: production-approved + grant: + hostname: app.example.test + reason: test + approved_on: '2026-08-22' + residual_risk_owner: test +""", + """exposure: + posture: public + grants: + - port: 443 + reason: test + approved_on: '2026-08-22' + residual_risk_owner: test +""", + ) + self.addCleanup(temp.cleanup) + result = validate_rendered_exposure(PUBLIC, rapp_declaration=rapp, reef_declaration=reef) + self.assertEqual(["app.example.test"], result["public_hosts"]) + + def test_unapproved_binding_fails(self) -> None: + rapp, reef, temp = self.declarations( + """exposure: + posture: public + binding_admission: verified + grant: {hostname: app.example.test} +""", + """exposure: + posture: public + grants: [{port: 443}] +""", + ) + self.addCleanup(temp.cleanup) + with self.assertRaisesRegex(ExposureError, "production-approved"): + validate_rendered_exposure(PUBLIC, rapp_declaration=rapp, reef_declaration=reef) + + def test_mismatched_hostname_fails(self) -> None: + rapp, reef, temp = self.declarations( + """exposure: + posture: public + binding_admission: production-approved + grant: + hostname: other.example.test + reason: test + approved_on: '2026-08-22' + residual_risk_owner: test +""", + """exposure: + posture: public + grants: + - port: 443 + reason: test + approved_on: '2026-08-22' + residual_risk_owner: test +""", + ) + self.addCleanup(temp.cleanup) + with self.assertRaisesRegex(ExposureError, "does not name"): + validate_rendered_exposure(PUBLIC, rapp_declaration=rapp, reef_declaration=reef) + + def test_incomplete_grant_fails(self) -> None: + rapp, reef, temp = self.declarations( + """exposure: + posture: public + binding_admission: production-approved + grant: {hostname: app.example.test} +""", + """exposure: + posture: public + grants: + - port: 443 + reason: test + approved_on: '2026-08-22' + residual_risk_owner: test +""", + ) + self.addCleanup(temp.cleanup) + with self.assertRaisesRegex(ExposureError, "rapp grant is missing"): + validate_rendered_exposure(PUBLIC, rapp_declaration=rapp, reef_declaration=reef) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/cmd/railiance-stage2 b/tools/cmd/railiance-stage2 index 2961eb8..416ad22 100755 --- a/tools/cmd/railiance-stage2 +++ b/tools/cmd/railiance-stage2 @@ -8,6 +8,7 @@ import json import shutil import subprocess import sys +import tempfile import time import tomllib import urllib.parse @@ -17,6 +18,9 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exposure import ExposureError, validate_rendered_exposure # noqa: E402 + SUPPORTED_SCHEMA = "railiance.app.v1" @@ -231,6 +235,8 @@ def deploy(argv: list[str]) -> int: parser.add_argument("--apply", action="store_const", const="apply", dest="mode") parser.add_argument("--server-dry-run", action="store_const", const="server-dry-run", dest="mode") parser.add_argument("--approval-id", help="Operator approval/progress id required before apply when declared.") + parser.add_argument("--rapp-declaration", type=Path) + parser.add_argument("--reef-declaration", type=Path) parser.add_argument("--stage1-result", help="Optional Stage 1 result JSON for same-candidate evidence.") parser.add_argument("--timeout-minutes", type=int, default=10) parser.add_argument("--json-out") @@ -242,6 +248,32 @@ def deploy(argv: list[str]) -> int: context = stage2_context(app_dir, contract_path, data) checks = local_prechecks(app_dir, data, args.mode, args.approval_id) + if args.mode in {"server-dry-run", "apply"} and shutil.which("helm"): + with tempfile.TemporaryDirectory(prefix="railiance-exposure-"): + render_args = [ + "helm", "template", context["release"], context["chart"], + "--namespace", context["namespace"], "-f", context["values"], + ] + rendered = subprocess.run( + render_args, cwd=app_dir, text=True, capture_output=True, + timeout=args.timeout_minutes * 60, check=False, + ) + if rendered.returncode != 0: + checks.append(precheck("exposure-render", "failed", True, "helm template failed")) + else: + try: + exposure = validate_rendered_exposure( + rendered.stdout, + rapp_declaration=args.rapp_declaration, + reef_declaration=args.reef_declaration, + ) + checks.append(precheck( + "adr-0008-exposure", "passed", True, + f"posture={exposure['posture']} hosts={len(exposure['public_hosts'])}", + )) + except ExposureError as exc: + checks.append(precheck("adr-0008-exposure", "failed", True, str(exc))) + if args.stage1_result: try: stage1 = json.loads(Path(args.stage1_result).read_text(encoding="utf-8")) diff --git a/tools/create_railiance_overlay_repo.sh b/tools/create_railiance_overlay_repo.sh index 8a44e30..44940cc 100755 --- a/tools/create_railiance_overlay_repo.sh +++ b/tools/create_railiance_overlay_repo.sh @@ -479,6 +479,7 @@ metadata: annotations: {{ include "railiance.prometheusAnnotations" . | nindent 4 }} spec: + type: ClusterIP selector: {{ include "railiance.selectorLabels" . | nindent 4 }} ports: @@ -487,6 +488,30 @@ spec: targetPort: http EOF +cat > "${OUT_DIR}/charts/${APP_ID}/templates/networkpolicy.yaml" <<'EOF' +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "railiance.releaseName" . }}-default-deny + labels: +{{ include "railiance.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: +{{ include "railiance.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + ingress: + - from: + - podSelector: {} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik +EOF + cat > "${OUT_DIR}/charts/${APP_ID}/templates/ingress.yaml" <<'EOF' {{- if and .Values.ingress.enabled (ne .Values.railiance.traffic.mode "weighted") }} apiVersion: networking.k8s.io/v1 @@ -631,8 +656,10 @@ image: tag: ${UPSTREAM_REVISION} replicaCount: 2 ingress: - enabled: true - host: ${APP_ID}.local + # ADR-0008: Stage 2 is private unless the rapp and reef declarations carry + # matching public grants and the binding is production-approved. + enabled: false + host: "" path: / annotations: railiance.coulomb.social/stage: stable @@ -664,6 +691,7 @@ for rel in ('${APP_ID}', '${APP_ID}-canary'): required_paths = [ 'charts/${APP_ID}/templates/deployment.yaml', 'charts/${APP_ID}/templates/service.yaml', + 'charts/${APP_ID}/templates/networkpolicy.yaml', 'charts/${APP_ID}/templates/ingress.yaml', 'charts/${APP_ID}/templates/traefik-weighted.yaml', 'values/stage2-canary.yaml', @@ -685,7 +713,9 @@ if command -v helm >/dev/null 2>&1; then helm template ${APP_ID}-canary charts/${APP_ID} -f values/stage2-canary.yaml >/tmp/${APP_ID}-stage2-canary-render.yaml grep -q 'kind: Deployment' /tmp/${APP_ID}-stage2-canary-render.yaml grep -q 'kind: Service' /tmp/${APP_ID}-stage2-canary-render.yaml - grep -q 'kind: Ingress' /tmp/${APP_ID}-stage2-canary-render.yaml + grep -q 'type: ClusterIP' /tmp/${APP_ID}-stage2-canary-render.yaml + grep -q 'kind: NetworkPolicy' /tmp/${APP_ID}-stage2-canary-render.yaml + ! grep -q 'kind: Ingress' /tmp/${APP_ID}-stage2-canary-render.yaml echo 'stage2 helm template ok' else echo 'helm unavailable; verified stage2 canary scaffold files only' diff --git a/tools/exposure.py b/tools/exposure.py new file mode 100644 index 0000000..8787595 --- /dev/null +++ b/tools/exposure.py @@ -0,0 +1,110 @@ +"""ADR-0008 exposure validation for rendered Kubernetes manifests.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + + +class ExposureError(ValueError): + """Rendered public exposure is not backed by the required declarations.""" + + +def _load(path: Path | None, label: str) -> dict[str, Any]: + if path is None: + raise ExposureError(f"public manifest requires --{label}-declaration") + try: + payload = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise ExposureError(f"cannot read {label} declaration {path}: {exc}") from exc + if not isinstance(payload, dict): + raise ExposureError(f"{label} declaration must be a YAML object") + return payload + + +def _public_hosts(documents: list[dict[str, Any]]) -> list[str]: + hosts: set[str] = set() + public_kinds = 0 + for document in documents: + kind = document.get("kind") + spec = document.get("spec") or {} + if kind == "Ingress": + public_kinds += 1 + for rule in spec.get("rules") or []: + host = rule.get("host") if isinstance(rule, dict) else None + if host: + hosts.add(str(host)) + elif kind == "IngressRoute": + public_kinds += 1 + for route in spec.get("routes") or []: + match = str(route.get("match", "")) if isinstance(route, dict) else "" + hosts.update(re.findall(r"Host\(`([^`]+)`\)", match)) + if public_kinds and not hosts: + raise ExposureError("public routing resource has no exact hostname") + return sorted(hosts) + + +def _grant_matches(grant: Any, hostname: str, *, allow_port: bool) -> bool: + if not isinstance(grant, dict): + return False + if grant.get("hostname") == hostname: + return True + return allow_port and grant.get("port") in {80, 443, "80", "443"} + + +def _require_grant_metadata(grant: Any, label: str) -> None: + if not isinstance(grant, dict): + raise ExposureError(f"{label} grant must be an object") + missing = [ + field for field in ("reason", "approved_on", "residual_risk_owner") + if not grant.get(field) + ] + if missing: + raise ExposureError(f"{label} grant is missing {', '.join(missing)}") + + +def validate_rendered_exposure( + rendered: str, + *, + rapp_declaration: Path | None = None, + reef_declaration: Path | None = None, +) -> dict[str, Any]: + """Validate all Ingress/IngressRoute hosts against rapp and reef grants.""" + + try: + documents = [item for item in yaml.safe_load_all(rendered) if isinstance(item, dict)] + except yaml.YAMLError as exc: + raise ExposureError(f"rendered manifest is not valid YAML: {exc}") from exc + hosts = _public_hosts(documents) + if not hosts: + return {"posture": "private", "public_hosts": [], "validated": True} + + rapp = _load(rapp_declaration, "rapp") + reef = _load(reef_declaration, "reef") + rapp_exposure = rapp.get("exposure") or {} + reef_exposure = reef.get("exposure") or {} + if rapp_exposure.get("posture") != "public": + raise ExposureError("rapp exposure.posture must be public") + if rapp_exposure.get("binding_admission") != "production-approved": + raise ExposureError("rapp exposure.binding_admission must be production-approved") + if reef_exposure.get("posture") != "public": + raise ExposureError("reef exposure.posture must be public") + + rapp_grant = rapp_exposure.get("grant") + reef_grants = reef_exposure.get("grants") or [] + _require_grant_metadata(rapp_grant, "rapp") + for hostname in hosts: + if not _grant_matches(rapp_grant, hostname, allow_port=False): + raise ExposureError(f"rapp grant does not name rendered hostname {hostname}") + matching_reef_grants = [ + grant for grant in reef_grants + if _grant_matches(grant, hostname, allow_port=True) + ] + if not matching_reef_grants: + raise ExposureError(f"reef has no public substrate grant for {hostname}") + for grant in matching_reef_grants: + _require_grant_metadata(grant, "reef") + return {"posture": "public", "public_hosts": hosts, "validated": True} diff --git a/tools/validate_exposure.py b/tools/validate_exposure.py new file mode 100755 index 0000000..641a41b --- /dev/null +++ b/tools/validate_exposure.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Validate a rendered manifest against ADR-0008 declarations.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from exposure import ExposureError, validate_rendered_exposure + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("rendered_manifest", type=Path) + parser.add_argument("--rapp-declaration", type=Path) + parser.add_argument("--reef-declaration", type=Path) + args = parser.parse_args() + try: + result = validate_rendered_exposure( + args.rendered_manifest.read_text(encoding="utf-8"), + rapp_declaration=args.rapp_declaration, + reef_declaration=args.reef_declaration, + ) + except (OSError, ExposureError) as exc: + print(f"exposure validation failed: {exc}", file=sys.stderr) + return 1 + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workplans/RAIL-K8S-WP-0003-private-by-default-networking.md b/workplans/RAIL-K8S-WP-0003-private-by-default-networking.md index e36e46c..0cb000e 100644 --- a/workplans/RAIL-K8S-WP-0003-private-by-default-networking.md +++ b/workplans/RAIL-K8S-WP-0003-private-by-default-networking.md @@ -4,11 +4,11 @@ type: workplan title: "Private-by-default networking until an exposure grant exists" domain: financials repo: rail-kubernetes -status: ready +status: finished owner: codex topic_slug: railiance created: "2026-08-15" -updated: "2026-08-15" +updated: "2026-08-22" related: - RMASTER-WP-0023 - ADR-0008 @@ -33,7 +33,7 @@ Do not define what production-safe means (ADR-0006). Do not open `6443`. ```task id: RAIL-K8S-WP-0003-T01 -status: todo +status: done priority: high ``` @@ -44,11 +44,16 @@ Document the operator/tunnel path for debug. Missing `exposure` means **Done when:** a new rapp on this rail has no public listener unless a grant exists. +**Outcome (2026-08-22):** generated overlays now render an explicit +`ClusterIP` Service, a default-deny ingress `NetworkPolicy`, and no Ingress by +default. The scaffold regression test renders the generated chart and asserts +all three properties. + ## T02 — Gate public Ingress on the grant ```task id: RAIL-K8S-WP-0003-T02 -status: todo +status: done priority: high ``` @@ -57,3 +62,15 @@ valid grant. An Ingress object is not itself a grant. **Done when:** an ungranted rapp cannot obtain a public Ingress from the paved path. + +**Outcome (2026-08-22):** Stage 2 renders the chart before server dry-run or +apply and validates any Ingress/IngressRoute against both the rapp and reef +declarations. Private is the fail-closed default; a public surface requires a +matching grant and `production-approved` binding admission. Regression tests +cover missing, mismatched, and valid grants. + +## Completion evidence + +- `python3 -m unittest discover -s tests -v`: 6 tests passed. +- Generated overlay `tests/stage2-template.sh`: passed, including Helm render. +- `bash -n tools/create_railiance_overlay_repo.sh`: passed.