What You'll Build
In this exercise you will apply the complete M5 security hardening stack to the IPL analytics platform, transforming it from a functional but minimally secured deployment into a production-grade hardened system. You will create dedicated ServiceAccounts for each workload with precisely scoped RBAC Roles, apply Pod Security Admission enforcement at the `restricted` level to the production namespace, deploy the default-deny NetworkPolicy set with specific allow-rules for each service's actual traffic patterns, install Kyverno with the approved-registry and resource-limits validation policies, and run the complete security verification checklist that confirms each hardening layer works independently and does not break the platform's functionality. The exercise is structured to apply hardening in layers, verifying after each layer that the platform continues to serve correct responses — the operational discipline that prevents a security hardening exercise from becoming an unplanned outage.
Prerequisites
- The IPL analytics platform from M4 Exercise running with StatefulSet PostgreSQL, HPA on the API Deployment, and Ingress with TLS — all services responding correctly before beginning hardening.
- Kyverno installed via Helm as covered in Lesson 32 — verify with `kubectl get pods -n kyverno` showing all three Kyverno controller replicas Running.
- The production namespace does NOT yet have PSA enforce labels — this exercise adds them; verify with `kubectl get ns production --show-labels` confirming no `pod-security.kubernetes.io/enforce` label.
- A Calico or Cilium CNI installed — verify NetworkPolicy enforcement is active by creating two test Pods and confirming unrestricted connectivity before applying default-deny.
- The API endpoint accessible via `kubectl port-forward` or the Ingress URL for integration testing — each hardening step is followed by a connectivity verification against this endpoint.
Step 1 — Foundation
Apply RBAC hardening first — create dedicated ServiceAccounts and patch the default ServiceAccount to disable automount. RBAC hardening is the safest first step because it does not disrupt existing network connectivity or Pod creation; it only changes the API server credentials available to running Pods. Verify after applying that all existing Pods are still functional before proceeding to the next hardening layer.
# Step 1: RBAC hardening
# Patch default ServiceAccount to disable automount in production
kubectl patch serviceaccount default -n production -p '{"automountServiceAccountToken": false}'
# Create purpose-specific ServiceAccounts
kubectl apply -f - << 'EOF'
apiVersion: v1
kind: ServiceAccount
metadata:
name: ipl-api-sa
namespace: production
automountServiceAccountToken: false # uses IRSA, no cluster API calls
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ipl-postgres-sa
namespace: production
automountServiceAccountToken: false # postgres makes no API calls
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ipl-redis-sa
namespace: production
automountServiceAccountToken: false # redis makes no API calls
EOF
# Update Deployments to use purpose-specific ServiceAccounts
kubectl patch deployment ipl-api -n production -p '{"spec":{"template":{"spec":{"serviceAccountName":"ipl-api-sa"}}}}'
kubectl patch statefulset ipl-postgres -n production -p '{"spec":{"template":{"spec":{"serviceAccountName":"ipl-postgres-sa"}}}}'
# Verify platform still works after RBAC change
kubectl port-forward service/ipl-api 8080:8000 -n production &
PF_PID=$!; sleep 3
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health)
kill $PF_PID 2>/dev/null
echo "After RBAC hardening: HTTP $STATUS (expected 200)"
[ "$STATUS" = "200" ] && echo "PASS ✓ — platform unaffected by RBAC hardening" Step 2 — Core Logic
Apply PSA enforcement and NetworkPolicies as the second and third hardening layers. PSA enforcement is applied by first running a dry-run validation against all existing Pod specs to confirm they are `restricted`-compliant — if any fail, fix them before applying the label. NetworkPolicies are applied as a complete set starting with the default-deny and followed by all allow-rules simultaneously, to ensure the allow-rules are in place before the deny takes effect.
# Step 2: PSA enforcement + NetworkPolicies
# ── PSA: dry-run validation before enabling enforcement ───────────────────
echo "Validating existing Pods against restricted profile..."
kubectl get pods -n production -o json | python3 -c "
import sys, json, subprocess
pods = json.load(sys.stdin)['items']
violations = []
for pod in pods:
result = subprocess.run(
['kubectl', 'auth', 'can-i', '--list'],
capture_output=True, text=True
)
# Simplified check: look for missing security context fields
spec = pod['spec']
for container in spec.get('containers', []):
sc = container.get('securityContext', {})
if not sc.get('runAsNonRoot'):
violations.append(f'{pod["metadata"]["name"]}/{container["name"]}: missing runAsNonRoot')
if not sc.get('allowPrivilegeEscalation') == False:
violations.append(f'{pod["metadata"]["name"]}/{container["name"]}: missing allowPrivilegeEscalation=false')
if violations:
print('VIOLATIONS FOUND — fix before enabling enforcement:')
for v in violations: print(f' {v}')
else:
print('All Pods compliant with restricted profile ✓')
"
# Apply PSA labels (assuming all Pods are compliant)
kubectl label namespace production pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest --overwrite
echo "PSA restricted enforcement enabled ✓"
# ── NetworkPolicies: apply complete set atomically ────────────────────────
kubectl apply -f - << 'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny-all, namespace: production}
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns-egress, namespace: production}
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}
podSelector: {matchLabels: {k8s-app: kube-dns}}
ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-api-from-ingress, namespace: production}
spec:
podSelector: {matchLabels: {app: ipl-api}}
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: ingress-nginx}}
podSelector: {matchLabels: {app.kubernetes.io/name: ingress-nginx}}
ports: [{port: 8000, protocol: TCP}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-api-to-data, namespace: production}
spec:
podSelector: {matchLabels: {app: ipl-api}}
policyTypes: [Egress]
egress:
- to: [{podSelector: {matchLabels: {app: ipl-postgres}}}]
ports: [{port: 5432, protocol: TCP}]
- to: [{podSelector: {matchLabels: {app: ipl-redis}}}]
ports: [{port: 6379, protocol: TCP}]
- to: [{ipBlock: {cidr: 0.0.0.0/0, except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16]}}]
ports: [{port: 443, protocol: TCP}]
EOF
echo "NetworkPolicies applied ✓"
# Verify API still accessible after NetworkPolicy application
kubectl port-forward service/ipl-api 8080:8000 -n production &
PF_PID=$!; sleep 3
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health)
kill $PF_PID 2>/dev/null
echo "After NetworkPolicy: HTTP $STATUS (expected 200)" Step 3 — Integration & Enhancement
Apply Kyverno policies as the final hardening layer. Install the approved-registry and resource-limits policies in Audit mode first, verify zero violations in the PolicyReport, then switch to Enforce mode. Run the complete security verification checklist confirming all four hardening layers are active and the platform serves correct responses.
# Step 3: Kyverno policies + final verification checklist
# Apply Kyverno policies in Audit mode
kubectl apply -f - << 'EOF'
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-approved-registry
spec:
validationFailureAction: Audit # start in Audit, switch to Enforce after review
background: true
rules:
- name: check-registry
match: {any: [{resources: {kinds: [Pod], namespaces: [production]}}]}
validate:
message: "Images must be from approved ECR registry"
pattern:
spec:
containers:
- image: "123456789.dkr.ecr.ap-south-1.amazonaws.com/*|(postgres|redis|alpine|busybox):*"
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resource-limits
spec:
validationFailureAction: Audit
background: true
rules:
- name: check-limits
match: {any: [{resources: {kinds: [Deployment, StatefulSet], namespaces: [production]}}]}
validate:
message: "All containers must have cpu and memory limits"
pattern:
spec:
template:
spec:
containers:
- resources:
limits:
memory: "?*"
cpu: "?*"
EOF
sleep 30 # wait for Kyverno to evaluate existing resources
# Check PolicyReport for violations
echo "=== Kyverno PolicyReport ==="
kubectl get policyreport -n production -o jsonpath='{range .items[*]}{.metadata.name}: pass={.summary.pass} fail={.summary.fail}{"
"}{end}'
# If fail=0, switch to Enforce mode
kubectl patch clusterpolicy require-approved-registry -p '{"spec":{"validationFailureAction":"Enforce"}}' --type merge
kubectl patch clusterpolicy require-resource-limits -p '{"spec":{"validationFailureAction":"Enforce"}}' --type merge
echo ""
echo "=== Final Security Hardening Verification ==="
# 1. RBAC: default SA has automount disabled
echo -n "1. Default SA automount disabled: "
AUTOMOUNT=$(kubectl get serviceaccount default -n production -o jsonpath='{.automountServiceAccountToken}')
[ "$AUTOMOUNT" = "false" ] && echo "PASS ✓" || echo "FAIL ✗"
# 2. PSA: enforce=restricted label present
echo -n "2. PSA restricted enforcement: "
PSA=$(kubectl get ns production -o jsonpath='{.metadata.labels.pod-security\.kubernetes\.io/enforce}')
[ "$PSA" = "restricted" ] && echo "PASS ✓" || echo "FAIL ✗ (got: $PSA)"
# 3. NetworkPolicy: default-deny-all exists
echo -n "3. Default-deny NetworkPolicy: "
kubectl get networkpolicy default-deny-all -n production > /dev/null 2>&1 && echo "PASS ✓" || echo "FAIL ✗"
# 4. Kyverno: policies in Enforce mode
echo -n "4. Kyverno enforce mode active: "
ACTION=$(kubectl get clusterpolicy require-approved-registry -o jsonpath='{.spec.validationFailureAction}')
[ "$ACTION" = "Enforce" ] && echo "PASS ✓" || echo "FAIL ✗"
# 5. Platform functional
echo -n "5. API responds correctly: "
kubectl port-forward service/ipl-api 8080:8000 -n production &
PF_PID=$!; sleep 3
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/batters/rohit_sharma)
kill $PF_PID 2>/dev/null
[ "$STATUS" = "200" ] && echo "PASS ✓" || echo "FAIL ✗ (HTTP $STATUS)"
echo "=== All hardening layers applied and verified ===" Warning: Never apply the default-deny NetworkPolicy before creating the DNS allow-rule and all service-specific allow-rules. Applying default-deny-all first and then adding allow-rules one at a time will create a window where DNS resolution is broken (causing all hostname lookups to fail), PostgreSQL is unreachable (causing API errors), and Redis is unreachable (causing cache failures) — each step takes a few seconds, but if any step fails or is interrupted, the platform is left in a partially-secured, partially-broken state. Apply all NetworkPolicies in a single `kubectl apply -f netpolicies/` directory command or a single multi-document YAML file to ensure the default-deny and all allow-rules are created atomically in a single API call batch.
Extension Challenge: Integrate the Vault Agent sidecar with the PostgreSQL StatefulSet to replace the static database password Secret with Vault-generated dynamic credentials. Configure the Vault Kubernetes auth method to accept the `ipl-postgres-sa` ServiceAccount, create a Vault database role with CREATE/GRANT permissions for schema initialization, and add the Vault Agent annotations to the API Deployment to render the database URL from Vault at startup. This extension exercises all three components of the secrets management stack: RBAC ServiceAccount, Vault Kubernetes auth, and Vault database secrets engine working together.
- Apply hardening layers sequentially with verification after each step — RBAC first (zero disruption risk), PSA second (dry-run validation required), NetworkPolicies third (apply all rules atomically), Kyverno fourth (audit mode first).
- PSA enforcement on a running namespace requires verifying all existing Pods are compliant via dry-run before applying the label — non-compliant running Pods continue until their next recreation, at which point they will be blocked.
- Apply NetworkPolicies as a complete set (default-deny + all allow-rules) in a single kubectl apply command to avoid a window where default-deny is active without the DNS and service-specific allow-rules.
- Kyverno policies should always start in Audit mode, with PolicyReport review confirming zero violations before switching to Enforce — never apply Enforce directly without an audit phase.
- Verify platform functionality after each hardening layer by testing the API health endpoint — a passing test after each layer confirms the hardening was applied correctly without breaking the application.
- The complete hardening stack — RBAC least-privilege, PSA restricted, NetworkPolicy default-deny, Kyverno governance — addresses four independent threat categories: API server compromise, container escape, lateral movement, and supply chain.