01

The App-Scoped Infrastructure Bottleneck

Application code ships in minutes through self-service GitOps; the cloud infrastructure that code depends on still crawls through a platform team's pull-request queue. This first day frames the two problems that motivate the whole course — a *velocity* problem (every app-scoped bucket, role, or identity is a platform round-trip) and a *security* problem (every service shares one cloud identity, so the blast radius is the whole fleet) — and the single idea that fixes both: let teams **claim** their own app-scoped cloud resources declaratively, and give each service its **own least-privilege cloud identity**.

Customer outcome and implementation focus

An application team should request a bucket or workload identity by merging a bounded claim, not by waiting for a privileged human to translate a ticket. The implementation separates the application’s desired resource from the platform controller that turns it into cloud state; the controller never becomes a shared application credential.

Story IDUser storyObservable acceptance
D01-US-01As an application team, I want to declare an app-scoped resource, so that delivery is self-service without broad cloud access.The claim reconciles to a named cloud resource and its status links to the provider operation.
D01-US-02As a platform owner, I want each workload to use a separate identity, so that one compromised service cannot inherit fleet access.A denied cloud call from service A does not prevent service B’s permitted call.

Components in focus

The bottleneck exists because one human-owned IaC process owns both foundation and app work. The replacement has explicit compute and state ownership.

LayerOwner and componentCompute/runtimeDurable stateEvidence
DeliveryGitOps controller, platform teamController podsGit revision and Kubernetes API objects in etcdSync revision and live-object diff.
ProvisioningCrossplane/provider, platform teamController pods with provider API clientsComposite/managed-resource status in etcdReady condition plus provider operation ID.
CloudCloud providerProvider control planeBucket/IAM policy in provider databaseCloud inventory and audit event.
WorkloadApplication teamDeployment podsApplication database/cache remain application-owned; neither stores cloud credentialsWorkload identity audit event and application response.

Implement the ownership handoff

A claim must name an app-owned resource while the platform retains the controller credentials. This minimal shape makes the desired state reviewable without embedding a cloud key in the application repository.

apiVersion: platform.example.com/v1alpha1
kind: XBucket
metadata: { name: exports, namespace: document-processing }
spec: { parameters: { owner: document-platform-team, retention: 30d } }

Declared intent: request one bounded bucket. Interpreter: GitOps applies the object; Crossplane’s composition controller writes managed-resource objects and the provider calls the cloud API. Software effect: status converges in etcd and the provider creates the bucket. Hardware effect: provider control-plane storage and bucket capacity are consumed, not application-pod disk. Evidence: the claim’s Ready=True condition must agree with provider audit and cloud inventory.

The two-speed platform

On a mature Kubernetes platform, application delivery is already fast and self-service: a team merges a change, a GitOps engine reconciles it, and the workload rolls out without anyone filing a ticket. But the infrastructure that workload needs — a storage bucket, permission to read a queue, a cloud identity for a brand-new service — travels a slower, human-gated road. The team that knows what the service needs files a request; the platform team turns it into an infrastructure-as-code change only they can write and merge; the work waits until they reach it.

App code path:   write code → merge → GitOps reconciles → live      (minutes, self-service)
Infra path:      file ticket → platform writes IaC PR → review → apply   (days, gated)

The two lifecycles are out of step. This is not a tooling failure so much as an ownership mismatch: the people with the context are not the people with the credentials, so every app-scoped resource becomes a round-trip through a team that lacks the context and has its own backlog. Rule of thumb: when the thing blocking a deploy is a human in another team's queue rather than a check that must pass, you have a self-service gap, not a safety control — and the cost compounds with every new service.

The security half: one identity to rule them all

The velocity story has a quieter twin. Many platforms grow up with one shared cloud identity that every workload assumes — one GCP service account, or one AWS IAM role, wired to every pod. It is trivial to set up and disastrous under attack, because the shared identity necessarily holds the union of every service's permissions. Compromise any single workload and you inherit the access of all of them.

This is the second problem the course solves, and it turns out to have the same root cause: cloud identity is provisioned out-of-band from the app, so nobody mints a fresh identity per service — they just reuse the one that already exists. Rule of thumb: a shared workload identity makes the blast radius of any compromise equal to the most-privileged thing any workload can do; per-service identity shrinks each blast radius to one service's real needs.

Where this sits in a longer arc

Per-service identity usually arrives in two moves, and most platforms have only made the first.

Move one is in-cluster identity separation: each workload gets its own Kubernetes ServiceAccount and a SPIFFE identity for mesh traffic. A workload-identity binding — IRSA on AWS or Workload Identity on GCP — connects that ServiceAccount to the cloud.

What "in-cluster" means in hardware and software

In-cluster does not mean inside one machine, one process, or a special piece of Kubernetes hardware. It means the software runs as a Kubernetes-managed workload and is represented in that cluster's API. A cluster is a control plane plus one or more worker nodes. A node is an ordinary physical server or virtual machine; its CPU executes container processes, its RAM holds their working memory, its disks hold image layers and ephemeral data, and its network interface carries Pod and API traffic. Kubernetes components Kubernetes nodes

Start at the machine and move upward:

The hardware supplies finite resources. The operating system isolates processes; the container runtime pulls the image and starts the container; the kubelet watches the Pod specification and keeps the requested containers running. A Pod is therefore not another physical machine—it is a Kubernetes object whose containers become ordinary processes on a node. The scheduler first checks node capacity, then binds the Pod to a suitable node. Kubernetes documents that nodes may be physical or virtual and that the scheduler keeps requested CPU and memory within reported capacity. Kubernetes nodes and capacity

Now add the control plane and the boundary that "in-cluster" describes:

The control plane may run on machines you operate or on provider-managed machines you never see. Either way, it stores desired state, schedules workloads, and exposes the cluster API; worker nodes execute the application processes. "In-cluster" is therefore a management and trust boundary, not necessarily a rack or network boundary. Two Pods on different worker machines are both in the same cluster, while a cloud IAM role or S3 bucket is outside the cluster even when the Pod can call it over the network.

Layerdocument-export-service realityEvidence
HardwareA physical host or VM supplies CPU, RAM, disk, and networkingkubectl describe node shows capacity and allocatable resources
Node softwarekubelet and the container runtime turn a Pod spec into running processesPod is Running; container ID and assigned node are visible
Workloaddocument-export-service runs inside a Pod on that nodekubectl -n document-processing get pod -o wide shows Pod IP and node
Cluster stateAPI server and etcd hold the Pod, Deployment, and ServiceAccount objectskubectl -n document-processing get deploy,pod,serviceaccount returns them
Outside serviceThe process calls a cloud API across the cluster boundaryCloud audit logs and the application's success or AccessDenied response
# Software placement: which worker machine is executing the Pod?
kubectl -n document-processing get pod -l app=document-export-service \
  -o custom-columns='POD:.metadata.name,NODE:.spec.nodeName,IP:.status.podIP'

# Hardware budget exposed through the Node object.
kubectl get node -o custom-columns='NODE:.metadata.name,CPU:.status.allocatable.cpu,MEMORY:.status.allocatable.memory'

An in-cluster controller such as Crossplane is consequently just another set of Pods using node CPU, memory, and networking, plus permissions to watch and update Kubernetes objects. Its special power comes from its reconciliation logic and credentials, not from special hardware. It reads desired state from the API, calls an external cloud API, and writes observed status back. If its Pod disappears, Kubernetes can start a replacement; if its permissions or provider credentials are wrong, it may run perfectly at the hardware level while reconciliation remains Ready=False.

Move two is cloud-side identity separation: the cloud IAM principal behind that ServiceAccount also becomes per-service. This last link is the one the course changes.

What a Kubernetes ServiceAccount actually means

A Kubernetes ServiceAccount is a named, non-human identity for software. It is an API object inside one namespace, so document-processing/document-export-service and sandbox/document-export-service are two different identities even though their final names match. A Pod does not become a Linux user or a cloud account when it selects one. Kubernetes records that the Pod is acting as a subject with the canonical name system:serviceaccount:document-processing:document-export-service, and can project a short-lived, rotating token into the Pod as evidence of that identity. If spec.serviceAccountName is omitted, Kubernetes assigns the namespace's default ServiceAccount. Kubernetes ServiceAccounts

Keep four things separate:

ThingQuestion it answersdocument-export-service example
ServiceAccount objectWho is this workload inside Kubernetes?document-processing/document-export-service
Projected tokenHow can the Pod prove that identity?Short-lived signed token mounted by the kubelet
Kubernetes RBACWhat may that identity do to the Kubernetes API?Perhaps read one ConfigMap; often nothing
Cloud workload-identity binding + IAMWhich cloud identity may it obtain, and what may that identity do?Assume document-export-service's role and access only its export bucket

The ServiceAccount supplies identity, not permission by itself. A RoleBinding can authorize that identity against the Kubernetes API. Separately, IRSA on AWS, EKS Pod Identity, or Workload Identity Federation on GCP can trust the same namespaced identity and deliver short-lived cloud credentials. AWS describes the mapping as a Kubernetes ServiceAccount to an IAM identity; GCP represents the KSA as a principal in a workload identity pool. Amazon EKS workload identities GKE Workload Identity Federation

The smallest useful model is therefore workload -> Kubernetes identity -> authorised API:

The API server creates the ServiceAccount object; the ServiceAccount admission controller and kubelet arrange the projected credential; the API server authenticates its signature, expiry, bound object, and audience; then RBAC makes a separate authorization decision. Creating the identity consumes negligible data-plane hardware by itself. The observable proof is the Pod's selected ServiceAccount, the token projection, and a positive or denied API request matching the intended RBAC policy.

# Identity proof: which ServiceAccount did the Deployment give its Pods?
kubectl -n document-processing get pod -l app=document-export-service \
  -o jsonpath='{.items[0].spec.serviceAccountName}{"\n"}'

# Authorization proof: may that exact identity read Secrets in its namespace?
kubectl auth can-i get secrets -n document-processing \
  --as=system:serviceaccount:document-processing:document-export-service

The second diagram adds the cloud boundary without changing the Kubernetes identity path:

The cloud provider, not Kubernetes RBAC, interprets the final permission. The bridge validates or exchanges workload identity evidence, the cloud security-token service issues temporary credentials, and the SDK uses them for a provider API call. The request then consumes application CPU and network plus the cloud service's storage or compute. The proof must come from both sides: document-export-service succeeds against its own export bucket, while a neighbouring ServiceAccount receives AccessDenied.

Before: an implicit Kubernetes identity ends at one shared cloud role

In the unsafe starting point, each Pod silently receives the namespace's default ServiceAccount because the Deployment never names one. That identity may then be mapped—directly or through node credentials—to the fleet's shared cloud role. The default ServiceAccount is not inherently privileged; the danger is the broad cloud identity behind it.

# Before: omission means "use document-processing/default".
apiVersion: apps/v1
kind: Deployment
metadata:
  name: document-export-service
  namespace: document-processing
spec:
  template:
    spec:
      containers:
        - name: api
          image: registry.example.com/document-export-service:1.4.0
      # No serviceAccountName: Kubernetes assigns document-processing/default.

Compromising either Pod now exposes the same role. The role must carry the union of permissions, so the identity boundary between the services exists in Kubernetes but disappears at the cloud boundary.

After: an explicit identity maps to one least-privilege cloud role

The safe shape creates and selects a dedicated ServiceAccount. The claim shown later asks the platform to mint a cloud identity whose trust subject is exactly system:serviceaccount:document-processing:document-export-service; it does not grant every ServiceAccount in document-processing access.

# After: the workload identity exists explicitly in the same namespace as the Pod.
apiVersion: v1
kind: ServiceAccount
metadata:
  name: document-export-service
  namespace: document-processing
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: document-export-service
  namespace: document-processing
spec:
  template:
    metadata:
      labels:
        app: document-export-service
    spec:
      serviceAccountName: document-export-service # selects document-processing/document-export-service, not document-processing/default
      containers:
        - name: api
          image: registry.example.com/document-export-service:1.4.0

The manifest alone does not create cloud permission. It creates the stable Kubernetes subject that the binding can trust. The later XRole claim creates the other half: a distinct cloud identity, an allowlisted permission policy, and an exact trust relationship back to this ServiceAccount. Decision rule: use one ServiceAccount per distinct permission boundary, always select it explicitly, and treat Kubernetes RBAC and cloud IAM as two independent authorization systems that happen to trust the same workload identity.

LayerCommon state todayWhat's still shared
Kubernetes ServiceAccountPer-service (delivered by an earlier identity project)
Mesh / SPIFFE identityPer-service
Cloud IAM identity (GCP SA / AWS role)One, shared by allThis is the gap

The binding mechanism from ServiceAccount to cloud identity already exists; it is simply pointed at one shared identity. Filling that binding with a distinct least-privilege identity per service is the concrete step that finishes the arc — and doing it through a self-service claim is what keeps it from re-creating the bottleneck. Rule of thumb: don't rebuild identity plumbing you already have; find the last shared link in the chain and make that per-service.

What "app-scoped" means (and what stays put)

The fix is emphatically not "let every team provision everything." It works only because cloud resources fall into two very different classes, and only one of them belongs in a self-service path.

ClassExamplesLifecycle & sharingProvisioned by
FoundationVPC, subnets, accounts/projects, DNS zones, shared keys, base IAMLong-lived, shared, part of the security perimeterTerraform (platform-owned, gated)
App-scopedA service's own bucket, its own IAM role/identity, its own cacheCreated & destroyed with one service, referenced only by itSelf-service claim (this course)

Terraform keeps the foundation; the self-service layer takes only the app-scoped tier. The two never own the same cloud object. That clean split is the precondition for everything that follows, and Day 03 turns it into an enforceable test. Rule of thumb: self-service is safe exactly where the resource's lifecycle matches one app's lifecycle; the moment a resource is shared or perimeter-level, it belongs back in the gated foundation.

Start with the ownership question, not the resource type

"Bucket" is not a useful classification by itself. A bucket holding one service's generated document exports may be app-scoped; a bucket holding company-wide audit logs is foundation. The cloud API is the same, but the ownership and failure consequences are not.

The vertical line in the diagram is the important one: a lifecycle boundary, not a permission free-for-all. An app team can request a resource only on its side of the line. The platform team still decides the cloud account, region, encryption defaults, network placement, provider credentials, and which kinds of claim are available. A claim is a request through a narrow, platform-authored API; it is not cloud-console access.

What the foundation code continues to own

Here is a deliberately small Terraform fragment for an AWS foundation. It creates the shared network and exposes its ID as an output. It does not create an application's bucket or IAM role.

# foundation/main.tf -- platform-owned and reviewed separately
resource "aws_vpc" "platform" {
  cidr_block           = "10.40.0.0/16"
  enable_dns_hostnames = true

  tags = {
    "platform.example.com/owner" = "platform"
    "platform.example.com/tier"  = "foundation"
  }
}

resource "aws_kms_key" "shared" {
  description         = "Shared platform encryption key"
  enable_key_rotation = true
}

output "platform_vpc_id" {
  value = aws_vpc.platform.id
}

What makes this foundation: Terraform reads this declaration, records the VPC and KMS key in its state, and reconciles those long-lived cloud objects. The VPC consumes provider network capacity; the key becomes part of the shared encryption perimeter. terraform plan must show only these foundation objects, and terraform state list must contain aws_vpc.platform and aws_kms_key.shared. The state-to-object binding is why a second reconciler must never manage the same object; Terraform's own model expects one configured resource instance for each remote object. Terraform state documentation

The output is an intentional one-way interface: later platform configuration can refer to a foundation fact such as the VPC ID, but an application claim cannot alter the VPC. This is the same shape as an operating system exposing a file handle without giving a process permission to reconfigure the disk controller.

What a team is allowed to request

Now take a service named document-export-service. It renders customer documents into downloadable formats and stores the resulting artifacts in its own bucket. The team commits a small XRole claim beside its Kubernetes workload. XRole is a custom API supplied by the platform; Day 05 defines its schema and Composition. The claim has no account ID, VPC ID, raw IAM statement, or provider credential because those are foundation facts and platform decisions.

# apps/document-export-service/identity.yaml -- application-team owned
apiVersion: platform.example.com/v1alpha1
kind: XRole
metadata:
  name: document-export-service
  namespace: document-processing
  labels:
    platform.example.com/owner: document-platform-team
spec:
  owner: document-platform-team
  serviceName: document-export-service
  serviceAccountName: document-export-service
  access:
    - export-storage-read-write # named, reviewed capability; not raw IAM JSON

The same workload selects its exact Kubernetes ServiceAccount:

# apps/document-export-service/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: document-export-service
  namespace: document-processing
spec:
  template:
    spec:
      serviceAccountName: document-export-service # matches the claim's trust subject
      containers:
        - name: api
          image: registry.example.com/document-export-service:1.4.0

What this changes in reality: GitOps applies the XRole object to the Kubernetes API server. Crossplane observes that desired state and its platform-authored Composition creates a cloud IAM identity whose trust is bound to document-processing/document-export-service; it expands export-storage-read-write into only the permitted storage actions. The pod then obtains cloud credentials through its workload-identity binding, rather than inheriting a fleet-wide credential. No VPC, shared key, DNS zone, or cloud account is created or edited by either manifest.

The hardware effect is indirect but real: the controller makes provider API calls that store an IAM principal and policy in the cloud control plane; the application pod uses CPU and network only when it exchanges its Kubernetes identity for short-lived credentials and calls the bucket service. Evidence is not a green Git commit: inspect the claim's readiness and test the service's actual access.

# Healthy state: the controller has created and observed the app identity.
kubectl -n document-processing get xrole document-export-service \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'

# Runtime proof: this pod can reach only its own app-scoped storage path.
kubectl -n document-processing exec deploy/document-export-service -- \
  aws s3api head-bucket --bucket xp-app-prod-document-processing-document-export

Expected evidence is True for the claim's Ready condition and a successful bucket check from document-export-service. document-preview-service should receive AccessDenied for that bucket. That negative result is a success condition: it proves the identity is not shared.

One request path, two separate ownership planes

The app team gets a faster path without receiving the platform team's blast radius:

The first path is deliberately unchanged: Terraform owns and changes the foundation. The second path is constrained: the team owns the request, while the platform owns the template that interprets it. That division is what lets the platform set mandatory tags, a fixed cloud account, a permissions boundary, and an approved region without asking each team to understand or safely reproduce them.

This fuller view adds two facts without moving the boundary. Git provides review and desired state; the Composition turns only approved claim fields into provider resources. The dotted edge is intentionally one-way. Foundation data may inform the template, but the claim never reaches back to mutate foundation objects.

A bounded failure and the recovery loop

Use a deliberately invalid claim to prove that the boundary is enforced before introducing it to a real service. This claim asks for a raw permission instead of one of the catalog's named capabilities:

# This must be rejected: raw provider permissions are not part of the claim API.
apiVersion: platform.example.com/v1alpha1
kind: XRole
metadata:
  name: document-export-unrestricted
  namespace: document-processing
spec:
  owner: document-platform-team
  serviceName: document-export-service
  serviceAccountName: document-export-service
  access:
    - "s3:*" # invalid: not an allowlisted capability profile

The API server or claim validation should reject this object, or the claim should settle Ready=False with a clear validation reason, depending on where the policy runs. Either way, verify two things: no IAM role was created for document-export-unrestricted, and Terraform's state remains unchanged. Correct the claim to the named capability, let GitOps reconcile again, and verify Ready=True. This is the essential control loop: declare, observe rejection, correct, reconcile, verify.

Edge cases that return to Terraform

The safest classification is conservative. A service-specific cache is normally app-scoped; a Redis cluster shared by checkout, catalogue, and recommendations is foundation even if one team originally requested it. Likewise, an app role can be self-service, but a policy on a shared KMS key remains Terraform-owned because the key is a foundation object. The app requests access; the platform grants it from the foundation side. Day 03 makes that cross-boundary case enforceable.

QuestionIf yesDecision
Would deleting this service delete the resource?It has the service's lifecycleContinue the test
Does any other service, team, or perimeter control depend on it?It is shared or security-criticalFoundation: Terraform
Can the platform expose it through a small allowlisted API?The request is bounded and auditableApp-scoped: claim
Is the answer uncertain?The ownership boundary is unclearFoundation by default; escalate

Reusable decision rule: give self-service only to resources that have one application's lifecycle, one application's references, and a narrow platform API. If any of those conditions stops being true, return the object to the gated foundation plane. That preserves the useful promise: app teams move quickly, while the security perimeter stays intentionally boring.

The shape of the solution

The rest of the course builds one system that resolves both problems at once. An in-cluster controller watches for small, high-level claims committed in each service's own config; a platform-authored composition turns each claim into the correct provider-specific cloud resources — including a per-service least-privilege identity — with guardrails baked in; and the same GitOps flow that ships the app now ships its infrastructure.

Everything from here is detail on that picture: which controller to run and why (Day 02), where the Terraform boundary sits (Day 03), how to stand the controller up (Day 04), how to design the claim API (Days 05–06), how to make it safe in GitOps (Day 07), and how to roll it from a two-cluster pilot to production without a big-bang migration (Day 08). Rule of thumb: the win is not "Kubernetes provisions cloud resources" — plenty of tools do that; it is that a reviewed, allowlisted claim replaces both a ticket and a shared credential in one move.

Before and after, side by side

The old path hides two different services behind one default Kubernetes identity and one shared cloud role, so either workload can inherit permissions intended for the other. The target path gives document-export-service and document-preview-service separate identities, roles, and storage boundaries, making both successful access and denied cross-service access observable.

Read the left side as one transitive trust path: choosing no ServiceAccount eventually means sharing cloud authority. Read the right side as two independently revocable paths; the decisive proof is that each service reaches its own storage while receiving AccessDenied from the other service's storage.

Key takeaways

  • A modern platform is two-speed: app delivery is self-service and fast, app-scoped infrastructure is ticket-gated and slow; the gap is an ownership mismatch, not a tooling one.
  • The same out-of-band provisioning that causes the delay also leaves every service on one shared cloud identity, making the blast radius of any compromise the whole fleet.
  • Per-service identity is a two-move arc; most platforms have done the in-cluster move (per-service ServiceAccount) but not the cloud-side move — the shared cloud IAM identity is the last link.
  • Only app-scoped resources (single-app lifecycle) go self-service; foundation resources (shared, perimeter, long-lived) stay in gated Terraform, and the two never co-own an object.
  • The solution is a claim + composition model on a GitOps flow that fixes velocity and security together — a reviewed claim replaces both a ticket and a shared credential.

Checklist

  • [ ] I can describe the two-speed platform problem and say why it is an ownership mismatch rather than a tooling failure.
  • [ ] I can explain why a single shared cloud identity makes the blast radius of any compromise equal to the whole fleet.
  • [ ] I can name the two moves of the per-service identity arc and identify which one most platforms have skipped.
  • [ ] I can classify a resource as foundation vs app-scoped and say which provisioning path each takes.
  • [ ] I can sketch the claim → composition → cloud-resource flow and say what problem each part solves.