diff --git a/packages/extra/gateway/README.md b/packages/extra/gateway/README.md index 063dde32..b170e962 100644 --- a/packages/extra/gateway/README.md +++ b/packages/extra/gateway/README.md @@ -14,10 +14,16 @@ Per-tenant Gateway API Gateway backed by Cilium. Installed automatically when `t ## Security model -Two layers protect cross-tenant isolation: +Six independent layers protect cross-tenant isolation. Compromising one of them does not bypass the others; admission-time checks (layers 2–5) fail closed (`failurePolicy: Fail`, `validationActions: [Deny]`). -1. **Namespace whitelist on listeners.** The Gateway's `allowedRoutes.namespaces.from: Selector` only accepts HTTPRoutes from the publishing tenant's namespace and from `publishing.gateway.attachedNamespaces` in the platform chart (default: cozy-cert-manager, cozy-dashboard, cozy-keycloak, cozy-system, cozy-harbor, cozy-bucket, cozy-kubevirt, cozy-kubevirt-cdi, cozy-monitoring, cozy-linstor-gui). A tenant namespace that is not on this list cannot attach an HTTPRoute to this Gateway at all. -2. **Hostname ownership via ValidatingAdmissionPolicy.** `cozystack-gateway-hostname-policy` (installed by cozystack-basics when `gateway.enabled` is true) rejects any Gateway whose listener hostnames fall outside the tenant's own domain suffix. For `tenant-root` the allowed suffix is `publishing.host`; for any `tenant-` it is `.publishing.host`. Other namespaces are rejected outright. +1. **Namespace whitelist on listeners.** The Gateway's `allowedRoutes.namespaces.from: Selector` matches the built-in `kubernetes.io/metadata.name` label (written by kube-apiserver, unspoofable). It accepts routes from the publishing tenant's namespace plus `publishing.gateway.attachedNamespaces` in the platform chart (default includes the `cozy-*` namespaces for platform services and `default` for the Kubernetes API TLSRoute). A namespace outside the list literally cannot attach any `HTTPRoute` or `TLSRoute` to this Gateway. +2. **`cozystack-gateway-hostname-policy`** — `ValidatingAdmissionPolicy` on `gateway.networking.k8s.io/v1 Gateway` CREATE/UPDATE. Reads `namespaceObject.metadata.labels["namespace.cozystack.io/host"]` and rejects any listener hostname that is not equal to that value or a subdomain of it. `matchConditions` gate the VAP to cozystack-managed namespaces only — Gateways in unrelated namespaces (e.g. `kube-system`) are not touched. +3. **`cozystack-gateway-attached-namespaces-policy`** — VAP on `cozystack.io/v1alpha1 Package` CREATE/UPDATE. Rejects any `tenant-*` entry in `spec.components.platform.values.gateway.attachedNamespaces`. Catches direct `kubectl edit packages.cozystack.io` that would bypass the helm render-time guard in layer 6. +4. **`cozystack-tenant-host-policy`** — VAP on `apps.cozystack.io/v1alpha1 Tenant` CREATE/UPDATE. Rejects setting or changing `spec.host` unless the caller's groups contain `system:masters`, `system:serviceaccounts:cozy-system`, `system:serviceaccounts:cozy-cert-manager`, `system:serviceaccounts:flux-system` or `system:serviceaccounts:kube-system`. Closes the path where a tenant user sets `spec.host=dashboard.example.org` on their own tenant to have the tenant chart write a hijacked label into the namespace. +5. **`cozystack-namespace-host-label-policy`** — VAP on core `v1 Namespace` UPDATE. Rejects any change to the `namespace.cozystack.io/host` label once it is set, except by the same trusted-caller whitelist as layer 4. CREATE is unrestricted because the initial label write is part of the cozystack chart apply. +6. **Render-time `fail` in cozystack-basics.** The cozystack-basics chart fails the helm render if `_cluster.gateway-attached-namespaces` contains any `tenant-*` entry. Triggers on the helm-install path before the cluster ever sees the values — complements layer 3 which triggers at `kubectl apply` time. + +For `tenant-root` the allowed host suffix is `publishing.host`; for any `tenant-` that inherits from its parent the suffix is `.`. A child tenant with an independent apex (`customer1.io` instead of a subdomain) is handled correctly because the VAP reads the per-namespace label rather than assuming a subdomain hierarchy. ## Rate limits