Enforce private-by-default rail exposure

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa
This commit is contained in:
codex 2026-08-22 12:34:25 +02:00
parent c6b045051f
commit 004f1c4dc1
7 changed files with 368 additions and 7 deletions

View file

@ -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"))

View file

@ -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'

110
tools/exposure.py Normal file
View file

@ -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}

34
tools/validate_exposure.py Executable file
View file

@ -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())