diff --git a/charts/zero-trust-mesh/Chart.yaml b/charts/zero-trust-mesh/Chart.yaml index 9385438..1b03dc5 100644 --- a/charts/zero-trust-mesh/Chart.yaml +++ b/charts/zero-trust-mesh/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: zero-trust-mesh -version: 0.1.3 +version: 0.1.4 description: Helm chart for Kubernetes NetworkPolicy + Istio zero-trust service communication appVersion: "1.0" type: application diff --git a/charts/zero-trust-mesh/README.md b/charts/zero-trust-mesh/README.md index e3ed108..bf88ff4 100644 --- a/charts/zero-trust-mesh/README.md +++ b/charts/zero-trust-mesh/README.md @@ -25,7 +25,7 @@ Enable namespace-wide resources: ```yaml namespace: default namespaceResourcesEnabled: true -allowTo: [] +allowPolicies: [] ``` This creates namespace-scoped defaults: @@ -37,20 +37,36 @@ This creates namespace-scoped defaults: ### 2) Service rules -Enable only per-service allow entries (minimal values): +Enable per-service deny-all first, then add explicit allow entries as traffic is validated: ```yaml -workload: frontend +service: frontend namespaceResourcesEnabled: false -allowTo: - - service: backend - targetPodLabels: +denyAll: + enabled: true +allowPolicies: + - type: ingress + service: gateway + podLabels: + app: gateway + port: 80 + - type: ingress + service: ingress-nginx + namespace: ingress-nginx + podLabels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/component: controller + port: 80 + allowUnauthenticated: true + - type: egress + service: backend + podLabels: app: backend port: 8080 - methods: ["GET", "POST"] - paths: ["/api/*"] - - hosts: ["api.stripe.com"] - - ips: ["192.0.2.10"] + - type: egress + hosts: ["api.stripe.com"] + - type: egress + ips: ["192.0.2.10"] ports: - number: 443 protocol: TCP @@ -58,27 +74,57 @@ allowTo: ## Values design -`allowTo` is a single list with three entry types: +`denyAll` is a service-scoped deny-all switch: + +- `enabled` (optional, default `false`) +- `podLabels` (optional selector override; defaults to `app.kubernetes.io/name: `) + +When enabled, it renders: + +- a service-scoped NetworkPolicy with both `Ingress` and `Egress` policy types and no allow rules +- a service-scoped Istio AuthorizationPolicy default deny for inbound mesh traffic +- service-scoped DNS and `istiod` egress NetworkPolicy exceptions so injected + sidecars can keep receiving mesh configuration while application traffic stays denied + +`allowPolicies` is the preferred typed allow list owned by the current service. + +For `type: ingress` entries: + +- `service` (required source service name; `workload` is accepted as a legacy alias) +- `namespace` (optional source namespace, defaults to Helm release namespace) +- `podLabels` (optional source pod selector override; defaults to `app.kubernetes.io/name: `) +- `sourceIpBlocks` (optional source CIDR allow list for non-pod sources such as AWS ALB target-type `ip`) +- `serviceAccount` (optional source service account override; defaults to `service`) +- `port` / `protocol` (optional, defaults to `80` / `TCP`) +- `methods` / `paths` (optional Istio operation filters) +- `allowUnauthenticated` (optional, default `false`): omit the Istio source principal match for non-mesh sources such as `ingress-nginx`; keep `podLabels` set so NetworkPolicy still restricts packet sources + +For `type: egress` entries, service destinations use `service` as the peer +service name. For service destinations it renders service-scoped egress +`NetworkPolicy` rules only; the destination service must open inbound traffic +with its own ingress allow policy if it has `denyAll.enabled: true`. + +`type: egress` supports three destination forms: - Service rule: - - `service` (required) + - `service` (required; `workload` is accepted as a legacy alias) - `namespace` (optional, defaults to Helm release namespace) - - `targetPodLabels` (optional target pod selector override for NetworkPolicy and AuthorizationPolicy; defaults to `app.kubernetes.io/name: `) + - `podLabels` (optional target pod selector override for generated egress `NetworkPolicy`; defaults to `app.kubernetes.io/name: `) - `port` (optional, default `8080`) - `protocol` (optional, default `TCP`) - - `serviceAccount` (optional target service account override) - - `methods` / `paths` (optional Istio operation filters) + - `serviceAccount` / `methods` / `paths` are only used by the legacy target-side ingress mode - Host rule: - `hosts` (list of approved external hosts) - `ports` (optional list; merged with defaults `80/HTTP` and `443/HTTPS`) - `paths` can be provided in values for future/egress-gateway routing use, but are not enforced by `ServiceEntry`-only mode + - renders Istio `ServiceEntry` resources and, when service deny-all is enabled, a service-scoped public-IP egress `NetworkPolicy` for the selected ports - IP rule: - `ips` (list of approved external destination IPs or CIDR blocks) - `ports` (optional list; defaults to `443/TCP`) - single IPv4 addresses are rendered as `/32` CIDRs for `NetworkPolicy` `ipBlock` - renders both an Istio `ServiceEntry` with `resolution: NONE` and a workload-scoped egress `NetworkPolicy` -Source service account defaults to `workload`, or can be set with top-level `serviceAccount`. +Source service account defaults to `service`, or can be set with top-level `serviceAccount`. If your cluster does not have an `istio-egressgateway` Service name, set: - `istio.egressGateway.serviceName` to your real gateway Service @@ -91,16 +137,22 @@ Most security defaults are now implicit in templates. Advanced overrides can sti | Key | Description | Default / Example | |-----|-------------|-------------------| -| `workload` | Source workload name used for source pod selectors and default source service account | Helm release name | +| `service` | Current service name used for pod selectors and default source service account | Helm release name | +| `workload` | Deprecated alias for `service` | Helm release name | | `serviceAccount` | Source service account override | `""` | | `namespaceResourcesEnabled` | Enables namespace-wide default deny, DNS, egress gateway, mTLS, and default-deny AuthorizationPolicy resources | `false` | -| `allowTo` | Service, host, and IP allow rules | `[]` | -| `allowTo[].service` | Destination service rule name | `backend` | -| `allowTo[].targetPodLabels` | Optional target pod selector override for generated NetworkPolicy and AuthorizationPolicy resources | `{ app: backend }` | -| `allowTo[].serviceAccount` | Optional target service account override for AuthorizationPolicy naming | `allowTo[].service` | -| `allowTo[].methods` / `allowTo[].paths` | Optional Istio operation filters | `["GET"]`, `["/api/*"]` | -| `allowTo[].hosts` | Approved external hosts for ServiceEntry-based egress | `["api.stripe.com"]` | -| `allowTo[].ips` | Approved external destination IPs or CIDR blocks for direct IP egress | `["192.0.2.10"]` | +| `denyAll.enabled` | Enables service-scoped deny-all for both inbound and outbound traffic | `false` | +| `denyAll.podLabels` | Optional pod selector override for service-level deny-all resources | Not set; defaults to `app.kubernetes.io/name: ` | +| `allowPolicies` | Preferred typed inbound/outbound allow rules owned by the current service | `[]` | +| `allowPolicies[].type` | Policy direction, either `ingress` or `egress` | `ingress` | +| `allowPolicies[].service` | Peer service name for ingress source or egress destination | `backend` | +| `allowPolicies[].workload` | Deprecated alias for `allowPolicies[].service` | `backend` | +| `allowPolicies[].podLabels` | Optional peer pod selector override for generated NetworkPolicy | `{ app: backend }` | +| `allowPolicies[].sourceIpBlocks` | Optional ingress CIDR allow list for non-pod sources such as AWS ALB target-type `ip` | `["172.31.0.0/16"]` | +| `allowPolicies[].serviceAccount` | Optional peer service account override for AuthorizationPolicy principals | `allowPolicies[].service` | +| `allowPolicies[].allowUnauthenticated` | Allow non-mesh inbound sources; NetworkPolicy should still restrict source pods or CIDRs | `false` | +| `allowPolicies[].hosts` | Approved external hosts for ServiceEntry-based egress | `["api.stripe.com"]` | +| `allowPolicies[].ips` | Approved external destination IPs or CIDR blocks for direct IP egress | `["192.0.2.10"]` | ## Install diff --git a/charts/zero-trust-mesh/templates/_helpers.tpl b/charts/zero-trust-mesh/templates/_helpers.tpl index 252f791..272dc98 100644 --- a/charts/zero-trust-mesh/templates/_helpers.tpl +++ b/charts/zero-trust-mesh/templates/_helpers.tpl @@ -37,7 +37,7 @@ TCP {{- define "ztm.workloadName" -}} {{- $svc := .Values.serviceConfig | default (dict) -}} -{{- .Values.workload | default ($svc.workload | default .Release.Name) -}} +{{- default (.Values.workload | default ($svc.workload | default ($svc.service | default .Release.Name))) .Values.service -}} {{- end -}} {{- define "ztm.workloadServiceAccount" -}} @@ -46,10 +46,27 @@ TCP {{- end -}} {{- define "ztm.targetPodLabels" -}} -{{- if .targetPodLabels -}} -{{- toYaml .targetPodLabels -}} +{{- if .podLabels -}} +{{- toYaml .podLabels -}} {{- else -}} -app.kubernetes.io/name: {{ .service }} +app.kubernetes.io/name: {{ default .workload .service }} +{{- end -}} +{{- end -}} + +{{- define "ztm.sourcePodLabels" -}} +{{- if .podLabels -}} +{{- toYaml .podLabels -}} +{{- else -}} +app.kubernetes.io/name: {{ default .workload .service }} +{{- end -}} +{{- end -}} + +{{- define "ztm.denyAllPodLabels" -}} +{{- $denyAll := .Values.denyAll | default (dict) -}} +{{- if $denyAll.podLabels -}} +{{- toYaml $denyAll.podLabels -}} +{{- else -}} +app.kubernetes.io/name: {{ include "ztm.workloadName" . }} {{- end -}} {{- end -}} diff --git a/charts/zero-trust-mesh/templates/istio-allow-from.yaml b/charts/zero-trust-mesh/templates/istio-allow-from.yaml new file mode 100644 index 0000000..bcd8cd0 --- /dev/null +++ b/charts/zero-trust-mesh/templates/istio-allow-from.yaml @@ -0,0 +1,53 @@ +{{- $denyAll := .Values.denyAll | default (dict) -}} +{{- $istio := .Values.istio | default (dict) -}} +{{- $ingress := list -}} +{{- range (.Values.allowPolicies | default (list)) -}} +{{- if eq (default "" .type) "ingress" -}} +{{- $ingress = append $ingress . -}} +{{- end -}} +{{- end -}} +{{- if and (default false $denyAll.enabled) (default true $istio.enabled) $ingress }} +{{- $targetNamespace := include "ztm.workloadNamespace" . -}} +{{- $targetServiceAccount := include "ztm.workloadServiceAccount" . -}} +{{- range $ingress }} +{{- $sourceWorkload := required "ingress[].service is required" (default .workload .service) -}} +{{- $sourceNamespace := default $targetNamespace .namespace -}} +{{- $sourceServiceAccount := default $sourceWorkload .serviceAccount -}} +{{- $policyName := printf "allow-%s-ingress-from-%s" $targetServiceAccount $sourceServiceAccount -}} +--- +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: {{ include "ztm.sanitizeName" $policyName }} + namespace: {{ $targetNamespace }} +spec: + selector: + matchLabels: + {{- include "ztm.denyAllPodLabels" $ | nindent 6 }} + action: ALLOW + rules: + {{- if and .allowUnauthenticated (not (or .methods .paths)) }} + - {} + {{- else }} + - + {{- if not .allowUnauthenticated }} + from: + - source: + principals: + - {{ printf "cluster.local/ns/%s/sa/%s" $sourceNamespace $sourceServiceAccount | quote }} + {{- end }} + {{- if or .methods .paths }} + to: + - operation: + {{- if .methods }} + methods: + {{- toYaml .methods | nindent 14 }} + {{- end }} + {{- if .paths }} + paths: + {{- toYaml .paths | nindent 14 }} + {{- end }} + {{- end }} + {{- end }} +{{ end }} +{{- end }} diff --git a/charts/zero-trust-mesh/templates/istio-authorizations.yaml b/charts/zero-trust-mesh/templates/istio-authorizations.yaml deleted file mode 100644 index aa1c666..0000000 --- a/charts/zero-trust-mesh/templates/istio-authorizations.yaml +++ /dev/null @@ -1,38 +0,0 @@ -{{- $istio := .Values.istio | default (dict) -}} -{{- if and (default true $istio.enabled) .Values.allowTo }} -{{- $sourceNamespace := include "ztm.workloadNamespace" . -}} -{{- $sourceServiceAccount := include "ztm.workloadServiceAccount" . -}} -{{- range .Values.allowTo }} -{{- if .service }} -{{- $targetNamespace := default $sourceNamespace .namespace -}} -{{- $targetServiceAccount := default .service .serviceAccount -}} -{{- $policyName := printf "allow-%s-to-%s" $sourceServiceAccount $targetServiceAccount -}} ---- -apiVersion: security.istio.io/v1 -kind: AuthorizationPolicy -metadata: - name: {{ include "ztm.sanitizeName" $policyName }} - namespace: {{ $targetNamespace }} -spec: - selector: - matchLabels: - {{- include "ztm.targetPodLabels" . | nindent 6 }} - action: ALLOW - rules: - - from: - - source: - principals: - - {{ printf "cluster.local/ns/%s/sa/%s" $sourceNamespace $sourceServiceAccount | quote }} - to: - - operation: - {{- if .methods }} - methods: - {{- toYaml .methods | nindent 14 }} - {{- end }} - {{- if .paths }} - paths: - {{- toYaml .paths | nindent 14 }} - {{- end }} -{{ end }} -{{ end }} -{{- end }} diff --git a/charts/zero-trust-mesh/templates/istio-egress-gateway.yaml b/charts/zero-trust-mesh/templates/istio-egress-gateway.yaml index 9b143a2..1b717a9 100644 --- a/charts/zero-trust-mesh/templates/istio-egress-gateway.yaml +++ b/charts/zero-trust-mesh/templates/istio-egress-gateway.yaml @@ -2,9 +2,15 @@ {{- $egw := $istio.egressGateway | default (dict) -}} {{- $egwNamespace := default "istio-system" $egw.namespace -}} {{- $egwServiceName := default "istio-egressgateway" $egw.serviceName -}} -{{- if and (ne $istio.enabled false) (eq $egw.enabled true) .Values.allowTo }} +{{- $egress := list -}} +{{- range (.Values.allowPolicies | default (list)) -}} +{{- if eq (default "" .type) "egress" -}} +{{- $egress = append $egress . -}} +{{- end -}} +{{- end -}} +{{- if and (ne $istio.enabled false) (eq $egw.enabled true) $egress }} {{- $workloadNamespace := include "ztm.workloadNamespace" . -}} -{{- range .Values.allowTo }} +{{- range $egress }} {{- if .hosts }} {{- range .hosts }} --- diff --git a/charts/zero-trust-mesh/templates/istio-service-deny-all.yaml b/charts/zero-trust-mesh/templates/istio-service-deny-all.yaml new file mode 100644 index 0000000..3958a06 --- /dev/null +++ b/charts/zero-trust-mesh/templates/istio-service-deny-all.yaml @@ -0,0 +1,15 @@ +{{- $denyAll := .Values.denyAll | default (dict) -}} +{{- $istio := .Values.istio | default (dict) -}} +{{- if and (default false $denyAll.enabled) (default true $istio.enabled) }} +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: {{ include "ztm.fullname" . }}-service-deny-all + namespace: {{ include "ztm.workloadNamespace" . }} + labels: + {{- include "ztm.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "ztm.denyAllPodLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/zero-trust-mesh/templates/istio-serviceentries.yaml b/charts/zero-trust-mesh/templates/istio-serviceentries.yaml index 68a9f50..129592c 100644 --- a/charts/zero-trust-mesh/templates/istio-serviceentries.yaml +++ b/charts/zero-trust-mesh/templates/istio-serviceentries.yaml @@ -1,7 +1,13 @@ {{- $istio := .Values.istio | default (dict) -}} -{{- if and (default true $istio.enabled) .Values.allowTo }} +{{- $egress := list -}} +{{- range (.Values.allowPolicies | default (list)) -}} +{{- if eq (default "" .type) "egress" -}} +{{- $egress = append $egress . -}} +{{- end -}} +{{- end -}} +{{- if and (default true $istio.enabled) $egress }} {{- $workloadNamespace := include "ztm.workloadNamespace" . -}} -{{- range .Values.allowTo }} +{{- range $egress }} {{- if .hosts }} {{- $defaultPorts := list (dict "number" 80 "protocol" "HTTP") (dict "number" 443 "protocol" "HTTPS") -}} {{- $userPorts := .ports | default (list) -}} @@ -75,7 +81,7 @@ spec: resolution: NONE ports: {{- range $port := $ports }} - {{- $number := required "allowTo[].ips ports[].number is required" $port.number }} + {{- $number := required "egress[].ips ports[].number is required" $port.number }} {{- $protocol := default "TCP" $port.protocol | upper }} - number: {{ $number }} name: {{ default (include "ztm.sanitizeName" (printf "%s-%v" ($protocol | lower) $number)) $port.name }} diff --git a/charts/zero-trust-mesh/templates/networkpolicy-allow-from.yaml b/charts/zero-trust-mesh/templates/networkpolicy-allow-from.yaml new file mode 100644 index 0000000..41275a9 --- /dev/null +++ b/charts/zero-trust-mesh/templates/networkpolicy-allow-from.yaml @@ -0,0 +1,48 @@ +{{- $denyAll := .Values.denyAll | default (dict) -}} +{{- $np := .Values.networkPolicy | default (dict) -}} +{{- $labels := .Values.labels | default (dict) -}} +{{- $ingress := list -}} +{{- range (.Values.allowPolicies | default (list)) -}} +{{- if eq (default "" .type) "ingress" -}} +{{- $ingress = append $ingress . -}} +{{- end -}} +{{- end -}} +{{- if and (default false $denyAll.enabled) (default true $np.enabled) $ingress }} +{{- $targetNamespace := include "ztm.workloadNamespace" . -}} +{{- $targetWorkload := include "ztm.workloadName" . -}} +{{- range $ingress }} +{{- $sourceWorkload := required "ingress[].service is required" (default .workload .service) -}} +{{- $sourceNamespace := default $targetNamespace .namespace -}} +{{- $ruleName := printf "%s-ingress-from-%s-%v" $targetWorkload $sourceWorkload (.port | default 80) -}} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-{{ include "ztm.sanitizeName" $ruleName }} + namespace: {{ $targetNamespace }} +spec: + podSelector: + matchLabels: + {{- include "ztm.denyAllPodLabels" $ | nindent 6 }} + policyTypes: + - Ingress + ingress: + - from: + {{- if .sourceIpBlocks }} + {{- range .sourceIpBlocks }} + - ipBlock: + cidr: {{ . | quote }} + {{- end }} + {{- else }} + - namespaceSelector: + matchLabels: + {{ default "kubernetes.io/metadata.name" $labels.namespaceLabelKey }}: {{ $sourceNamespace }} + podSelector: + matchLabels: + {{- include "ztm.sourcePodLabels" . | nindent 14 }} + {{- end }} + ports: + - port: {{ .port | default 80 }} + protocol: {{ .protocol | default "TCP" }} +{{ end }} +{{- end }} diff --git a/charts/zero-trust-mesh/templates/networkpolicy-host-egress.yaml b/charts/zero-trust-mesh/templates/networkpolicy-host-egress.yaml new file mode 100644 index 0000000..992dcc9 --- /dev/null +++ b/charts/zero-trust-mesh/templates/networkpolicy-host-egress.yaml @@ -0,0 +1,55 @@ +{{- $np := .Values.networkPolicy | default (dict) -}} +{{- $denyAll := .Values.denyAll | default (dict) -}} +{{- $egress := list -}} +{{- range (.Values.allowPolicies | default (list)) -}} +{{- if eq (default "" .type) "egress" -}} +{{- $egress = append $egress . -}} +{{- end -}} +{{- end -}} +{{- if and (default false $denyAll.enabled) (default true $np.enabled) $egress }} +{{- $workloadNamespace := include "ztm.workloadNamespace" . -}} +{{- $sourceWorkload := include "ztm.workloadName" . -}} +{{- $portsByKey := dict -}} +{{- range $egress }} +{{- if .hosts }} +{{- $defaultPorts := list (dict "number" 80 "protocol" "TCP") (dict "number" 443 "protocol" "TCP") -}} +{{- $ports := .ports | default $defaultPorts -}} +{{- range $port := $ports }} +{{- $number := required "egress[].hosts ports[].number is required" $port.number -}} +{{- $protocol := include "ztm.networkPolicyProtocol" $port.protocol -}} +{{- $_ := set $portsByKey (printf "%v-%s" $number $protocol) (dict "number" $number "protocol" $protocol) -}} +{{- end }} +{{- end }} +{{- end }} +{{- if $portsByKey }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-{{ include "ztm.sanitizeName" (printf "%s-egress-to-external-hosts" $sourceWorkload) }} + namespace: {{ $workloadNamespace }} +spec: + podSelector: + matchLabels: + {{- include "ztm.denyAllPodLabels" . | nindent 6 }} + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.168.0.0/16 + ports: + {{- range $key := keys $portsByKey | sortAlpha }} + {{- $port := get $portsByKey $key }} + - port: {{ $port.number }} + protocol: {{ $port.protocol }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/zero-trust-mesh/templates/networkpolicy-ip-egress.yaml b/charts/zero-trust-mesh/templates/networkpolicy-ip-egress.yaml index b303516..bede1e5 100644 --- a/charts/zero-trust-mesh/templates/networkpolicy-ip-egress.yaml +++ b/charts/zero-trust-mesh/templates/networkpolicy-ip-egress.yaml @@ -1,8 +1,14 @@ {{- $np := .Values.networkPolicy | default (dict) -}} -{{- if and (ne $np.enabled false) .Values.allowTo }} +{{- $egress := list -}} +{{- range (.Values.allowPolicies | default (list)) -}} +{{- if eq (default "" .type) "egress" -}} +{{- $egress = append $egress . -}} +{{- end -}} +{{- end -}} +{{- if and (ne $np.enabled false) $egress }} {{- $workloadNamespace := include "ztm.workloadNamespace" . -}} {{- $sourceWorkload := include "ztm.workloadName" . -}} -{{- range .Values.allowTo }} +{{- range $egress }} {{- if .ips }} {{- $defaultPorts := list (dict "number" 443 "protocol" "TCP") -}} {{- $ports := .ports | default $defaultPorts -}} @@ -20,7 +26,7 @@ metadata: spec: podSelector: matchLabels: - app.kubernetes.io/name: {{ $sourceWorkload }} + {{- include "ztm.denyAllPodLabels" $ | nindent 6 }} policyTypes: - Egress egress: @@ -31,7 +37,7 @@ spec: {{- end }} ports: {{- range $port := $ports }} - {{- $number := required "allowTo[].ips ports[].number is required" $port.number }} + {{- $number := required "egress[].ips ports[].number is required" $port.number }} - port: {{ $number }} protocol: {{ include "ztm.networkPolicyProtocol" $port.protocol }} {{- end }} diff --git a/charts/zero-trust-mesh/templates/networkpolicy-service-deny-all.yaml b/charts/zero-trust-mesh/templates/networkpolicy-service-deny-all.yaml new file mode 100644 index 0000000..b71aee7 --- /dev/null +++ b/charts/zero-trust-mesh/templates/networkpolicy-service-deny-all.yaml @@ -0,0 +1,18 @@ +{{- $denyAll := .Values.denyAll | default (dict) -}} +{{- $np := .Values.networkPolicy | default (dict) -}} +{{- if and (default false $denyAll.enabled) (default true $np.enabled) }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "ztm.fullname" . }}-service-deny-all + namespace: {{ include "ztm.workloadNamespace" . }} + labels: + {{- include "ztm.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "ztm.denyAllPodLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress +{{- end }} diff --git a/charts/zero-trust-mesh/templates/networkpolicy-service-dns.yaml b/charts/zero-trust-mesh/templates/networkpolicy-service-dns.yaml new file mode 100644 index 0000000..3452be0 --- /dev/null +++ b/charts/zero-trust-mesh/templates/networkpolicy-service-dns.yaml @@ -0,0 +1,31 @@ +{{- $denyAll := .Values.denyAll | default (dict) -}} +{{- $np := .Values.networkPolicy | default (dict) -}} +{{- $nsr := .Values.namespaceResources | default (dict) -}} +{{- if and (default false $denyAll.enabled) (default true $np.enabled) (not (default false (default .Values.namespaceResourcesEnabled $nsr.enabled))) }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "ztm.fullname" . }}-allow-dns-egress + namespace: {{ include "ztm.workloadNamespace" . }} + labels: + {{- include "ztm.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "ztm.denyAllPodLabels" . | nindent 6 }} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP +{{- end }} diff --git a/charts/zero-trust-mesh/templates/networkpolicy-flows.yaml b/charts/zero-trust-mesh/templates/networkpolicy-service-egress.yaml similarity index 50% rename from charts/zero-trust-mesh/templates/networkpolicy-flows.yaml rename to charts/zero-trust-mesh/templates/networkpolicy-service-egress.yaml index f162640..ffffffb 100644 --- a/charts/zero-trust-mesh/templates/networkpolicy-flows.yaml +++ b/charts/zero-trust-mesh/templates/networkpolicy-service-egress.yaml @@ -1,32 +1,40 @@ {{- $np := .Values.networkPolicy | default (dict) -}} +{{- $denyAll := .Values.denyAll | default (dict) -}} {{- $labels := .Values.labels | default (dict) -}} -{{- if and (default true $np.enabled) .Values.allowTo }} +{{- $egress := list -}} +{{- range (.Values.allowPolicies | default (list)) -}} +{{- if eq (default "" .type) "egress" -}} +{{- $egress = append $egress . -}} +{{- end -}} +{{- end -}} +{{- if and (default false $denyAll.enabled) (default true $np.enabled) $egress }} {{- $sourceNamespace := include "ztm.workloadNamespace" . -}} {{- $sourceWorkload := include "ztm.workloadName" . -}} -{{- range .Values.allowTo }} -{{- if .service }} +{{- range $egress }} +{{- $targetService := default .workload .service -}} +{{- if $targetService }} {{- $targetNamespace := default $sourceNamespace .namespace -}} -{{- $ruleName := printf "%s-to-%s-%v" $sourceWorkload .service (.port | default 8080) -}} +{{- $ruleName := printf "%s-egress-to-%s-%v" $sourceWorkload $targetService (.port | default 8080) -}} --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-{{ include "ztm.sanitizeName" $ruleName }} - namespace: {{ $targetNamespace }} + namespace: {{ $sourceNamespace }} spec: podSelector: matchLabels: - {{- include "ztm.targetPodLabels" . | nindent 6 }} + {{- include "ztm.denyAllPodLabels" $ | nindent 6 }} policyTypes: - - Ingress - ingress: - - from: + - Egress + egress: + - to: - namespaceSelector: matchLabels: - {{ default "kubernetes.io/metadata.name" $labels.namespaceLabelKey }}: {{ $sourceNamespace }} + {{ default "kubernetes.io/metadata.name" $labels.namespaceLabelKey }}: {{ $targetNamespace }} podSelector: matchLabels: - app.kubernetes.io/name: {{ $sourceWorkload }} + {{- include "ztm.targetPodLabels" . | nindent 14 }} ports: - port: {{ .port | default 8080 }} protocol: {{ .protocol | default "TCP" }} diff --git a/charts/zero-trust-mesh/templates/networkpolicy-service-istiod.yaml b/charts/zero-trust-mesh/templates/networkpolicy-service-istiod.yaml new file mode 100644 index 0000000..70e9a00 --- /dev/null +++ b/charts/zero-trust-mesh/templates/networkpolicy-service-istiod.yaml @@ -0,0 +1,39 @@ +{{- $denyAll := .Values.denyAll | default (dict) -}} +{{- $np := .Values.networkPolicy | default (dict) -}} +{{- $istio := .Values.istio | default (dict) -}} +{{- $nsr := .Values.namespaceResources | default (dict) -}} +{{- if and (default false $denyAll.enabled) (default true $np.enabled) (default true $istio.enabled) (not (default false (default .Values.namespaceResourcesEnabled $nsr.enabled))) }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "ztm.fullname" . }}-allow-istiod-egress + namespace: {{ include "ztm.workloadNamespace" . }} + labels: + {{- include "ztm.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "ztm.denyAllPodLabels" . | nindent 6 }} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: istio-system + podSelector: + matchLabels: + app: istiod + istio: pilot + ports: + - port: 15010 + protocol: TCP + - port: 15012 + protocol: TCP + - port: 15014 + protocol: TCP + - port: 15017 + protocol: TCP + - port: 443 + protocol: TCP +{{- end }} diff --git a/charts/zero-trust-mesh/values.yaml b/charts/zero-trust-mesh/values.yaml index 3f810e2..b2a5784 100644 --- a/charts/zero-trust-mesh/values.yaml +++ b/charts/zero-trust-mesh/values.yaml @@ -1,31 +1,59 @@ # namespace: -# workload: -# Optional; defaults to workload when empty. +# service: +# workload: +# Optional; defaults to service when empty. serviceAccount: "" # Keep false for per-service releases. Enable only in one baseline release per namespace. namespaceResourcesEnabled: false -# Single allowTo list. Defaults to no service-level allow rules. -# Supported entry types: -# - service rule: workload -> service -# - hosts rule: approved external hosts (default ports: 80/HTTP and 443/HTTPS) -# - ips rule: approved external IP/CIDR egress (default port: 443/TCP) -allowTo: [] +# Optional service-level deny-all. This is safer for incremental rollout than +# namespaceResourcesEnabled because it selects only the current service pods. +# denyAll: +# enabled: false +# # Optional pod selector override; defaults to: +# # app.kubernetes.io/name: +# # podLabels: +# # app: frontend +# # component: api +denyAll: {} -# Example allowTo entries: -# allowTo: -# - service: backend +# Single typed allow list. Defaults to no service-level allow rules. +# `type: ingress` entries open inbound traffic to the current service. +# `type: egress` entries open outbound traffic from the current service. +allowPolicies: [] + +# Example typed allow entries: +# allowPolicies: +# - type: ingress +# service: gateway +# # Optional source service account override; defaults to service. +# # serviceAccount: gateway +# # Optional peer pod selector override; defaults to: +# # app.kubernetes.io/name: +# # podLabels: +# # app: gateway +# port: 80 +# methods: ["GET"] +# paths: ["/*"] +# - type: ingress +# service: internal-alb +# # Use VPC CIDR or narrower ALB subnet CIDRs for AWS ALB target-type ip. +# sourceIpBlocks: +# - 172.31.0.0/16 +# port: 80 +# allowUnauthenticated: true +# - type: egress +# service: backend # # Optional target pod selector override; defaults to: # # app.kubernetes.io/name: -# # targetPodLabels: +# # podLabels: # # app: backend # port: 8080 -# methods: ["GET", "POST"] -# paths: ["/api/*"] # -# - hosts: ["api.stripe.com"] +# - type: egress +# hosts: ["api.stripe.com"] # # Optional custom ports/protocols for this host group. # # These are merged with defaults (80/HTTP and 443/HTTPS). # # ports: @@ -34,7 +62,8 @@ allowTo: [] # # - number: 443 # # protocol: HTTPS # -# - ips: ["192.0.2.10"] +# - type: egress +# ips: ["192.0.2.10"] # # Single IPs are normalized to /32 for NetworkPolicy ipBlock. # # CIDRs like 198.51.100.0/24 can also be used. # # Optional custom ports/protocols for this IP group. diff --git a/examples/zero-trust-mesh/service-deny-all.yaml b/examples/zero-trust-mesh/service-deny-all.yaml new file mode 100644 index 0000000..412ea15 --- /dev/null +++ b/examples/zero-trust-mesh/service-deny-all.yaml @@ -0,0 +1,9 @@ +# helm template ztm-service-deny-all ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/service-deny-all.yaml +workload: frontend +namespaceResourcesEnabled: false +serviceDenyAll: + enabled: true + podLabels: + app: frontend + component: api + diff --git a/examples/zero-trust-mesh/values.full.yaml b/examples/zero-trust-mesh/values.full.yaml index 9c013b5..53cdb9e 100644 --- a/examples/zero-trust-mesh/values.full.yaml +++ b/examples/zero-trust-mesh/values.full.yaml @@ -6,32 +6,62 @@ labels: namespaceResources: enabled: true -# Preferred top-level identity fields (kept with legacy serviceConfig below for compatibility). -workload: backend +# Current service identity. +service: backend serviceAccount: backend-runtime-sa -serviceConfig: +# Optional service-level deny-all. Use this for a protected service release. +# Keep this disabled for source/peer services that should stay unrestricted. +denyAll: enabled: true - namespace: default - workload: backend - serviceAccount: backend-runtime-sa + podLabels: + app.kubernetes.io/name: backend -allowTo: - - service: auth +allowPolicies: + # Inbound traffic to backend from selected in-mesh services. + - type: ingress + service: frontend + namespace: default + serviceAccount: frontend-runtime-sa + podLabels: + app.kubernetes.io/name: frontend + port: 8080 + protocol: TCP + methods: ["GET", "POST"] + paths: ["/api/*"] + + # Inbound traffic from a non-mesh source such as an ingress controller. + # NetworkPolicy still restricts packets to the selected pods. + - type: ingress + service: ingress-nginx + namespace: ingress-nginx + podLabels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/component: controller + port: 8080 + protocol: TCP + allowUnauthenticated: true + + # Outbound traffic from backend to another service. + - type: egress + service: auth namespace: shared port: 8080 protocol: TCP - serviceAccount: auth-runtime-sa - methods: ["POST"] - paths: ["/verify", "/token"] + podLabels: + app.kubernetes.io/name: auth - - service: payments + - type: egress + service: payments namespace: poc-mesh port: 8080 - methods: ["POST"] - paths: ["/charge"] + protocol: TCP + podLabels: + app.kubernetes.io/name: payments - - hosts: + # External host egress. + - type: egress + hosts: - api.stripe.com - s3.eu-central-1.amazonaws.com # Optional host ports; merged with defaults 80/HTTP and 443/HTTPS. diff --git a/specs/016-service-deny-all/checklists/requirements.md b/specs/016-service-deny-all/checklists/requirements.md new file mode 100644 index 0000000..f92f7ce --- /dev/null +++ b/specs/016-service-deny-all/checklists/requirements.md @@ -0,0 +1,20 @@ +# Requirements Checklist: Service-Level Deny All + +**Feature**: `specs/016-service-deny-all/spec.md` + +## Content Quality + +- [x] No implementation-only details in user stories +- [x] User value and rollout safety are clear +- [x] Acceptance scenarios are independently testable +- [x] Scope is bounded to zero-trust-mesh chart behavior + +## Requirement Completeness + +- [x] Service-level inbound deny behavior specified +- [x] Service-level outbound deny behavior specified +- [x] Namespace-wide behavior explicitly out of scope for the new option +- [x] Selector default and override behavior specified +- [x] Disabled default specified +- [x] Existing allow-rule compatibility specified +- [x] Documentation, example, render assertion, and version bump specified diff --git a/specs/016-service-deny-all/contracts/render-contract.md b/specs/016-service-deny-all/contracts/render-contract.md new file mode 100644 index 0000000..217a356 --- /dev/null +++ b/specs/016-service-deny-all/contracts/render-contract.md @@ -0,0 +1,50 @@ +# Render Contract: Service-Level Deny All + +## Service-level deny-all enabled + +Command: + +```bash +helm template ztm-service-deny-all ./charts/zero-trust-mesh -n default -f ./charts/zero-trust-mesh/tests/service-deny-all-values.yaml +``` + +Expected output: + +- Includes a `networking.k8s.io/v1` `NetworkPolicy`. +- NetworkPolicy selects only the configured workload labels. +- NetworkPolicy has `policyTypes` containing both `Ingress` and `Egress`. +- NetworkPolicy has no ingress or egress allow rule entries. +- Includes a `security.istio.io/v1` `AuthorizationPolicy`. +- AuthorizationPolicy selects only the configured workload labels. +- Does not include namespace baseline deny-all resource names unless namespace baseline is separately enabled. + +## Disabled default + +Command: + +```bash +helm template ztm-default ./charts/zero-trust-mesh -n default +``` + +Expected output: + +- Does not render service-level deny-all resources. +- Does not render sample allow rules. + +## NetworkPolicy disabled + +When `serviceDenyAll.enabled: true` and `networkPolicy.enabled: false`: + +- Does not render the service-level NetworkPolicy. +- May still render the service-level AuthorizationPolicy if Istio is enabled. + +## Istio disabled + +When `serviceDenyAll.enabled: true` and `istio.enabled: false`: + +- Does not render the service-level AuthorizationPolicy. +- May still render the service-level NetworkPolicy if NetworkPolicy is enabled. + +## Existing allow rules + +Existing service, host, and IP examples must continue rendering without behavior changes. diff --git a/specs/016-service-deny-all/data-model.md b/specs/016-service-deny-all/data-model.md new file mode 100644 index 0000000..44225af --- /dev/null +++ b/specs/016-service-deny-all/data-model.md @@ -0,0 +1,39 @@ +# Data Model: Service-Level Deny All + +## ServiceDenyAllConfig + +Values object that controls workload-scoped deny-all behavior. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `enabled` | boolean | no | Enables service/workload-level deny-all resources. Defaults to `false`. | +| `podLabels` | map[string]string | no | Selector labels for the workload pods. Defaults to `app.kubernetes.io/name: `. | + +## WorkloadSelector + +The pod labels used by generated policies. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `matchLabels` | map[string]string | yes | Kubernetes and Istio workload selector labels. | + +## ServiceDenyAllNetworkPolicy + +One Kubernetes NetworkPolicy rendered when `serviceDenyAll.enabled` is true and `networkPolicy.enabled` is not false. + +Key fields: + +- `metadata.namespace`: workload namespace +- `spec.podSelector.matchLabels`: service-level workload selector +- `spec.policyTypes`: `Ingress` and `Egress` +- no `spec.ingress` or `spec.egress` allow rules + +## ServiceDenyAllAuthorizationPolicy + +One Istio AuthorizationPolicy rendered when `serviceDenyAll.enabled` is true and `istio.enabled` is not false. + +Key fields: + +- `metadata.namespace`: workload namespace +- `spec.selector.matchLabels`: service-level workload selector +- no allow rules, establishing default-deny inbound behavior for selected workload diff --git a/specs/016-service-deny-all/plan.md b/specs/016-service-deny-all/plan.md new file mode 100644 index 0000000..1fe9fb2 --- /dev/null +++ b/specs/016-service-deny-all/plan.md @@ -0,0 +1,98 @@ +# Implementation Plan: Service-Level Deny All + +**Branch**: `016-service-deny-all` | **Date**: 2026-05-21 | **Spec**: `/specs/016-service-deny-all/spec.md` +**Input**: Feature specification from `/specs/016-service-deny-all/spec.md` + +## Summary + +Add a disabled-by-default service-level deny-all option to `charts/zero-trust-mesh`. When enabled, it renders workload-scoped Kubernetes NetworkPolicy default deny for both ingress and egress, plus workload-scoped Istio AuthorizationPolicy default deny for inbound mesh traffic. Namespace baseline behavior and existing `allowTo` rules remain unchanged. + +## Technical Context + +**Language/Version**: Helm template DSL, YAML manifests +**Primary Dependencies**: Helm 3 CLI, Kubernetes NetworkPolicy `networking.k8s.io/v1`, Istio AuthorizationPolicy `security.istio.io/v1` +**Storage**: N/A +**Testing**: `helm lint`, `helm template`, focused shell render assertion +**Target Platform**: Kubernetes clusters with a NetworkPolicy provider and Istio sidecar traffic management +**Project Type**: Helm chart repository +**Performance Goals**: Render behavior remains constant for the deny-all option and linear for existing `allowTo` entries +**Constraints**: Disabled by default; service/workload scoped only; no namespace-wide behavior changes; compatible with later explicit allow rules +**Scale/Scope**: One chart (`zero-trust-mesh`), one example under `examples/zero-trust-mesh/`, focused tests, and Speckit artifacts under `specs/016-service-deny-all/` + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **Chart-First**: Work stays inside `charts/zero-trust-mesh`, repo examples, and repo specs. +- [x] **Values Contract**: New consumer-facing behavior is exposed via values as a disabled-by-default service deny-all block. +- [x] **Lint & Template**: Plan includes `helm lint` and `helm template` with focused and existing examples. +- [x] **Versioning & Compatibility**: Change is backward-compatible and includes a patch version bump. +- [x] **Simplicity & Defaults**: New behavior is opt-in and defaults to no rendered resources. +- [x] **Examples for new abilities**: Plan includes `examples/zero-trust-mesh/service-deny-all.yaml`. +- [x] **Example testing and regression**: Plan includes rendering the new example and existing zero-trust-mesh examples. +- [x] **Docs before implementation**: Kubernetes NetworkPolicy and Istio AuthorizationPolicy shapes are confirmed against existing repo usage and documented in research. + +## Project Structure + +### Documentation (this feature) + +```text +specs/016-service-deny-all/ +├── plan.md +├── spec.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── render-contract.md +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +charts/ +└── zero-trust-mesh/ + ├── Chart.yaml + ├── README.md + ├── values.yaml + ├── templates/ + │ ├── _helpers.tpl + │ ├── istio-service-deny-all.yaml + │ └── networkpolicy-service-deny-all.yaml + └── tests/ + ├── service-deny-all-values.yaml + └── render-service-deny-all.sh + +examples/ +└── zero-trust-mesh/ + └── service-deny-all.yaml +``` + +**Structure Decision**: Keep service-level deny-all in dedicated templates so namespace baseline default-deny templates remain unchanged and the new option stays visibly service scoped. + +## Phase 0: Research Plan + +- Confirm current namespace-level deny-all resources and avoid modifying those templates. +- Confirm service-level selector behavior follows existing `workload` and override patterns. +- Confirm additive Kubernetes NetworkPolicy behavior supports opening explicit allow rules later. +- Confirm Istio default-deny behavior should use an ALLOW policy with no rules, not an action DENY policy. + +## Phase 1: Design & Contracts Plan + +- Document the service deny-all option, workload selector, NetworkPolicy, and AuthorizationPolicy in `data-model.md`. +- Define render contract for the new resources, selector override, disabled defaults, and regression behavior in `contracts/render-contract.md`. +- Provide quickstart commands for focused render assertions, chart linting, default rendering, and example rendering. +- Re-check constitution compliance after artifact generation. + +## Post-Design Constitution Check + +- [x] No constitution violations remain in the planned implementation. +- [x] Chart version bump is included in tasks. +- [x] New public value is paired with README/values documentation and a runnable example. + +## Complexity Tracking + +No constitution violations requiring justification. diff --git a/specs/016-service-deny-all/quickstart.md b/specs/016-service-deny-all/quickstart.md new file mode 100644 index 0000000..caf28ce --- /dev/null +++ b/specs/016-service-deny-all/quickstart.md @@ -0,0 +1,46 @@ +# Quickstart: Service-Level Deny All + +Run these commands from the repository root. + +## Focused service deny-all assertion + +```bash +./charts/zero-trust-mesh/tests/render-service-deny-all.sh ./charts/zero-trust-mesh +``` + +Expected: exits with status `0` after implementation. + +## Chart lint + +```bash +helm lint ./charts/zero-trust-mesh +``` + +Expected: `0 chart(s) failed`. + +## Default render regression + +```bash +helm template ztm-default ./charts/zero-trust-mesh -n default +``` + +Expected: renders successfully and does not include service-level deny-all resources. + +## Existing examples + +```bash +helm template ztm-namespace ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/values.namespace.yaml +helm template ztm-full ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/values.full.yaml +helm template ztm-target-pod-labels ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/target-pod-labels.yaml +helm template ztm-ip-egress ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/ip-egress.yaml +``` + +Expected: each command exits with status `0`. + +## New example + +```bash +helm template ztm-service-deny-all ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/service-deny-all.yaml +``` + +Expected: renders service-level deny-all resources for the selected workload only. diff --git a/specs/016-service-deny-all/research.md b/specs/016-service-deny-all/research.md new file mode 100644 index 0000000..d29aa18 --- /dev/null +++ b/specs/016-service-deny-all/research.md @@ -0,0 +1,34 @@ +# Research: Service-Level Deny All + +## Decision: Add a dedicated `serviceDenyAll` values block + +- **Decision**: Use `serviceDenyAll.enabled` with optional `serviceDenyAll.podLabels`. +- **Rationale**: The chart already has `namespaceResourcesEnabled` for namespace-wide controls and `allowTo` for explicit service/host/IP allows. A dedicated block keeps the new safety switch separate from both concepts. +- **Alternatives considered**: + - Reuse `namespaceResourcesEnabled`: rejected because the requested behavior must not affect the entire namespace. + - Add a special `allowTo` entry: rejected because deny-all is a baseline isolation state, not an allow rule. + +## Decision: Render service-level deny-all with Kubernetes NetworkPolicy + +- **Decision**: Render a NetworkPolicy that selects only the service workload pods and lists both `Ingress` and `Egress` policy types with no rules. +- **Rationale**: Kubernetes NetworkPolicy deny behavior is driven by selecting pods and omitting allow rules. NetworkPolicy is additive, so later allow policies can open explicit paths for the selected pods. +- **Existing repo evidence**: `charts/zero-trust-mesh/templates/networkpolicy-default-deny.yaml` already uses no allow rules for namespace baseline deny-all. + +## Decision: Render service-level Istio default deny with AuthorizationPolicy selector + +- **Decision**: Render an Istio AuthorizationPolicy with a workload selector and no allow rules when `istio.enabled` is not false. +- **Rationale**: Existing chart baseline uses an empty AuthorizationPolicy to establish default-deny behavior. Adding a selector scopes that behavior to one workload. Avoiding action `DENY` keeps later explicit ALLOW policies usable. +- **Boundary**: Istio AuthorizationPolicy covers inbound authorization. Outbound denial is handled by Kubernetes NetworkPolicy for this feature. + +## Decision: Selector override is required + +- **Decision**: Default selector is `app.kubernetes.io/name: `, with `serviceDenyAll.podLabels` override. +- **Rationale**: Existing chart service allow rules support target selector override because real workloads may not use the default label. Service-level deny-all has the same risk and needs an explicit override path. + +## Compatibility Notes + +- `serviceDenyAll.enabled` defaults to false and renders no resources unless explicitly enabled. +- `namespaceResourcesEnabled` templates are left unchanged. +- Existing `allowTo` service, host, and IP templates are left unchanged. +- `networkPolicy.enabled: false` suppresses the service-level NetworkPolicy. +- `istio.enabled: false` suppresses the service-level AuthorizationPolicy. diff --git a/specs/016-service-deny-all/spec.md b/specs/016-service-deny-all/spec.md new file mode 100644 index 0000000..0f016fb --- /dev/null +++ b/specs/016-service-deny-all/spec.md @@ -0,0 +1,101 @@ +# Feature Specification: Service-Level Deny All + +**Feature Branch**: `016-service-deny-all` +**Created**: 2026-05-21 +**Status**: Draft +**Input**: Jira `DMVP-10070`: add a service-level deny-all mode so one workload can deny inbound and outbound traffic without enabling namespace-wide deny-all. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Isolate one service first (Priority: P1) + +As a zero-trust-mesh chart consumer, I can enable deny-all for one selected workload, so I can safely isolate that service before adding explicit allow rules. + +**Why this priority**: This is the requested safety control for Checkpoint rollout. Namespace-level deny-all is too broad for incremental adoption because it can affect every workload in the namespace. + +**Independent Test**: Render `charts/zero-trust-mesh` with service-level deny-all enabled, then verify the output includes workload-scoped Kubernetes `NetworkPolicy` deny-all for both ingress and egress plus a workload-scoped Istio `AuthorizationPolicy` default deny. + +**Acceptance Scenarios**: + +1. **Given** service-level deny-all is enabled for workload `frontend`, **When** the chart is rendered, **Then** the generated NetworkPolicy selects only `frontend` pods and lists both `Ingress` and `Egress` policy types. +2. **Given** the same values, **When** the chart is rendered, **Then** the generated Istio AuthorizationPolicy selects only `frontend` pods and provides default-deny behavior for inbound mesh traffic. +3. **Given** namespace baseline is disabled, **When** service-level deny-all is rendered, **Then** no namespace-wide deny-all NetworkPolicy is created by that setting. + +--- + +### User Story 2 - Preserve existing allow rules (Priority: P2) + +As an existing chart consumer, I can keep using service, host, and IP allow rules without behavior changes caused by adding service-level deny-all. + +**Why this priority**: The new control must be additive and compatible with follow-up explicit allow rules. + +**Independent Test**: Render existing examples and focused render checks after the new option is added. + +**Acceptance Scenarios**: + +1. **Given** existing `allowTo[].service` rules, **When** the chart is rendered, **Then** existing NetworkPolicy ingress and AuthorizationPolicy allow resources still render. +2. **Given** existing `allowTo[].hosts` and `allowTo[].ips` rules, **When** the chart is rendered, **Then** existing ServiceEntry and IP egress NetworkPolicy behavior remains valid. + +--- + +### User Story 3 - Discover safe rollout values (Priority: P3) + +As a service owner, I can find an example for service-level deny-all, so I can start with a safe isolated service and later add explicit allow rules. + +**Why this priority**: The option changes the public chart values contract and should be easy to discover. + +**Independent Test**: Follow the example under `examples/zero-trust-mesh/service-deny-all.yaml` and render it successfully with Helm. + +**Acceptance Scenarios**: + +1. **Given** a user reads the chart README, **When** they scan the key values table, **Then** they can find the service-level deny-all option and its intended scope. +2. **Given** the documented example, **When** a user runs its top-line Helm command, **Then** the chart renders successfully. + +### Edge Cases + +- Service-level deny-all must not create namespace-wide deny resources when `namespaceResourcesEnabled` is false. +- If NetworkPolicy support is disabled with `networkPolicy.enabled: false`, the chart must not render the service-level NetworkPolicy. +- If Istio support is disabled with `istio.enabled: false`, the chart must not render the service-level AuthorizationPolicy. +- Consumers must be able to override the workload pod selector when the default `app.kubernetes.io/name: ` label does not match their deployment labels. +- Existing namespace baseline resources must remain unchanged. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The chart MUST support an optional service/workload-level deny-all values block that defaults to disabled. +- **FR-002**: When service-level deny-all is enabled and NetworkPolicy rendering is enabled, the chart MUST render a Kubernetes `NetworkPolicy` that selects only the configured workload pods. +- **FR-003**: The service-level NetworkPolicy MUST deny both inbound and outbound traffic by including `Ingress` and `Egress` policy types without allow rules. +- **FR-004**: When service-level deny-all is enabled and Istio rendering is enabled, the chart MUST render an Istio `AuthorizationPolicy` that selects only the configured workload pods and denies inbound mesh traffic by default. +- **FR-005**: The service-level deny-all selector MUST default to `app.kubernetes.io/name: `. +- **FR-006**: The service-level deny-all values MUST allow selector override for services whose pods use different labels. +- **FR-007**: Service-level deny-all MUST NOT enable or alter namespace-wide deny-all resources. +- **FR-008**: Existing `allowTo[].service`, `allowTo[].hosts`, and `allowTo[].ips` behavior MUST remain compatible. +- **FR-009**: The chart MUST document the new values in `charts/zero-trust-mesh/values.yaml` and `charts/zero-trust-mesh/README.md`. +- **FR-010**: The repository MUST include a runnable example under `examples/zero-trust-mesh/`. +- **FR-011**: The change MUST include a render check that fails against the previous chart and passes after implementation. +- **FR-012**: The affected chart version MUST be bumped according to repository constitution requirements. + +### Key Entities + +- **Service-level deny-all option**: A values block that enables deny-all behavior for the current workload only. +- **Workload selector**: The labels used by generated policies to select the service pods. +- **Service deny-all NetworkPolicy**: A workload-scoped Kubernetes policy with both ingress and egress policy types and no allow rules. +- **Service default-deny AuthorizationPolicy**: A workload-scoped Istio policy that establishes default-deny inbound mesh behavior for the selected workload. + +### Assumptions + +- Kubernetes NetworkPolicy is additive, so later allow policies can open specific traffic for the same selected pods. +- Istio AuthorizationPolicy default-deny behavior can be established for a selected workload without using a DENY action that would block later ALLOW policies. +- Outbound traffic denial is enforced by Kubernetes NetworkPolicy in this chart; Istio AuthorizationPolicy is used for inbound mesh authorization. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Rendering the service-level deny-all test fixture produces exactly workload-scoped deny resources and no namespace-wide deny-all resource. +- **SC-002**: Rendered service-level NetworkPolicy contains both `Ingress` and `Egress` policy types. +- **SC-003**: Rendered service-level deny resources select the intended workload labels. +- **SC-004**: Existing zero-trust-mesh examples render successfully after the change. +- **SC-005**: `helm lint ./charts/zero-trust-mesh` completes with 0 failed charts. +- **SC-006**: A reviewer can locate the new values shape in README, `values.yaml`, and a runnable example in under 5 minutes. diff --git a/specs/016-service-deny-all/tasks.md b/specs/016-service-deny-all/tasks.md new file mode 100644 index 0000000..b08e74d --- /dev/null +++ b/specs/016-service-deny-all/tasks.md @@ -0,0 +1,107 @@ +# Tasks: Service-Level Deny All + +**Input**: Design documents from `/specs/016-service-deny-all/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/render-contract.md`, `quickstart.md` + +**Tests**: This feature requires Helm lint/template checks plus a focused render assertion. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Capture the missing service-level deny-all behavior before implementation. + +- [x] T001 Review existing namespace baseline deny-all templates and document behavior in `specs/016-service-deny-all/research.md` +- [x] T002 Add `charts/zero-trust-mesh/tests/service-deny-all-values.yaml` with service-level deny-all enabled for one workload +- [x] T003 Add `charts/zero-trust-mesh/tests/render-service-deny-all.sh` to assert workload-scoped NetworkPolicy and AuthorizationPolicy output +- [x] T004 Run `./charts/zero-trust-mesh/tests/render-service-deny-all.sh ./charts/zero-trust-mesh` before implementation and confirm it fails because service-level deny-all resources are absent + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add selector helper behavior needed by both service-level deny-all templates. + +- [x] T005 Add a service deny-all pod-label helper in `charts/zero-trust-mesh/templates/_helpers.tpl` +- [x] T006 Ensure the helper defaults to `app.kubernetes.io/name: ` +- [x] T007 Ensure the helper supports `serviceDenyAll.podLabels` override + +**Checkpoint**: Shared workload selector behavior is available. + +--- + +## Phase 3: User Story 1 - Isolate one service first (Priority: P1) MVP + +**Goal**: Render deny-all resources for one selected workload. + +**Independent Test**: `./charts/zero-trust-mesh/tests/render-service-deny-all.sh ./charts/zero-trust-mesh` + +- [x] T008 [US1] Add `charts/zero-trust-mesh/templates/networkpolicy-service-deny-all.yaml` +- [x] T009 [US1] Add `charts/zero-trust-mesh/templates/istio-service-deny-all.yaml` +- [x] T010 [US1] Re-run the focused render assertion and confirm it exits `0` + +**Checkpoint**: Service-level deny-all is rendered and independently verifiable. + +--- + +## Phase 4: User Story 2 - Preserve existing allow rules (Priority: P2) + +**Goal**: Keep existing service, host, and IP allow behavior valid. + +**Independent Test**: Existing examples and render checks complete successfully. + +- [x] T011 [US2] Render default chart values and confirm service-level deny-all is absent by default +- [x] T012 [US2] Render existing zero-trust-mesh examples from `specs/016-service-deny-all/quickstart.md` +- [x] T013 [US2] Confirm existing default-empty render assertion still exits `0` + +**Checkpoint**: Existing consumers are not regressed. + +--- + +## Phase 5: User Story 3 - Discover safe rollout values (Priority: P3) + +**Goal**: Document and demonstrate service-level deny-all. + +**Independent Test**: A user can render the repo-level example command successfully. + +- [x] T014 [US3] Document `serviceDenyAll` in `charts/zero-trust-mesh/values.yaml` +- [x] T015 [US3] Document `serviceDenyAll` in `charts/zero-trust-mesh/README.md` +- [x] T016 [US3] Add `examples/zero-trust-mesh/service-deny-all.yaml` with a top-line runnable Helm command +- [x] T017 [US3] Render the new example with `helm template ztm-service-deny-all ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/service-deny-all.yaml` + +**Checkpoint**: Documentation and example values show the new safe rollout option. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final compliance, versioning, and release readiness. + +- [x] T018 Add Speckit artifacts under `specs/016-service-deny-all/` +- [x] T019 Bump `charts/zero-trust-mesh/Chart.yaml` patch version +- [x] T020 Run `helm lint ./charts/zero-trust-mesh` +- [x] T021 Run focused render assertion `./charts/zero-trust-mesh/tests/render-service-deny-all.sh ./charts/zero-trust-mesh` +- [x] T022 Run existing example regressions from `specs/016-service-deny-all/quickstart.md` +- [x] T023 Run `git diff --check` + +## Dependencies & Execution Order + +- Phase 1 precedes implementation because the render assertion must fail first. +- Phase 2 precedes US1 because both templates use the shared selector helper. +- US2 depends on final template output to validate regression behavior. +- US3 depends on finalized values shape and render output. +- Phase 6 depends on all stories. + +## Parallel Opportunities + +- Documentation updates (`T014`, `T015`) can run after values shape is final. +- Example rendering and default rendering can run in parallel during verification. +- Speckit documentation can be reviewed independently of template code after behavior is finalized. + +## Implementation Strategy + +1. Prove current behavior fails the new service-level deny-all assertion. +2. Add selector helper and deny-all templates. +3. Confirm focused service-level deny-all rendering. +4. Confirm existing zero-trust-mesh examples still render. +5. Add docs/example/version bump and run Helm validation.