From 15bbe9768d9c40b8d8ca5b7710bc93e530963c21 Mon Sep 17 00:00:00 2001 From: Manuel Lorenzo Date: Tue, 16 Jun 2026 13:03:26 +0200 Subject: [PATCH] mbp-1126: Network segmentation using UDN Signed-off-by: Manuel Lorenzo --- .../templates/postgresql-statefulset.yaml | 4 + charts/qtodo/templates/app-deployment.yaml | 3 + .../templates/udn-admin-network-policy.yaml | 118 +++++++++ .../udn-network-attachment-definition.yaml | 23 ++ .../templates/udn-user-defined-network.yaml | 32 +++ charts/qtodo/values.yaml | 53 ++++ docs/SYNC-WAVE-INVENTORY.md | 8 +- docs/user-defined-networks.md | 236 ++++++++++++++++++ scripts/features/features.yaml | 3 + scripts/features/udn.yaml | 9 + 10 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 charts/qtodo/templates/udn-admin-network-policy.yaml create mode 100644 charts/qtodo/templates/udn-network-attachment-definition.yaml create mode 100644 charts/qtodo/templates/udn-user-defined-network.yaml create mode 100644 docs/user-defined-networks.md create mode 100644 scripts/features/udn.yaml diff --git a/charts/qtodo-db/templates/postgresql-statefulset.yaml b/charts/qtodo-db/templates/postgresql-statefulset.yaml index c03f36b5..edd0b0ed 100644 --- a/charts/qtodo-db/templates/postgresql-statefulset.yaml +++ b/charts/qtodo-db/templates/postgresql-statefulset.yaml @@ -20,6 +20,10 @@ spec: serviceName: qtodo-db template: metadata: +{{- if .Values.app.udn.enabled }} + annotations: + k8s.v1.cni.cncf.io/networks: {{ .Release.Namespace }}/{{ .Values.app.udn.nadName }} +{{- end }} labels: app: qtodo-db spec: diff --git a/charts/qtodo/templates/app-deployment.yaml b/charts/qtodo/templates/app-deployment.yaml index 8a956f6a..13198e3d 100644 --- a/charts/qtodo/templates/app-deployment.yaml +++ b/charts/qtodo/templates/app-deployment.yaml @@ -21,6 +21,9 @@ spec: {{- if .Values.app.spire.enabled }} checksum/app-spiffe-helper-config: {{ include (print $.Template.BasePath "/spiffe-helper-config.yaml") . | sha256sum }} checksum/app-spiffe-vault-client-config: {{ include (print $.Template.BasePath "/spiffe-vault-client-config.yaml") . | sha256sum }} +{{- end }} +{{- if .Values.app.udn.enabled }} + k8s.v1.cni.cncf.io/networks: {{ .Release.Namespace }}/{{ .Values.app.udn.nadName }} {{- end }} labels: app: qtodo diff --git a/charts/qtodo/templates/udn-admin-network-policy.yaml b/charts/qtodo/templates/udn-admin-network-policy.yaml new file mode 100644 index 00000000..71229b27 --- /dev/null +++ b/charts/qtodo/templates/udn-admin-network-policy.yaml @@ -0,0 +1,118 @@ +{{- if and .Values.app.udn.enabled .Values.app.udn.networkPolicy.enabled }} +# AdminNetworkPolicy for qtodo UDN +# Controls traffic on the secondary (UDN) interface with explicit allow-lists +# Primary network policies (cluster network) are in qtodo-network-policy.yaml +apiVersion: policy.networking.k8s.io/v1alpha1 +kind: AdminNetworkPolicy +metadata: + annotations: + argocd.argoproj.io/sync-wave: '37' + name: qtodo-udn-policy +spec: + priority: 50 + subject: + namespaces: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + ingress: + {{- if .Values.app.udn.networkPolicy.ingress.router.enabled }} + # Allow ingress from OpenShift router on primary network (not UDN) + # Router traffic comes through the cluster network interface + - name: allow-router-ingress + action: Allow + from: + - namespaces: + matchLabels: + policy-group.network.openshift.io/ingress: "" + ports: + - portNumber: + protocol: TCP + port: {{ .Values.app.udn.networkPolicy.ingress.router.port }} + {{- end }} + {{- if .Values.app.udn.networkPolicy.egress.postgresql.enabled }} + # Allow ingress to PostgreSQL from qtodo pods within same namespace on UDN + - name: allow-postgresql-ingress + action: Allow + from: + - pods: + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + podSelector: + matchLabels: + app: qtodo + ports: + - portNumber: + protocol: TCP + port: {{ .Values.app.udn.networkPolicy.egress.postgresql.port }} + {{- end }} + # Deny all other ingress by default on UDN + - name: deny-all-ingress + action: Deny + from: + - namespaces: {} + egress: + {{- if .Values.app.udn.networkPolicy.egress.dns.enabled }} + # Allow DNS resolution via CoreDNS + - name: allow-dns + action: Allow + to: + - namespaces: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.app.udn.networkPolicy.egress.dns.namespace }} + ports: + - portNumber: + protocol: UDP + port: {{ .Values.app.udn.networkPolicy.egress.dns.port }} + - portNumber: + protocol: TCP + port: {{ .Values.app.udn.networkPolicy.egress.dns.port }} + {{- end }} + {{- if .Values.app.udn.networkPolicy.egress.postgresql.enabled }} + # Allow PostgreSQL access within same namespace on UDN + - name: allow-postgresql + action: Allow + to: + - pods: + namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + podSelector: + matchLabels: + app: qtodo-db + ports: + - portNumber: + protocol: TCP + port: {{ .Values.app.udn.networkPolicy.egress.postgresql.port }} + {{- end }} + {{- if .Values.app.udn.networkPolicy.egress.vault.enabled }} + # Allow Vault API access (SPIFFE JWT auth) via cluster network + - name: allow-vault + action: Allow + to: + - namespaces: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.app.udn.networkPolicy.egress.vault.namespace }} + ports: + - portNumber: + protocol: TCP + port: {{ .Values.app.udn.networkPolicy.egress.vault.port }} + {{- end }} + {{- if .Values.app.udn.networkPolicy.egress.https.enabled }} + # Allow OIDCs (HTTPS connections) via cluster network (external route) + - name: allow-https + action: Allow + to: + - networks: + - 0.0.0.0/0 # External route, cannot match by namespace + ports: + - portNumber: + protocol: TCP + port: {{ .Values.app.udn.networkPolicy.egress.https.port }} + {{- end }} + # Deny all other egress by default on UDN + - name: deny-all-egress + action: Deny + to: + - namespaces: {} +{{- end }} diff --git a/charts/qtodo/templates/udn-network-attachment-definition.yaml b/charts/qtodo/templates/udn-network-attachment-definition.yaml new file mode 100644 index 00000000..321a3621 --- /dev/null +++ b/charts/qtodo/templates/udn-network-attachment-definition.yaml @@ -0,0 +1,23 @@ +{{- if .Values.app.udn.enabled }} +apiVersion: k8s.cni.cncf.io/v1 +kind: NetworkAttachmentDefinition +metadata: + annotations: + argocd.argoproj.io/sync-wave: '36' + description: "Network attachment for qtodo UDN isolation" + name: {{ .Values.app.udn.nadName }} + namespace: {{ .Release.Namespace }} +spec: + config: | + { + "cniVersion": "0.4.0", + "type": "ovn-k8s-cni-overlay", + "name": "{{ .Values.app.udn.name }}", + "topology": "{{ lower .Values.app.udn.topology }}", + "netAttachDefName": "{{ .Release.Namespace }}/{{ .Values.app.udn.nadName }}", + {{- if eq .Values.app.udn.topology "Layer2" }} + "subnets": "{{ .Values.app.udn.subnet }}", + {{- end }} + "mtu": {{ .Values.app.udn.mtu }} + } +{{- end }} diff --git a/charts/qtodo/templates/udn-user-defined-network.yaml b/charts/qtodo/templates/udn-user-defined-network.yaml new file mode 100644 index 00000000..40ec33d8 --- /dev/null +++ b/charts/qtodo/templates/udn-user-defined-network.yaml @@ -0,0 +1,32 @@ +{{- if .Values.app.udn.enabled }} +apiVersion: k8s.ovn.org/v1 +kind: UserDefinedNetwork +metadata: + annotations: + argocd.argoproj.io/sync-wave: '35' + name: {{ .Values.app.udn.name }} + namespace: {{ .Release.Namespace }} +spec: + topology: {{ .Values.app.udn.topology }} + {{- if eq .Values.app.udn.topology "Layer2" }} + layer2: + role: Primary + subnets: + - {{ .Values.app.udn.subnet }} + {{- if .Values.app.udn.mtu }} + mtu: {{ .Values.app.udn.mtu }} + {{- end }} + {{- else if eq .Values.app.udn.topology "Layer3" }} + layer3: + role: Primary + subnets: + - {{ .Values.app.udn.subnet }} + {{- if .Values.app.udn.joinSubnet }} + joinSubnets: + - {{ .Values.app.udn.joinSubnet }} + {{- end }} + {{- if .Values.app.udn.mtu }} + mtu: {{ .Values.app.udn.mtu }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/qtodo/values.yaml b/charts/qtodo/values.yaml index 6d7ebded..f820cdbe 100644 --- a/charts/qtodo/values.yaml +++ b/charts/qtodo/values.yaml @@ -114,6 +114,59 @@ app: # QTodo truststore password path (app-level isolation) vaultPath: "secret/data/apps/qtodo/qtodo-truststore" + # User-Defined Network (UDN) configuration for network isolation + # Provides layer 2/3 network segmentation following Zero Trust principles + udn: + enabled: false + # Network name + name: qtodo-isolated-network + # NetworkAttachmentDefinition name + nadName: qtodo-udn-nad + # Topology: Layer2 or Layer3 + # Layer2: Same subnet, pods can communicate directly + # Layer3: Different subnets per node, requires routing + topology: Layer2 + # CIDR for the UDN subnet (Layer2 only) + subnet: "10.100.0.0/16" + # Join subnet (for Layer3, optional) + # joinSubnet: "100.64.0.0/16" + # MTU for the network (default: 1400 to avoid fragmentation) + mtu: 1400 + # IPAM configuration + ipam: + type: static + # Network Policy for UDN + # UDN requires policies on both primary (cluster network) and secondary (UDN) interfaces + networkPolicy: + # Enable network policies on the UDN + enabled: true + # Allowed egress destinations from qtodo pods on the UDN + egress: + # DNS resolution + dns: + enabled: true + port: 5353 + namespace: openshift-dns + # PostgreSQL database + postgresql: + enabled: true + port: 5432 + # Vault for secrets (via primary network, not UDN) + vault: + enabled: true + port: 8200 + namespace: vault + # Allow HTTPS connections to any destination, used for OIDCs (via primary network for external route) + https: + enabled: true + port: 443 + # Allowed ingress sources to qtodo pods on the UDN + ingress: + # OpenShift router (via primary network, not UDN) + router: + enabled: true + port: 8080 + # PostgreSQL database configuration postgresql: name: qtodo-db diff --git a/docs/SYNC-WAVE-INVENTORY.md b/docs/SYNC-WAVE-INVENTORY.md index f0cfd26e..93f8bb09 100644 --- a/docs/SYNC-WAVE-INVENTORY.md +++ b/docs/SYNC-WAVE-INVENTORY.md @@ -48,18 +48,20 @@ Every sync-wave in the repository, in order. **App** = hub-level Argo CD Applica | 34 | └ rhtpa-operator | chart | oidc-cli-secret | | 34 | └ noobaa-mcg | chart | bucket-class | | 35 | rh-keycloak | **App** | | +| 35 | └ qtodo | chart | udn-user-defined-network (UserDefinedNetwork CR for network isolation) | | 36 | noobaa-mcg | **App** | | | 36 | └ rhtpa-operator | chart | postgresql-serviceaccount, postgresql-external-secret, object-bucket-claim | | 36 | └ keycloak | chart | keycloak.yaml (Keycloak CR) | | 36 | └ quay-registry | chart | object-bucket-claim | | 36 | └ acs-central | chart | admin-password-secret, central-htpasswd-external-secret, keycloak-client-secret-external-secret | -| 36 | └ qtodo | chart | truststore-secret-external-secret, registry-external-secret | | 36 | └ qtodo-db | chart | postgresql-external-secret | +| 36 | └ qtodo | chart | udn-network-attachment-definition (NAD), truststore-secret-external-secret, registry-external-secret | | 38+0 | └ qtodo | chart | registry-seed SA, ClusterRole, ClusterRoleBinding | | 38+5 | └ qtodo | chart (hook) | registry-seed-image (Sync hook Job -- mirrors upstream image to configured registry) | | 37 | qtodo-db | **App** | PostgreSQL for qtodo (before qtodo app) | | 37 | └ quay-registry | chart | quay-s3-setup-serviceaccount (5 resources) | | 37 | └ acs-central | chart | create-htpasswd-field (Job) | +| 37 | └ qtodo | chart | udn-admin-network-policy (AdminNetworkPolicy for UDN traffic control) | | 38 | qtodo | **App** | | | 38 | └ quay-registry | chart | quay-config-bundle-secret | | 39 | └ rhtpa-operator | chart | s3-credentials-secret | @@ -284,9 +286,11 @@ Charts marked **(external)** have been externalized to standalone repositories m | --- | ---: | ---: | | registry-seed-job.yaml (SA, ClusterRole, ClusterRoleBinding) | --- | 0 | | registry-seed-job.yaml (Sync hook Job) | --- | 5 | +| udn-user-defined-network.yaml | --- | 35 | +| udn-network-attachment-definition.yaml | --- | 36 | | truststore-secret-external-secret.yaml | 5 | 36 | | registry-external-secret.yaml | --- | 36 | -| postgresql-external-secret.yaml (SPIFFE-off only) | 5 | 36 | +| udn-admin-network-policy.yaml (AdminNetworkPolicy) | --- | 37 | | qtodo-truststore-config.yaml | 10 | 41 | | app-deployment.yaml | 20 | 51 | | app-service.yaml | 20 | 51 | diff --git a/docs/user-defined-networks.md b/docs/user-defined-networks.md new file mode 100644 index 00000000..f6681791 --- /dev/null +++ b/docs/user-defined-networks.md @@ -0,0 +1,236 @@ +# User-Defined Networks (UDN) for Zero Trust Network Isolation + +## Overview + +User-Defined Networks (UDN) provide layer 2/3 network isolation for workloads in OpenShift, separate from the default cluster network. This feature implements Zero Trust network segmentation principles by creating an isolated network for the qtodo application, restricting communication to only necessary services. + +## Architecture + +### Network Topology + +The UDN implementation creates a dedicated isolated network for qtodo workloads: + +```text +┌─────────────────────────────────────────────────────┐ +│ Cluster Network │ +│ ┌────────────┐ ┌─────────┐ ┌──────────┐ │ +│ │ Router │───▶│ qtodo │───▶│ Vault │ │ +│ │ (Ingress) │ │ (eth0) │ │ (8200) │ │ +│ └────────────┘ └────┬────┘ └──────────┘ │ +│ │ │ +│ │ UDN Attachment │ +└─────────────────────────┼───────────────────────────┘ + │ + ┌─────▼──────┐ + │ UDN │ + │ (net1/ │ + │ Layer2) │ + └─────┬──────┘ + │ + ┌─────────┴────────┐ + │ │ + ┌─────▼──────┐ ┌────▼─────┐ + │ qtodo pod │ │ qtodo-db │ + │ (isolated) │──────│ (5432) │ + └────────────┘ └──────────┘ + │ + └─────────▶ DNS (5353) via cluster network +``` + +### Dual Network Interfaces + +When UDN is enabled, qtodo pods have two network interfaces: + +1. **eth0 (Primary - Cluster Network)** + - Ingress from OpenShift Router (port 8080) + - Egress to Vault (SPIFFE auth, port 8200) + - Egress to OIDCs (OIDC back-channel, port 443) + - DNS resolution (CoreDNS, port 5353) + +2. **net1 (Secondary - UDN)** + - PostgreSQL communication (qtodo ↔ qtodo-db, port 5432) + - Isolated from other cluster workloads + - Layer 2 topology (same subnet across nodes) + +## Security Benefits + +1. **Network Segmentation**: qtodo workloads are isolated from arbitrary cluster traffic +2. **Explicit Allow-Lists**: AdminNetworkPolicy enforces allow-only-required communication +3. **Defense in Depth**: Combines with existing NetworkPolicy for dual-layer protection +4. **Blast Radius Reduction**: Compromise of qtodo cannot pivot to unrelated services +5. **Compliance**: Supports Zero Trust architecture mandates (NIST 800-207, NIS2, ISO 27001:2022) + +## Components + +UDN is integrated into the qtodo Helm chart (`charts/qtodo`). When enabled, the following resources are created: + +### UserDefinedNetwork CR + +Template: `charts/qtodo/templates/udn-user-defined-network.yaml` + +Creates the isolated network with Layer2 topology: + +- Subnet: `10.100.0.0/16` +- MTU: 1400 (avoids fragmentation) +- IPAM: Persistent IP assignment +- Sync-wave: 35 (before NAD) + +### NetworkAttachmentDefinition + +Template: `charts/qtodo/templates/udn-network-attachment-definition.yaml` + +Defines how pods attach to the UDN: + +- CNI type: `ovn-k8s-cni-overlay` +- References the UserDefinedNetwork +- Used via pod annotation `k8s.v1.cni.cncf.io/networks` +- Sync-wave: 36 (before policies) + +### AdminNetworkPolicy + +Template: `charts/qtodo/templates/udn-admin-network-policy.yaml` + +Explicit allow-list for UDN traffic: + +- **Ingress**: + - OpenShift router (port 8080) + - qtodo pods to qtodo-db (port 5432) +- **Egress**: DNS, PostgreSQL, Vault, Keycloak (HTTPS connections) +- Priority: 50 (higher = processed first) +- Sync-wave: 37 (before qtodo app) + +## Enabling UDN + +### Option 1: Feature Variant Generator (Recommended) + +```bash +python3 scripts/gen-feature-variants.py \ + --features udn \ + --base values-hub.yaml + +# Apply the variant +cp /tmp/values-hub-udn.yaml values-hub.yaml +./pattern.sh make install +``` + +### Option 2: Manual Configuration + +1. **Enable UDN in the qtodo application** in `values-hub.yaml`: + + ```yaml + clusterGroup: + applications: + qtodo: + # ... existing config ... + overrides: + # ... existing overrides ... + - name: app.udn.enabled + value: "true" + ``` + +2. **Deploy**: + + ```bash + ./pattern.sh make install + ``` + +## Verification + +### 1. Check UDN Resources + +```bash +# UserDefinedNetwork +oc get userdefinednetwork -n qtodo +NAME AGE +qtodo-isolated-network 5m + +# NetworkAttachmentDefinition +oc get network-attachment-definitions -n qtodo +NAME AGE +qtodo-udn-nad 5m +``` + +### 2. Verify Network Policies + +```bash +# AdminNetworkPolicy +oc get adminnetworkpolicy +NAME PRIORITY AGE +qtodo-udn-policy 50 5m +``` + +### 3. Test Connectivity + +```bash +# DNS resolution (should work via eth0) +oc exec -n qtodo deploy/qtodo -c qtodo -- getent hosts qtodo-db + +# PostgreSQL connectivity (should work via net1) +oc exec -n qtodo deploy/qtodo -c qtodo -- timeout 5 bash -c '/dev/null' && echo "OK" + +# Vault API (should work via eth0) +oc exec -n qtodo deploy/qtodo -c qtodo -- curl -sk https://vault.vault.svc:8200/v1/sys/health +``` + +### 4. Verify qtodo Application + +```bash +# Get the route +QTODO_URL=$(oc get route -n qtodo qtodo -o jsonpath='{.spec.host}') + +# Access the application +curl https://$QTODO_URL +``` + +## Configuration Options + +UDN is configured via the `app.udn` section in `charts/qtodo/values.yaml`: + +| Parameter | Description | Default | +| -------------------------------- | -------------------------------- | ------------------------ | +| `app.udn.enabled` | Enable UDN | `false` | +| `app.udn.name` | UserDefinedNetwork name | `qtodo-isolated-network` | +| `app.udn.nadName` | NetworkAttachmentDefinition name | `qtodo-udn-nad` | +| `app.udn.topology` | Network topology (Layer2/Layer3) | `Layer2` | +| `app.udn.subnet` | CIDR for UDN | `10.100.0.0/16` | +| `app.udn.mtu` | MTU for the network | `1400` | +| `app.udn.networkPolicy.enabled` | Enable AdminNetworkPolicy | `true` | + +### Layer3 Topology + +For larger deployments, Layer3 provides better scalability. Override in `values-hub.yaml`: + +```yaml +clusterGroup: + applications: + qtodo: + overrides: + - name: app.udn.enabled + value: "true" + - name: app.udn.topology + value: "Layer3" + - name: app.udn.joinSubnet + value: "100.64.0.0/16" +``` + +## Security Considerations + +### Defense in Depth + +UDN complements, but does not replace, other security controls: + +- **NetworkPolicy**: Still applied on the cluster network (eth0) +- **Service Mesh**: mTLS can layer on top of UDN +- **ACS Policies**: Runtime enforcement still active + +### Attack Surface + +- UDN pods are still reachable via cluster network (eth0) for ingress/egress to external services +- AdminNetworkPolicy must be correctly configured to avoid bypasses +- Pods with `CAP_NET_ADMIN` could potentially manipulate interfaces +- For integration with IDPs (_Keycloak_, _EntraID_), HTTPS connections to any destination are enabled. In a more secure environment, this rule should be more restrictive and only allow access to specific destinations. + +## References + +- [OpenShift UDN Documentation](https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/multiple_networks/understanding-multiple-networks) +- [OVN-Kubernetes User-Defined Networks](https://github.com/ovn-kubernetes/ovn-kubernetes/blob/master/docs/features/user-defined-networks/user-defined-networks.md) diff --git a/scripts/features/features.yaml b/scripts/features/features.yaml index 5a324cde..cb4f80be 100644 --- a/scripts/features/features.yaml +++ b/scripts/features/features.yaml @@ -48,6 +48,9 @@ features: entra-id-qtodo: description: "Azure Entra ID for qtodo authentication (no supply-chain)" + + udn: + description: "User-Defined Network isolation for qtodo" depends_on: [] # Registry options (only used with supply-chain feature) diff --git a/scripts/features/udn.yaml b/scripts/features/udn.yaml new file mode 100644 index 00000000..71c8d383 --- /dev/null +++ b/scripts/features/udn.yaml @@ -0,0 +1,9 @@ +# User-Defined Network isolation for qtodo application +# Provides layer 2/3 network segmentation following Zero Trust principles +clusterGroup: + # Merge UDN configuration into existing qtodo application + merge_into_applications: + qtodo: + overrides: + - name: app.udn.enabled + value: "true"