Capstone · Part 6 of 6 · Security, Tenancy & the Golden Path

Capstone Part 6: Security, Tenancy & the Golden Path

This is the last of six parts, and it does two jobs at once. First, it installs the guardrails a real platform needs: policy-as-code that starts in audit and graduates to enforce, RBAC scoped to a tenant instead of cluster-admin, a default-deny NetworkPolicy, mutual TLS from a service mesh, a supply-chain scan, and multi-tenant resource fairness with cost visibility. Second — and this is the payoff of the whole capstone — it wires Parts 1 through 5 together so a brand-new service scaffolded in Backstage flows through GitOps, a pipeline, a canary rollout, a platform CRD, dashboards, and every guardrail on this page automatically, with zero hand-written infrastructure. That is the golden path this entire course has been describing.

⚖ CNPA vs CNPE — This page's hands-on build — Kyverno in enforce, scoped tenant RBAC, default-deny NetworkPolicy, Linkerd mTLS, a Trivy scan, and the golden path itself — is CNPE-specific: CNPE is performance-based, so a narrow allowlist (kubernetes.io/docs, kubernetes.io/blog, and any links in a task's own Quick Reference box) stays reachable mid-task. CNPA has no allowlist at all — it's a fully closed-book multiple-choice exam with zero external lookups of any kind, stricter than CNPE, not looser — and no lab component whatsoever, so nothing on this page is something you'd ever build for it. Even so, the concept layer — why audit precedes enforce, what least-privilege RBAC proves, why NetworkPolicy and mTLS operate at different layers — is exactly the kind of concept-level knowledge CNPA's closed-book recall draws on.

⚠ Where you are arriving from, and where you're headed

Arriving: everything Parts 1–5 left behind, all still live on platform-dev: Argo CD in platform reconciling a root App-of-Apps from your platform-capstone repo; the real ledger image built by a Kubernetes-native pipeline and pushed to registry.local/ledger, shipped through an Argo Rollouts canary; a platform CRD or Crossplane Composition (whichever you built in Part 3) reconciling a ledger dependency and self-healing a deleted child; Backstage running with ledger registered in the Software Catalog and a Software Template that scaffolds new services; and kube-prometheus-stack watching ledger with a ServiceMonitor, a golden-signals Grafana dashboard, and a firing PrometheusRule. Leaving this page: the same cluster, now with Kyverno policy enforcing (not just auditing), a scoped tenant Role instead of cluster-admin, default-deny networking, Linkerd mTLS across the mesh, a read Trivy scan report, two fairly-quotaed tenant namespaces with visible per-namespace spend — and, proven with your own hands, one Backstage-scaffolded service that ships through the full chain with nothing hand-applied. There is no Part 7 — this is the finish line.

☺ Explain it like I'm 10

You've built the whole amusement park: the rides (GitOps and the pipeline), the new attraction machine (the platform CRD), the gift-shop kiosk where you order a new ride with one click (Backstage), and the watchtower that watches everyone have fun safely (observability). Today you add the last piece — the safety rules, the wristbands that say which rides you're allowed on, the fences between different school groups visiting on the same day, and a guard checking IDs at every gate. Then, because it's the last day, you press the "one click" button at the kiosk one more time and watch the entire park — ride, fence, wristband, watchtower and all — build itself around the new attraction while you just watch.

🐢🐦Your hosts for this part: Timmy the Turtle & Pip the Hummingbird — Timmy installs every guardrail on this page, and Pip zips the mTLS certificates around the mesh. For the finale, the whole Guild shows up: Benny, Recon, Mira, Ellie, Nutty and Dot all get a cameo, because the golden path is everyone's work landing at once.

What this part assumes and what it produces

☺ Like you're 10: A quick roll call of everything already standing, so nothing below surprises you.

Before touching anything, confirm the state you should already be in. If any of these are missing, that part isn't finished yet — go back before continuing:

FromWhat must already be trueCheck with
Part 1platform-dev cluster; Argo CD in platform; root App-of-Apps reconciling from platform-capstone.kubectl -n platform get applications
Part 2ledger image built by a pipeline, pushed to registry.local/ledger; shipped by an Argo Rollouts canary.kubectl -n ledger get rollout ledger
Part 3A custom resource (operator or Crossplane Claim) that provisions and self-heals a ledger dependency.kubectl -n ledger get <your CR kind>
Part 4Backstage running; ledger in the Software Catalog; a Software Template that scaffolds a new service.open the Backstage UI, search "ledger"
Part 5kube-prometheus-stack installed; a ServiceMonitor for ledger; a golden-signals dashboard; a PrometheusRule.kubectl -n platform get servicemonitor,prometheusrule

This part adds one new namespace to the world: storefront, a second tenant standing in for another team on the same cluster, used only for the multi-tenancy exercise later on this page. Every other name — platform-dev, ledger, platform, platform-capstone, registry.local/ledger — is exactly what Part 1 established and every later part kept using.

Kyverno: policy-as-code, audit before enforce

☺ Like you're 10: First the guard just writes down who's breaking the rule; only once everyone's fixed do they start turning people away.

Install Kyverno into platform the same GitOps way everything else in this capstone arrives — commit its Application into apps/ so the root App-of-Apps picks it up:

# apps/kyverno.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: kyverno
  namespace: platform
spec:
  project: default
  source:
    repoURL: https://github.com/kyverno/kyverno.git
    targetRevision: main
    path: charts/kyverno
    helm:
      releaseName: kyverno
  destination:
    server: https://kubernetes.default.svc
    namespace: platform
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [ CreateNamespace=true ]

Commit a policy that requires every pod to run as non-root and forbids the :latest tag — two of the most common accidental holes. Start it in Audit, the way a senior engineer rolls out a new guardrail on a live cluster:

# platform-addons/kyverno/require-non-root-and-pinned-tags.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-non-root-and-pinned-tags
spec:
  validationFailureAction: Audit    # step 1: report only, block nothing yet
  background: true                   # also scan pods that are already running
  rules:
    - name: run-as-non-root
      match:
        any:
          - resources: { kinds: ["Pod"] }
      validate:
        message: "Running as root is not allowed — set runAsNonRoot: true."
        pattern:
          spec:
            securityContext:
              runAsNonRoot: true
    - name: disallow-latest-tag
      match:
        any:
          - resources: { kinds: ["Pod"] }
      validate:
        message: "Images must be pinned to a specific tag, not :latest."
        pattern:
          spec:
            containers:
              - image: "!*:latest"

Commit this into platform-addons/kyverno/ and add an Application for it in apps/, exactly like every other add-on so far. Once it's Synced, read the report — it should already show something, because the Part-1 placeholder and a few early manifests were never written with this rule in mind:

kubectl get clusterpolicyreport -o wide
kubectl get policyreport -n ledger -o wide
# look for POLICY require-non-root-and-pinned-tags, RESULT fail — that's audit doing its job

Fix every violation the report names (add securityContext.runAsNonRoot: true to the ledger Deployment or Rollout, pin the pipeline's image tag), push the fix through Git, and confirm the report clears. Only then flip the switch:

# same file, one line changed and pushed
spec:
  validationFailureAction: Enforce   # step 2: now it blocks

Prove enforcement with a pod that deliberately breaks the rule — it must be rejected at admission, not merely reported:

kubectl run rootcheck --image=nginx:latest -n ledger --dry-run=server -o yaml
# Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
# policy Pod/ledger/rootcheck for resource violation:
# require-non-root-and-pinned-tags: ... running as root is not allowed ...
⚠ Don't leave a policy in Audit forever

Audit mode gives you a runway to fix violations without an outage — it is not a destination. A policy that only reports root pods for months gives false comfort: the dashboard is red, nobody owns fixing it, and on the day of a real incident it blocks nothing. Set a date to flip each new policy to Enforce, and track "policies still in Audit" as debt, exactly as Security & Policy Enforcement warns.

Scoped RBAC for the tenant, never cluster-admin

☺ Like you're 10: The ledger team gets a key that opens exactly their own room — not the master key to the whole building.

Give the ledger workload its own ServiceAccount — never the default one — and give the humans on the ledger team a namespaced Role that lets them operate their own Deployments, Services and ConfigMaps but touches nothing outside ledger and nothing security-sensitive inside it:

# platform-addons/rbac/ledger-tenant.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ledger
  namespace: ledger
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ledger-tenant-dev
  namespace: ledger
rules:
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["argoproj.io"]
    resources: ["rollouts"]
    verbs: ["get", "list", "watch", "patch"]   # patch: promote/abort a canary, nothing more
  - apiGroups: [""]
    resources: ["services", "configmaps", "pods", "pods/log"]
    verbs: ["get", "list", "watch"]
  # deliberately absent: secrets, networkpolicies, rolebindings, and anything cluster-scoped
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ledger-tenant-dev-binding
  namespace: ledger
subjects:
  - kind: Group
    name: ledger-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: ledger-tenant-dev
  apiGroup: rbac.authorization.k8s.io

Set automountServiceAccountToken: false on the ledger Deployment/Rollout's pod spec too, since the ledger workload itself never needs to call the Kubernetes API:

# patch merged into the ledger Rollout's pod template from Part 2
spec:
  serviceAccountName: ledger
  automountServiceAccountToken: false

Prove the ceiling exists by impersonating the tenant identity and trying something outside its Role:

kubectl auth can-i patch rollouts -n ledger --as=system:serviceaccount:ledger:ledger
# yes

kubectl auth can-i delete secrets -n ledger --as=system:serviceaccount:ledger:ledger
# no

kubectl auth can-i get pods -n platform --as=system:serviceaccount:ledger:ledger
# no — the Role is namespaced to ledger, it has no reach into platform
⚠ Never hand out cluster-admin, even "just for now"

The built-in cluster-admin ClusterRole reads every Secret, deletes any namespace, and can disable every guardrail on this page in one command. Gizmo's favourite move is a ClusterRoleBinding to a whole group "so people stop filing tickets" — one line erasing everything you just built. If the ledger team needs something this Role doesn't grant, widen it deliberately and reviewably in Git; never reach for cluster-admin as the fast fix.

Default-deny NetworkPolicy

☺ Like you're 10: Even with ID checks at every door, you brick up every hallway nobody should walk down.

A plain Kubernetes cluster is allow-all: any pod can reach any other. Fix that in ledger with a default-deny baseline, then open only the flows the app actually needs — including DNS, which a naive default-deny silently breaks:

# platform-addons/netpol/ledger-default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: ledger
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: ledger
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-ingress-to-ledger
  namespace: ledger
spec:
  podSelector:
    matchLabels: { app: ledger }
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: platform }
      ports:
        - protocol: TCP
          port: 9898

Prove the blast radius actually shrank — spin up a scratch pod in a third namespace and confirm it cannot reach ledger, then confirm the platform namespace still can:

kubectl create namespace scratch
kubectl run probe -n scratch --rm -it --image=busybox:1.36 --restart=Never -- \
  wget -qO- --timeout=3 http://ledger.ledger.svc.cluster.local
# times out — default-deny is doing its job

kubectl run probe -n platform --rm -it --image=busybox:1.36 --restart=Never -- \
  wget -qO- --timeout=3 http://ledger.ledger.svc.cluster.local
# succeeds — this namespace is explicitly allowed
⚠ NetworkPolicy needs a CNI that enforces it

kind's default CNI (kindnetd) does not enforce NetworkPolicy — apply the manifests above and every rule will be silently ignored. Install Cilium or Calico on platform-dev first (a kind cluster created with --config setting disableDefaultCNI: true, then cilium install, is the cleanest path) or the probe test above will pass for the wrong reason.

Linkerd mTLS across the mesh

☺ Like you're 10: Every service now shows an ID card and whispers through a private tube nobody else can listen to.

Mesh the ledger and platform namespaces with Linkerd — deliberately lighter than Istio, and it turns mTLS on automatically with zero policy YAML for the encryption itself. Install the CLI and the control plane, running linkerd check before and after as the project itself insists on:

curl -sL https://run.linkerd.io/install | sh
linkerd check --pre

linkerd install --crds | kubectl apply -f -
linkerd install | kubectl apply -f -
linkerd check

linkerd viz install | kubectl apply -f -
linkerd check

Mesh the namespace by annotating it, then restart the workload so it picks up the sidecar:

kubectl annotate namespace ledger linkerd.io/inject=enabled
kubectl -n ledger rollout restart deploy/ledger 2>/dev/null || kubectl -n ledger rollout restart rollout/ledger

kubectl -n ledger get pods
# each ledger pod now shows 2/2 containers — the app, plus linkerd-proxy

Confirm mTLS is live and see the identity on every edge — no certificates to generate by hand, Linkerd derives them from each pod's ServiceAccount:

linkerd viz stat deploy -n ledger
linkerd viz edges deploy -n ledger
# SRC          DST      SRC_IDENTITY                         DST_IDENTITY
# platform-*   ledger   platform.serviceaccount.identity...   ledger.serviceaccount.identity...

Tighten it with an explicit authorization so only the platform namespace's callers (your Prometheus, your ingress) may reach ledger at all — the network-layer complement to the RBAC and NetworkPolicy you already added:

# platform-addons/linkerd/ledger-server-authz.yaml
apiVersion: policy.linkerd.io/v1beta3
kind: Server
metadata:
  name: ledger-9898
  namespace: ledger
spec:
  podSelector:
    matchLabels: { app: ledger }
  port: 9898
  proxyProtocol: HTTP/1
---
apiVersion: policy.linkerd.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: ledger-allow-platform
  namespace: ledger
spec:
  targetRef:
    group: policy.linkerd.io
    kind: Server
    name: ledger-9898
  requiredAuthenticationRefs:
    - group: policy.linkerd.io
      kind: MeshTLSAuthentication
      name: platform-callers
---
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata:
  name: platform-callers
  namespace: ledger
spec:
  identities:
    - "*.platform.serviceaccount.identity.linkerd.cluster.local"
⚠ Creating a Server is a live change

The instant a Server exists for a port, everything not explicitly authorized starts getting connection refusals — including probes and scrapers you forgot about. Roll it out in the safe order Linkerd itself recommends: apply the Server with a permissive policy first, watch linkerd viz stat for the workload, then tighten to the AuthorizationPolicy above. Never do this for the first time right before you close your laptop.

Supply-chain: scan the ledger image with Trivy

☺ Like you're 10: Before you eat the sandwich, check the ingredient list for anything that could make you sick.

Scan the exact image your Part-2 pipeline built and pushed to registry.local/ledger. Run it as its own step so a critical finding is visible before anything ships, and again against the live cluster so you know what's actually running:

trivy image --severity CRITICAL,HIGH --ignore-unfixed registry.local/ledger:TAG

trivy image --format json --output ledger-scan.json registry.local/ledger:TAG

# what's actually deployed right now, cluster-wide
trivy k8s --report summary --include-namespaces ledger,platform

Read the findings the way a platform engineer would: a CRITICAL with a fix available is a blocker — bump the base image or the dependency and re-push through the pipeline; a HIGH with no fix yet gets triaged and tracked, not ignored silently. If this were wired into CI (Part 2's pipeline is the natural home for it), a non-zero --exit-code on CRITICAL findings would fail the build before the image is ever pushed — the same "shift left, verify at admission" pattern Security & Policy Enforcement teaches for signing.

◆ Key idea

Scanning answers "is it safe?" — it does not, by itself, stop an unscanned image from running. That's what the Kyverno verifyImages pattern from Security & Policy Enforcement is for: sign with cosign in the pipeline, then require the signature at admission. This page proves the scan step works; wiring a hard admission gate on top of it is the natural next step once you're comfortable with everything above.

Multi-tenancy: a second namespace, fair shares, no starving

☺ Like you're 10: Two school groups share the same playground — a fence keeps them apart, and each gets its own fair pile of swings so one group can't hog them all.

Create storefront as a second tenant namespace standing in for another team on the same cluster, and give both ledger and storefront a ResourceQuota and a LimitRange so neither can starve the other or the cluster:

kubectl create namespace storefront
# platform-addons/tenancy/ledger-quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: ledger-quota
  namespace: ledger
spec:
  hard:
    requests.cpu: "2"
    requests.memory: 2Gi
    limits.cpu: "4"
    limits.memory: 4Gi
    pods: "20"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: ledger-limits
  namespace: ledger
spec:
  limits:
    - type: Container
      default: { cpu: 250m, memory: 256Mi }         # applied if a container sets no limit
      defaultRequest: { cpu: 100m, memory: 128Mi }  # applied if a container sets no request
      max: { cpu: "1", memory: 1Gi }
      min: { cpu: 50m, memory: 64Mi }
---
# platform-addons/tenancy/storefront-quota.yaml — same shape, its own namespace, its own numbers
apiVersion: v1
kind: ResourceQuota
metadata:
  name: storefront-quota
  namespace: storefront
spec:
  hard:
    requests.cpu: "1"
    requests.memory: 1Gi
    limits.cpu: "2"
    limits.memory: 2Gi
    pods: "10"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: storefront-limits
  namespace: storefront
spec:
  limits:
    - type: Container
      default: { cpu: 250m, memory: 256Mi }
      defaultRequest: { cpu: 100m, memory: 128Mi }
      max: { cpu: "1", memory: 1Gi }
      min: { cpu: 50m, memory: 64Mi }

Isolate them at the network layer too — storefront gets its own default-deny, same shape as ledger's from earlier on this page:

# platform-addons/netpol/storefront-default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: storefront
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]

Prove the quota is real by trying to exceed it on purpose:

kubectl -n storefront create deployment hog --image=nginx:1.27 --replicas=20 \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n storefront get events --sort-by=.lastTimestamp | tail -5
# Warning  FailedCreate  replicaset-controller  Error creating: pods "hog-..." is forbidden:
# exceeded quota: storefront-quota, requested: pods=1, used: pods=10, limited: pods=10
⚠ A quota with no LimitRange doesn't do what you think

If a ResourceQuota sets a hard limit on requests.cpu but a pod's containers specify no request at all, the pod is rejected outright rather than getting a default — Kubernetes refuses to guess. The LimitRange above is what supplies that default, so ordinary manifests that never mention resources: still schedule instead of failing admission with a confusing error.

Cost visibility with OpenCost

☺ Like you're 10: Now that everyone shares the same swings fairly, you also want a receipt showing what each group's turn actually cost.

Install OpenCost into its own namespace, GitOps-managed like everything else:

# apps/opencost.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: opencost
  namespace: platform
spec:
  project: default
  source:
    repoURL: https://github.com/opencost/opencost-helm-chart.git
    targetRevision: main
    path: charts/opencost
    helm:
      releaseName: opencost
  destination:
    server: https://kubernetes.default.svc
    namespace: opencost
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [ CreateNamespace=true ]

Point kubectl cost at the plain-OpenCost install (it defaults to Kubecost's namespace and port, so all four flags below matter) and read spend split by namespace — ledger vs storefront, side by side:

kubectl -n opencost port-forward svc/opencost 9003 9090 &

OC="--kubecost-namespace opencost --service-name opencost --service-port 9003"
kubectl cost namespace $OC --window 24h --show-efficiency
# NAMESPACE     CPU CORE-HRS   RAM GiB-HRS   TOTAL COST   EFFICIENCY
# ledger        ...            ...           $x.xx        ...
# storefront    ...            ...           $y.yy        ...
# platform      ...            ...           $z.zz        ...

That output is the tenancy story made visible in dollars: two isolated, fairly-quotaed namespaces, and now a number showing exactly what each one is spending — the same allocation model OpenCost teaches, now pointed at the tenants you just built.

The finale: wiring Parts 1–5 into one golden path

☺ Like you're 10: Press the "new ride" button at the kiosk once, and watch the entire park — track, fence, wristband and watchtower — build itself around it while you just watch.

Everything above stands alone, but the point of a capstone is that it doesn't stay standing alone. Here is the chain, named once, end to end — this is the sentence to be able to say out loud in the exam or an interview:

🦆 Dot picks a Software Template 🦋 Backstage scaffolds repo, opens PR 🤖 Argo CD root reconciles the PR merge 🦫 Pipeline builds & pushes the image 🐦 Canary 10% → 50% → 100% 🦋 Platform CRD provisions the dependency 🐘 Dashboards ServiceMonitor auto-attached 🐢 Guardrails policy, RBAC, netpol, mTLS zero hand-written infrastructure — one self-service action, everything else automatic

Making the template carry the guardrails

The trick that closes the loop is: the guardrails on this page must live inside the Backstage Software Template from Part 4, not be something a human remembers to add afterwards. Extend that template's scaffolded output so every new service's generated ledger/-shaped folder already includes its own NetworkPolicy (default-deny + the narrow allow), its own ResourceQuota/LimitRange, its own scoped ServiceAccount and Role, the linkerd.io/inject: enabled namespace annotation, and a ServiceMonitor pointed at itself — plus an apps/<service>.yaml Application so the root App-of-Apps picks it up without you ever running kubectl apply:

# skeleton/apps/${{values.name}}.yaml — part of the Backstage Software Template's output,
# rendered once per scaffolded service and committed straight into platform-capstone/apps/
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: ${{values.name}}
  namespace: platform
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/YOU/platform-capstone.git
    targetRevision: main
    path: ${{values.name}}
  destination:
    server: https://kubernetes.default.svc
    namespace: ${{values.name}}
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [ CreateNamespace=true ]

Running the golden path live

Now be Dot. Open Backstage, pick the Software Template from Part 4, and scaffold a brand-new service — call it ledger-reports — through the portal, touching no kubectl, no Git client, no YAML editor:

# after clicking "Create" in Backstage and merging the PR it opened, watch the chain fire:
kubectl -n platform get applications -w
# ledger-reports   Synced   Healthy      <- appeared with no kubectl apply from you

kubectl -n ledger-reports get networkpolicy,resourcequota,limitrange,serviceaccount,rollout,servicemonitor
# every guardrail and every observability hook already present — the template put them there

kubectl -n ledger-reports get rollout ledger-reports -w
# 10% -> 50% -> 100%, the exact canary shape from Part 2, with no one hand-configuring it

kubectl auth can-i delete secrets -n ledger-reports --as=system:serviceaccount:ledger-reports:ledger-reports
# no — scoped from birth, the same as ledger's Role earlier on this page

If any one of those checks fails, the platform isn't finished, not the exercise — go back and add the missing piece to the template until scaffolding a service really does produce all of it, automatically, every time.

🦆 Dot's-eye view

"This is the whole course in one afternoon, from my side of the desk: I picked a template, answered three form fields, and clicked once. I didn't write a Dockerfile, a NetworkPolicy, a ResourceQuota, or a Rollout. I didn't ask anyone for access. Twenty minutes later I had a running, mTLS-secured, policy-checked, dashboarded service — and the only thing I ever touched was a form. If you remember one sentence from this entire capstone, remember that this is what 'the platform is the product' actually feels like from the outside."

What "done" looks like for the whole capstone

☺ Like you're 10: A cluster that fixes itself, ships safely, grows new attractions by itself, watches for trouble, and now guards every gate — six chapters, one finished park.

At the end of this part your platform-dev cluster carries everything all six parts built: a self-reconciling GitOps core (Part 1), a real pipeline and canary delivery (Part 2), a self-healing platform API (Part 3), a self-service portal that scaffolds fully-wired services (Part 4), a working observability stack with a triaged incident behind it (Part 5), and now — this page — policy-as-code in enforce mode, scoped RBAC, default-deny networking, mesh mTLS, a read supply-chain scan, fair multi-tenant quotas across two namespaces with visible cost, and proof that one self-service action produces all of the above with zero hand-written infrastructure. That is the golden path the entire course has been building toward, and you built it with your own hands.

🎬 At the Platform Guild
🦊

Foxy: Six parts, dozens of manifests — be honest, did we actually need all of it just to ship one little ledger service?

🦫

Benny: Not for one service, no. But Dot didn't ship one service — she scaffolded ledger-reports in a single click, and it arrived with a canary, a database, a dashboard, and every guardrail on this page already attached. The work was building the road, once.

🐢

Timmy: And the guardrails didn't slow her down — they were already baked into the template before she ever opened the portal. That's the difference between security as a gate and security as a default.

👺

Gizmo: Or — hear me out — skip the template, skip the policies, and just hand every new team cluster-admin. Ship day one, worry never. 😈

🤖

Recon: BEEP. And then I'd have nothing to reconcile against, Gizmo, because there'd be no Git, no desired state, and no guardrails to enforce. You'd have Ticket Swamp again by lunchtime.

🦆

Dot: I'll say the only thing that actually matters: I clicked once, and twenty minutes later I had a real, safe, observed service. That's not a demo. That's the job, done right.

Milestones

☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen — including the very last one, which is the whole capstone proving itself.

Work these in order — each depends on the cluster and repo state from the one before, and from every part before this one. Progress saves in this browser.

0 / 14 milestones complete
1Install Kyverno and roll out a policy in Audit
Add apps/kyverno.yaml and the require-non-root-and-pinned-tags ClusterPolicy with validationFailureAction: Audit.
Done when: kubectl get clusterpolicyreport -o wide shows results, without anything being blocked.
2Fix every reported violation, then flip to Enforce
Patch the ledger workload's securityContext and pin its image tag through Git, confirm the report clears, then push validationFailureAction: Enforce.
Done when: a deliberately root, :latest-tagged test pod is rejected at admission with a message naming your policy.
3Scope a tenant ServiceAccount and Role for ledger
Apply the ledger ServiceAccount, ledger-tenant-dev Role, and its RoleBinding. Set automountServiceAccountToken: false on the workload.
Done when: kubectl auth can-i patch rollouts -n ledger --as=system:serviceaccount:ledger:ledger says yes and delete secrets says no.
4Install a policy-enforcing CNI and confirm default-deny actually bites
Install Cilium (or Calico) if your kind cluster's default CNI doesn't enforce NetworkPolicy.
Done when: kubectl get pods -n kube-system shows your CNI's pods Running.
Concept: Cilium
5Apply the default-deny and DNS-allow NetworkPolicy for ledger
Apply default-deny-all, allow-dns, and allow-ingress-to-ledger from the manifests above.
Done when: a probe pod in scratch cannot reach ledger, but one in platform can.
Concept: NetworkPolicy
6Install Linkerd and mesh the ledger namespace
linkerd install --crds, linkerd install, linkerd viz install, then annotate ledger with linkerd.io/inject: enabled and restart the workload.
Done when: kubectl -n ledger get pods shows 2/2 containers per pod and linkerd check is fully green.
Concept: Linkerd
7Confirm mTLS and add a Server + AuthorizationPolicy
linkerd viz edges deploy -n ledger to see the identity on each edge, then apply the Server/AuthorizationPolicy/MeshTLSAuthentication trio, permissive first.
Done when: edges show a real mTLS identity on both sides, and linkerd viz stat stays healthy after tightening to platform-callers only.
8Scan registry.local/ledger with Trivy
trivy image --severity CRITICAL,HIGH --ignore-unfixed registry.local/ledger:TAG, then trivy k8s --report summary --include-namespaces ledger,platform.
Done when: you have a scan report and can name, out loud, whether any CRITICAL finding has a fix available.
9Create storefront and quota both tenants
Create the namespace, then apply the ResourceQuota + LimitRange pair for both ledger and storefront, plus storefront's own default-deny NetworkPolicy.
Done when: kubectl -n storefront get resourcequota,limitrange and kubectl -n ledger get resourcequota,limitrange both show your values.
Concept: Multi-tenancy
10Prove the quota rejects an over-large deployment
Try to create 20 replicas in storefront against its 10-pod quota.
Done when: kubectl -n storefront get events shows a FailedCreate naming exceeded quota.
Concept: Multi-tenancy
11Install OpenCost and read per-namespace spend
Add apps/opencost.yaml, port-forward, and run kubectl cost namespace with the plain-OpenCost flags.
Done when: the output lists ledger, storefront, and platform with distinct cost figures.
Concept: OpenCost
12Extend the Backstage template to carry every guardrail
Update Part 4's Software Template so its scaffolded output includes a NetworkPolicy pair, ResourceQuota/LimitRange, a scoped ServiceAccount/Role, the Linkerd inject annotation, a ServiceMonitor, and an apps/<service>.yaml Application.
Done when: reading the template's skeleton files, you can point at exactly where each guardrail from this page is generated.
13Run the golden path live: scaffold ledger-reports
In Backstage, scaffold a new service from the template. Merge the PR it opens. Touch no kubectl apply.
Done when: kubectl -n platform get applications lists the new Application Synced/Healthy with no command from you beyond merging the PR, and its namespace already has a NetworkPolicy, ResourceQuota, LimitRange, ServiceAccount, and a canary Rollout all present.
Concept: the whole capstone, chained
14Say out loud what you built across all six parts
Describe, without looking anything up: the reconciliation loop, the canary delivery path, the platform CRD's self-healing, the self-service scaffold, the golden-signals dashboard and the incident you triaged, and every guardrail on this page.
Done when: you can tell that story in under two minutes — that fluency is exactly what the performance-based CNPE rewards.
🐢 Timmy's checkpoint

1. Why roll a new Kyverno policy out in Audit before Enforce, and what's the risk of leaving it in Audit forever? 2. Name the tenant Role's ceiling proved by kubectl auth can-i in this part. 3. Why does a default-deny NetworkPolicy need an explicit DNS-allow rule alongside it? 4. What does Linkerd's mTLS add on top of a NetworkPolicy that already restricts which pods may connect? 5. What must supply-chain scanning be paired with at admission to actually stop an unsafe image from running? 6. What is the single sentence that describes this whole capstone's payoff?

Check your answers
  1. Audit lets you see every violation on a live cluster and fix them without an outage; the risk of leaving it in Audit forever is false safety — violations are recorded but nothing is blocked, so on incident day the policy stops nothing.
  2. The ledger ServiceAccount can patch rollouts and read core objects in its own namespace, but cannot delete Secrets, touch RoleBindings, or reach the platform namespace at all — least privilege, proven, not assumed.
  3. A default-deny NetworkPolicy with Egress in its policyTypes also blocks DNS lookups (typically UDP/TCP port 53 to the cluster's DNS pods), so without an explicit allow rule every service lookup inside the namespace fails.
  4. NetworkPolicy decides which traffic may exist at all at L3/4; mTLS encrypts the traffic that is allowed and proves each side's cryptographic identity — the two operate at different layers and you want both.
  5. Something must verify the scan/signature at admission — a scan report or signature nobody checks is a locked door with the key left in it. Kyverno's verifyImages (or the Sigstore policy-controller) is the enforcement half.
  6. One self-service action — scaffolding a new service in Backstage — produces a running, canary-delivered, dependency-provisioned, dashboarded, and fully guardrailed service with zero hand-written infrastructure. That is the golden path.

That's the capstone, start to finish. Part 6 added the guardrails and, more importantly, proved every earlier part's work compounds into one automatic golden path. Revisit Security & Policy Enforcement for the concepts behind everything on this page, step back to the Capstone Hub to see how all six parts fit together, or retrace the whole build from Part 1 — Foundation through Part 5 — Observability. When you're ready, take the muscle memory into the Exam Guide and the exam-prep checklist.