Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion charts/zero-trust-mesh/Chart.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
apiVersion: v2
name: zero-trust-mesh
version: 0.1.1
version: 0.1.2
description: Helm chart for Kubernetes NetworkPolicy + Istio zero-trust service communication
appVersion: "1.0"
type: application
14 changes: 12 additions & 2 deletions charts/zero-trust-mesh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Minimal Helm chart for strict Kubernetes + Istio zero-trust communication.
- STRICT mTLS via Istio `PeerAuthentication`
- Default deny via Istio `AuthorizationPolicy`
- Service-account based allow rules with optional HTTP methods/paths
- External egress only to approved hosts via Istio egress gateway
- External egress only to approved hosts or IP blocks

## Two deployment modes

Expand Down Expand Up @@ -50,11 +50,15 @@ allowTo:
methods: ["GET", "POST"]
paths: ["/api/*"]
- hosts: ["api.stripe.com"]
- ips: ["192.0.2.10"]
ports:
- number: 443
protocol: TCP
```

## Values design

`allowTo` is a single list with two entry types:
`allowTo` is a single list with three entry types:

- Service rule:
- `service` (required)
Expand All @@ -68,6 +72,11 @@ allowTo:
- `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
- 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`.

Expand All @@ -90,6 +99,7 @@ Most security defaults are now implicit in templates. Advanced overrides can sti
| `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"]` |

## Install

Expand Down
20 changes: 20 additions & 0 deletions charts/zero-trust-mesh/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@
{{- regexReplaceAll "[^a-z0-9-]+" (lower .) "-" | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "ztm.ipCidr" -}}
{{- $ip := printf "%v" . -}}
{{- if contains "/" $ip -}}
{{- $ip -}}
{{- else if contains ":" $ip -}}
{{- printf "%s/128" $ip -}}
{{- else -}}
{{- printf "%s/32" $ip -}}
{{- end -}}
{{- end -}}

{{- define "ztm.networkPolicyProtocol" -}}
{{- $protocol := default "TCP" . | upper -}}
{{- if or (eq $protocol "UDP") (eq $protocol "SCTP") -}}
{{- $protocol -}}
{{- else -}}
TCP
{{- end -}}
{{- end -}}

{{- define "ztm.workloadNamespace" -}}
{{- $svc := .Values.serviceConfig | default (dict) -}}
{{- default .Release.Namespace (default $svc.namespace .Values.namespace) -}}
Expand Down
33 changes: 33 additions & 0 deletions charts/zero-trust-mesh/templates/istio-serviceentries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,38 @@ spec:
{{- end }}
{{- end }}
{{- end }}
{{ if .ips }}
{{- $defaultPorts := list (dict "number" 443 "protocol" "TCP") -}}
{{- $ports := .ports | default $defaultPorts -}}
{{- $addresses := list -}}
{{- range .ips }}
{{- $addresses = append $addresses (include "ztm.ipCidr" .) -}}
{{- end -}}
{{- $hashSource := printf "%s-%s" (join "," $addresses) (toJson $ports) -}}
{{- $resourceName := printf "external-ip-%s" ($hashSource | sha256sum | trunc 10) }}
---
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: {{ $resourceName }}
namespace: {{ $workloadNamespace }}
spec:
hosts:
- {{ printf "%s.ztm.local" $resourceName | quote }}
addresses:
{{- range $addresses }}
- {{ . | quote }}
{{- end }}
location: MESH_EXTERNAL
resolution: NONE
ports:
{{- range $port := $ports }}
{{- $number := required "allowTo[].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 }}
protocol: {{ $protocol }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
40 changes: 40 additions & 0 deletions charts/zero-trust-mesh/templates/networkpolicy-ip-egress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{{- $np := .Values.networkPolicy | default (dict) -}}
{{- if and (ne $np.enabled false) .Values.allowTo }}
{{- $workloadNamespace := include "ztm.workloadNamespace" . -}}
{{- $sourceWorkload := include "ztm.workloadName" . -}}
{{- range .Values.allowTo }}
{{- if .ips }}
{{- $defaultPorts := list (dict "number" 443 "protocol" "TCP") -}}
{{- $ports := .ports | default $defaultPorts -}}
{{- $addresses := list -}}
{{- range .ips }}
{{- $addresses = append $addresses (include "ztm.ipCidr" .) -}}
{{- end -}}
{{- $hashSource := printf "%s-%s" (join "," $addresses) (toJson $ports) -}}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-{{ include "ztm.sanitizeName" (printf "%s-to-ip-%s" $sourceWorkload ($hashSource | sha256sum | trunc 10)) }}
namespace: {{ $workloadNamespace }}
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: {{ $sourceWorkload }}
policyTypes:
- Egress
egress:
- to:
{{- range $addresses }}
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
ports:
{{- range $port := $ports }}
{{- $number := required "allowTo[].ips ports[].number is required" $port.number }}
- port: {{ $number }}
protocol: {{ include "ztm.networkPolicyProtocol" $port.protocol }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
10 changes: 10 additions & 0 deletions charts/zero-trust-mesh/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespaceResourcesEnabled: false
# Single allowTo list:
# - 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:
- service: backend
# Optional target pod selector override; defaults to:
Expand All @@ -28,3 +29,12 @@ allowTo:
# protocol: HTTP
# - number: 443
# protocol: HTTPS

- 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.
# Defaults to 443/TCP.
# ports:
# - number: 443
# protocol: TCP
12 changes: 12 additions & 0 deletions examples/zero-trust-mesh/ip-egress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# helm template ztm-ip-egress ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/ip-egress.yaml
workload: frontend
namespaceResourcesEnabled: false
allowTo:
- ips:
- 192.0.2.10
- 198.51.100.0/24
ports:
- number: 443
protocol: TCP
- number: 8080
protocol: HTTP
11 changes: 11 additions & 0 deletions examples/zero-trust-mesh/target-pod-labels.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# helm template ztm-target-pod-labels ./charts/zero-trust-mesh -n default -f ./examples/zero-trust-mesh/target-pod-labels.yaml
workload: frontend
namespaceResourcesEnabled: false
allowTo:
- service: backend
targetPodLabels:
app: backend
component: api
port: 8080
methods: ["GET"]
paths: ["/api/*"]
24 changes: 24 additions & 0 deletions specs/013-zero-trust-ip-egress/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Requirements Quality Checklist: Zero Trust IP Egress

**Purpose**: Validate specification quality before implementation handoff
**Created**: 2026-05-08
**Feature**: `specs/013-zero-trust-ip-egress/spec.md`

## Content Quality

- [x] No implementation details leak into user stories beyond chart-rendering behavior needed for acceptance.
- [x] Requirements are testable through Helm render output.
- [x] Requirements avoid ambiguous "support IPs" phrasing by defining ServiceEntry and NetworkPolicy outcomes.
- [x] Public values contract changes are documented.

## Requirement Completeness

- [x] User scenarios cover direct IP egress, existing behavior preservation, and discoverability.
- [x] Acceptance criteria include single IP and CIDR behavior.
- [x] Edge cases include default ports and protocol compatibility.
- [x] Success criteria are measurable with render checks and chart linting.
- [x] Constitution-required example and version bump are captured.

## Validation Result

Validation completed 2026-05-08. Spec is ready for `/speckit.plan`, `/speckit.tasks`, or implementation review.
54 changes: 54 additions & 0 deletions specs/013-zero-trust-ip-egress/contracts/render-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Render Contract: Zero Trust IP Egress

## Values Input

```yaml
workload: frontend
namespaceResourcesEnabled: false
allowTo:
- ips:
- 192.0.2.10
- 198.51.100.0/24
ports:
- number: 443
protocol: TCP
- number: 8080
protocol: HTTP
```

## Required Rendered ServiceEntry

The chart MUST render a `networking.istio.io/v1` `ServiceEntry` with:

- `metadata.namespace: default` when rendered with `-n default`
- `spec.addresses` containing `"192.0.2.10/32"` and `"198.51.100.0/24"`
- `spec.location: MESH_EXTERNAL`
- `spec.resolution: NONE`
- `spec.ports` containing port `443` protocol `TCP`
- `spec.ports` containing port `8080` protocol `HTTP`

The ServiceEntry host name may be synthetic, but it MUST be deterministic for the same values.

## Required Rendered NetworkPolicy

The chart MUST render a `networking.k8s.io/v1` `NetworkPolicy` with:

- `metadata.namespace: default` when rendered with `-n default`
- `spec.podSelector.matchLabels.app.kubernetes.io/name: frontend`
- `spec.policyTypes` containing `Egress`
- `spec.egress[].to[].ipBlock.cidr` containing `"192.0.2.10/32"` and `"198.51.100.0/24"`
- `spec.egress[].ports` containing port `443` protocol `TCP`
- `spec.egress[].ports` containing port `8080` protocol `TCP`, because Kubernetes NetworkPolicy does not accept `HTTP` as a protocol

## Default Port Contract

When an IP egress rule omits `ports`, the rendered ServiceEntry and NetworkPolicy MUST allow `443/TCP`.

## Regression Contract

Adding IP egress support MUST NOT change:

- `allowTo[].service` NetworkPolicy ingress rendering
- `allowTo[].service` AuthorizationPolicy rendering
- `allowTo[].hosts` DNS ServiceEntry rendering
- namespace baseline resources
69 changes: 69 additions & 0 deletions specs/013-zero-trust-ip-egress/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Data Model: Zero Trust IP Egress

## IpEgressRule

An `allowTo` entry that allows a source workload to egress to literal destination IP addresses or CIDR ranges.

```yaml
allowTo:
- ips:
- 192.0.2.10
- 198.51.100.0/24
ports:
- number: 443
protocol: TCP
```

Fields:

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `ips` | list(string) | yes | Destination IP addresses or CIDR blocks. |
| `ports` | list(IpPort) | no | Defaults to one entry: `443/TCP`. |

## IpPort

A port allowed for a direct IP egress rule.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `number` | integer | yes | Destination port rendered in ServiceEntry and NetworkPolicy. |
| `protocol` | string | no | Defaults to `TCP`; ServiceEntry uses the configured protocol, NetworkPolicy renders only Kubernetes-compatible L4 protocols. |
| `name` | string | no | Optional ServiceEntry port name override. |

## NormalizedIpBlock

The CIDR-form address rendered into manifests.

Rules:

- Input containing `/` is treated as already normalized and rendered unchanged.
- Input containing `:` and no `/` is treated as IPv6 and rendered with `/128`.
- Any other input without `/` is treated as IPv4 and rendered with `/32`.

## Rendered Resources

### IP ServiceEntry

One `networking.istio.io/v1` ServiceEntry per IP egress rule.

Key fields:

- `metadata.namespace`: workload namespace
- `spec.hosts`: synthetic internal host name for the ServiceEntry object
- `spec.addresses`: normalized IP blocks
- `spec.location`: `MESH_EXTERNAL`
- `spec.resolution`: `NONE`
- `spec.ports`: configured IP rule ports or default `443/TCP`

### IP Egress NetworkPolicy

One `networking.k8s.io/v1` NetworkPolicy per IP egress rule.

Key fields:

- `metadata.namespace`: workload namespace
- `spec.podSelector.matchLabels.app.kubernetes.io/name`: source workload
- `spec.policyTypes`: `Egress`
- `spec.egress[].to[].ipBlock.cidr`: normalized IP blocks
- `spec.egress[].ports`: configured IP rule ports with Kubernetes-compatible protocols
Loading
Loading