diff --git a/README.md b/README.md index 7a4ae51..70bd9c8 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ curl -sk -X DELETE https://dns.iamworkin.lan/api/v1/servers//zones/iam - **StatefulSet PVC drift**: `volumeClaimTemplates` needs explicit `volumeMode: Filesystem` or ArgoCD SSA self-heals forever. See memory `feedback_argocd_statefulset_pvc_drift.md`. - **IngressRoute namespace split**: this RKE2 Traefik install does not allow cross-namespace service refs. Keep the `IngressRoute`, backend `Service`, and TLS secret in the same namespace; if one host is shared across namespaces, duplicate the `Certificate` and move the route next to the destination service. - **Public read-only hosts**: if a public host fronts a service that also exposes admin writes internally, add a Traefik route match like `Host(...) && (Method(GET) || Method(HEAD))` on the public edge instead of trusting the app to reject unsafe methods. +- **Public read-write allowlist hosts**: if a public host accepts a tightly bounded write surface (e.g. bootstrap-JWT POST), pin the allowlist as `(Method(GET) || Method(HEAD) || Method(POST) || Method(OPTIONS))`. PUT/PATCH/DELETE must still 404 at the route. Track A's `updatecenter.iamworkin.lan` / `updates.iamworkin.lan` are the canonical example. The lint test enforces this invariant. - **Traefik VIP netpols**: when a `NetworkPolicy` allows `10.0.56.200`, also allow the post-DNAT backend ports (`8443` for TLS plus `8080` or `8000` for HTTP) or Calico will drop the rewritten flow. - **Auth-safe probes**: services behind API-key or global auth middleware should prefer `tcpSocket` probes unless `/health` is explicitly exempted before the middleware runs. - **ArgoCD must use internal Gitea URL**: `http://gitea-clusterip.gitea.svc.cluster.local:3000/bluejay/bluejay-infra.git`, not the external HTTPS URL (step-ca cert isn't trusted by ArgoCD). The `ApplicationSet` and any hand-created `Application` must both use the internal URL. diff --git a/tests/bluejay-infra-lint/FleetManifestLintTests.cs b/tests/bluejay-infra-lint/FleetManifestLintTests.cs index 10c7b97..9f14017 100644 --- a/tests/bluejay-infra-lint/FleetManifestLintTests.cs +++ b/tests/bluejay-infra-lint/FleetManifestLintTests.cs @@ -17,6 +17,17 @@ public sealed class FleetManifestLintTests "dns.iamworkin.lan", }; + // Public hosts that allow a tightly bounded write surface in addition to + // GET/HEAD. updatecenter.iamworkin.lan accepts POST /api/v1/checkin/{id} + // (bootstrap-JWT) so its allowlist is GET||HEAD||POST||OPTIONS — but + // PUT/PATCH/DELETE must still 404 at the route. Anything wider than this + // set should fail this lint. + private static readonly HashSet PublicReadWriteAllowlistHosts = new(StringComparer.Ordinal) + { + "updatecenter.iamworkin.lan", + "updates.iamworkin.lan", + }; + private static readonly HashSet ApiKeyProtectedDeployments = new(StringComparer.Ordinal) { "messageboard-web", @@ -82,6 +93,52 @@ public sealed class FleetManifestLintTests violations.Should().BeEmpty(); } + [Fact] + public void PublicReadWriteIngressRoutes_MustPinGetHeadPostOptionsAllowlist() + { + // For hosts in PublicReadWriteAllowlistHosts, the route match MUST + // contain Method(`GET`), Method(`HEAD`), Method(`POST`), and + // Method(`OPTIONS`) AND MUST NOT contain Method(`PUT`), + // Method(`PATCH`), or Method(`DELETE`). This keeps the public + // allowlist invariant against regression — see Track A's + // updatecenter-web ingressroute hardening. + var violations = Inventory.Documents + .Where(document => document.Kind == "IngressRoute") + .SelectMany(document => + document.MappingSequence("spec", "routes") + .Select(route => new + { + Document = document, + Match = ManifestNodeExtensions.Scalar(route, "match") ?? string.Empty, + })) + .Where(entry => PublicReadWriteAllowlistHosts.Any(host => entry.Match.Contains($"Host(`{host}`)", StringComparison.Ordinal))) + .SelectMany(entry => + { + var localViolations = new List(); + + foreach (var required in new[] { "GET", "HEAD", "POST", "OPTIONS" }) + { + if (!entry.Match.Contains($"Method(`{required}`)", StringComparison.Ordinal)) + { + localViolations.Add($"{entry.Document.Descriptor} is missing required Method(`{required}`)."); + } + } + + foreach (var forbidden in new[] { "PUT", "PATCH", "DELETE" }) + { + if (entry.Match.Contains($"Method(`{forbidden}`)", StringComparison.Ordinal)) + { + localViolations.Add($"{entry.Document.Descriptor} must not include Method(`{forbidden}`) on a public host."); + } + } + + return localViolations; + }) + .ToList(); + + violations.Should().BeEmpty(); + } + [Fact] public void TraefikVipNetworkPolicies_MustAllowPostDnatBackendPorts() { @@ -311,6 +368,16 @@ internal sealed class ManifestInventory Path.Combine(workspaceRoot, "FlowerCore.Media", "k8s"), Path.Combine(workspaceRoot, "FlowerCore.MenuBoard", "k8s"), Path.Combine(workspaceRoot, "FlowerCore.MessageBoard", "k8s"), + // FlowerCore.Notes/k8s/selenium/ is the live Selenium Grid + // manifest tree (consumed by deploy-selenium scripts). + // FlowerCore.Notes/k8s/guacamole/ + FlowerCore.Notes/k8s/monitoring/ + // are historical scaffolds that have diverged from the live state + // (bluejay-infra/apps/guacamole + bluejay-infra/apps/monitoring are + // canonical). Operator review is required before bringing them in + // line OR decommissioning them — keep them out of the lint scope + // until that decision lands. See xxl-regroup-2026-05-03-followup.md + // "Codex 7 §0 stop conditions" + the C7 close-session output. + Path.Combine(workspaceRoot, "FlowerCore.Notes", "k8s", "selenium"), Path.Combine(workspaceRoot, "FlowerCore.MySQL", "k8s"), Path.Combine(workspaceRoot, "FlowerCore.PHP", "k8s"), Path.Combine(workspaceRoot, "FlowerCore.Presentations", "k8s"), diff --git a/tests/bluejay-infra-lint/conftest.dev/08_public_readwrite_allowlist.rego b/tests/bluejay-infra-lint/conftest.dev/08_public_readwrite_allowlist.rego new file mode 100644 index 0000000..ebb1152 --- /dev/null +++ b/tests/bluejay-infra-lint/conftest.dev/08_public_readwrite_allowlist.rego @@ -0,0 +1,35 @@ +package bluejayinfra.public_readwrite_allowlist + +# Public hosts that allow a tightly bounded write surface in addition to +# GET/HEAD. updatecenter.iamworkin.lan accepts POST /api/v1/checkin/{id} +# (bootstrap-JWT) so its allowlist is GET||HEAD||POST||OPTIONS — but +# PUT/PATCH/DELETE must still 404 at the route. Any host in this set MUST +# include all four required methods AND MUST NOT include any forbidden +# method. +public_readwrite_hosts := {"updatecenter.iamworkin.lan", "updates.iamworkin.lan"} + +required_methods := {"GET", "HEAD", "POST", "OPTIONS"} + +forbidden_methods := {"PUT", "PATCH", "DELETE"} + +deny[msg] { + input.kind == "IngressRoute" + route := input.spec.routes[_] + match := object.get(route, "match", "") + host := public_readwrite_hosts[_] + contains(match, sprintf("Host(`%s`)", [host])) + required := required_methods[_] + not contains(match, sprintf("Method(`%s`)", [required])) + msg := sprintf("IngressRoute %s/%s is missing required Method(%s) for public read-write host %s", [input.metadata.namespace, input.metadata.name, required, host]) +} + +deny[msg] { + input.kind == "IngressRoute" + route := input.spec.routes[_] + match := object.get(route, "match", "") + host := public_readwrite_hosts[_] + contains(match, sprintf("Host(`%s`)", [host])) + forbidden := forbidden_methods[_] + contains(match, sprintf("Method(`%s`)", [forbidden])) + msg := sprintf("IngressRoute %s/%s must not include Method(%s) on public read-write host %s", [input.metadata.namespace, input.metadata.name, forbidden, host]) +}