Skip to main content

Kubernetes / EKS → AWS Configured Authority

Status: TARGET STATE. This page describes a design that is decided, not a description of what runs today. Every claim below is marked SHIPPED, TARGET or HELD. The SHIPPED rows carry file:line citations into sv0-platform and sv0-connectors; the TARGET rows carry none, because there is nothing to cite yet.

The vocabulary used here — approved, applied, verified, and the Observed → Decided → Approved → Applied → Verified rail — is defined once in ADR-034. This page uses those definitions and does not restate them.

Context

The largest cross-plane authority gap in an AWS-plus-Kubernetes estate is the hop from a Kubernetes ServiceAccount into an AWS IAM role. It is invisible from either side alone: the cluster sees a ServiceAccount with an annotation, AWS sees a role with a federated trust policy, and neither shows what the identity can actually reach.

The finding that shapes this design: the hop lives entirely in AWS. For IAM Roles for Service Accounts (IRSA), the IAM role's trust policy names the Kubernetes namespace and ServiceAccount directly in its OIDC sub condition:

{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:sub":
"system:serviceaccount:payments-risk/txn-scoring-runner",
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:aud":
"sts.amazonaws.com"
}
}
}

iam:GetRole returns this document. No cluster credential is required, and no new AWS permission is required for this half. IAM roles, policies and OIDC providers are free AWS objects.

Reference: AWS — Assign IAM roles to Kubernetes service accounts.

What this proves, and what it does not

This distinction is load-bearing and must survive into the UI, the evidence pack and anything said out loud to a customer.

QuestionAnswered from AWS reads alone?
Which ServiceAccount may assume which IAM role (IRSA)Yes — trust policy :sub
Which ServiceAccount is bound to which IAM role (Pod Identity)Yes — EKS associations
Which IAM principals can reach the cluster APIYes — EKS access entries
Does that ServiceAccount actually existNo
Which Pods or Deployments run as itNo
What it can do inside the cluster (RBAC)No
Who can create a Pod using it — the real escalationNo

A trust policy is a declaration, not a confirmation. It proves AWS will accept a token minted for a subject with that name in that namespace. It does not prove the ServiceAccount exists, that any Pod runs as it, or what it can do in-cluster. A ServiceAccount deleted a year ago produces an identical trust policy.

The product must therefore label this configured authority and say so plainly. The existing evidence-strength vocabulary already carries the right word — structural renders as "Configured" (sv0-platform/ui/src/components/EvidenceBadge.tsx:11-16, SHIPPED).

One honest upgrade, and its limit

A CloudTrail AssumeRoleWithWebIdentity event for the role proves a token was accepted for it within the window — so the trust is live, not a stale policy for something long deleted. It does not identify the Pod, and where a role trusts more than one subject it does not identify which subject. This moves the claim from configured to live; it does not move it to confirmed.

The exact field contents of a real AssumeRoleWithWebIdentity record must be checked against a live event before any wider claim is made from it. The narrow claim above is the only one currently supported.

IRSA and Pod Identity are alternatives, not a sequence

They are two different mechanisms and the customer's configuration decides which one applies. Neither is a later phase of the other.

IRSAEKS Pod Identity
Carrier of the bindingThe IAM role trust policyThe EKS association
Legible from IAM aloneYesNo — the principal is pods.eks.amazonaws.com and names no namespace or ServiceAccount
API costNone beyond existing IAM readsListPodIdentityAssociations then one DescribePodIdentityAssociation per association — the list summary omits roleArn
Works on self-managed KubernetesYesNo

Two consequences worth stating:

  • On self-managed Kubernetes on EC2 there is no EKS API and no Pod Identity, so the trust-policy route is the only AWS-legible hop. That makes the IRSA path more important in that environment, not less.
  • pods.eks.amazonaws.com appearing in a trust policy is, on its own, a finding: any Pod Identity association in the account's clusters can be pointed at that role, so the blast radius is namespace-wide until the associations are read.

References: Pod Identity overview · ListPodIdentityAssociations · EKS access entries.

Graph mapping

Kubernetes objectnodeTypesourceSystemEdge out
ServiceAccountidentitykubernetes:{cluster}AUTHENTICATES_AS → aws_iam_role
Namespaceresourcekubernetes:{cluster}
Clusterresourcekubernetes:{cluster}
Pod / Deployment / CronJob (only with cluster-side read)workloadkubernetes:{cluster}RUNS_AS → ServiceAccount
Role / ClusterRole (only with cluster-side read)permission_setkubernetes:{cluster}APPLIES_TO → resource
RoleBinding / ClusterRoleBindingno node — model as an edgeHAS_PERMISSION_SET: subject → role

The identity typing decision is load-bearing

PATH_MATERIALIZABLE_TYPES = new Set(["identity", "workload"]) (sv0-platform/src/ingestion/path-materializer.ts:69, SHIPPED). A connection-typed node never anchors an access path, never enters a breakage cone and never enters rehearsal.

The ServiceAccount must therefore be emitted as identity. Emitted as connection it is visible in the graph and inert everywhere else — which is exactly the state the existing GitHub Actions OIDC node is in today (see below).

Re-typing an existing node has ingest-key and deletion-policy consequences; the natural key must be confirmed against ADR-033 before an emitter is written.

Wildcard subjects are namespaces, never ServiceAccounts

AWS supports StringLike with system:serviceaccount:$namespace:*, allowing every ServiceAccount in a namespace. This must produce a namespace-scoped node, not a synthesised ServiceAccount. Inventing an identity that does not exist is a correctness failure, not a display choice.

Correctness precondition: trust statements must stay statement-scoped

TrustPolicyParser.parse_trust_policy merges every statement's conditions into one flat dictionary — result["conditions"].update(conditions) (sv0-connectors/integrations/aws/src/sv0_aws/core/trust_policy_parser.py:73, SHIPPED and defective). extract_github_repos_from_oidc (:202-220) reads that same flattened dictionary and inherits the defect.

Consequences on a real estate:

  • A role trusted by two clusters (blue/green, or prod plus DR) loses one cluster's subject silently.
  • A role trusted by GitHub Actions and EKS can associate a subject with the wrong provider.

Either produces a confidently rendered wrong answer, which is worse than an incomplete one. Statement-scoped parsing — principal, action and conditions kept together per statement, with StringEquals and StringLike both supported — is a prerequisite for any customer-facing emission. TARGET.

Registered blind spots

sv0-platform/src/rehearsal/coverage.ts already registers not-modelled controls with a direction (over_states_reach, under_states_reach, both) and the UI renders every entry verbatim (ui/src/components/rehearsal/RehearsalVerdictPanel.tsx:548-560, SHIPPED).

Kubernetes adds entries that must be registered alongside the existing Conditional Access / deny assignments / ABAC / resource locks / PIM set:

ControlDirection
NetworkPolicyover-states reach
Admission control (Gatekeeper, Kyverno)over-states reach
Pod Security admissionover-states reach
Namespace-wildcard subject scopeover-states reach
In-cluster RBAC — who can create a Pod as this ServiceAccountunder-states reach

An unregistered blind spot found during diligence does not merely cost that line — it converts the registered candour into selective candour retroactively. TARGET.

Where the Observed column comes from

The Approved | Configured | Observed comparison requires an Observed column for the AWS half. Three sources, in increasing cost:

SourceStatusWhat it givesPermission
Secret LastAccessedDateSHIPPEDsecrets_extractor.py:122, surfaced at aws/core/transformer.py:1582A date per secretAlready granted
IAM Access AdvisorPermission granted and unusediam:GetServiceLastAccessedDetails in cfn/securityv0-readonly-role.yaml:82-103, grep-negative in connector sourcePer-service last-used date for a roleAlready granted
CloudTrail evidencePartial — see belowPer-action counts and outcomesA real permission delta

The first two need no new permission and no CloudTrail access at all, which means the Observed column can begin before any archive grant is negotiated. They give dates, not events, and carry no denial — so they cannot support verification.

CloudTrail is a real permission delta — do not describe it otherwise

The CloudTrail archive grant is conditional and off by default: CloudTrailBucketArn defaults to '' (cfn/securityv0-readonly-role.yaml:27-41, SHIPPED), and the connector consumes it only with CLOUDTRAIL_ENABLED=true and CLOUDTRAIL_BUCKET_NAME.

Any statement that this design needs "no new AWS permission" is true for the identification half and false for the verification half. The permission list handed to a customer's security review must carry both, separately labelled.

cloudtrail_extractor.py is a scoped proof-of-concept — S3 event archive only (not Lake, not lookup-events), Lambda-anchored, and its own header records that it is not wired into cli/main.py. Two facts that matter for this design:

  • Its event set is already correct: AssumeRoleWithWebIdentity, PutObject, DeleteObject, GetSecretValue, Decrypt are all in CLOUDTRAIL_EVENT_NAMES_OF_INTEREST (:47-86). It does not need widening.
  • Its role-ARN matching strategy is the mechanism this design needs — non-invoke events matched on userIdentity.sessionContext.sessionIssuer.arn == role_arn (:345-398). What it lacks is a role-anchored entry point; today it is anchored on a Lambda workload, and an IRSA role has no Lambda. TARGET.

The control loop for this workstream

The loop stages and their definitions are ADR-034's. What is specific to this workstream is who performs each stage and what the control point is.

The loop is source-agnostic — each customer-facing capability is a different source feeding it, not a separate enforcement product. Only the last two columns vary:

Source of authorityControl pointApplied by
Kubernetes / EKS → AWS (this page)AWS IAMCustomer
Customer-published proprietary systemsThe customer's existing control processCustomer
Agent registries, gateways and vendor-hosted workflowsVaries by systemCustomer
SecurityV0's own AWS organisationAWS IAM / SCPSecurityV0

The control point for this workstream is AWS IAM. Not a deployment gate, not an admission controller, not an inline proxy.

SecurityV0 does not apply anything in a customer environment. The only outbound mutating calls in sv0-platform/src go to WorkOS (our own identity provider) and SMTP. The customer applies the control with the customer's own credential through the customer's own change process. This is the design, not a current limitation — see Held below.

The demo flow, with both verification moments

The two verification moments are distinct and both must be shown

What it provesWhen
Provider enforcedThe provider holds the control and refused the identical action. Read-back plus a denial receipt.Immediately after apply
SecurityV0 reconciledOur own next scan no longer computes the dangerous path.Next triggered or scheduled scan

They must be displayed as two timestamps, never collapsed into one:

Provider enforced        — 10:04
SecurityV0 reconciled — 10:07

Auto-resolution is not causal proof. A finding that disappears because it was absent from the next evaluation is stamped resolved_by_sync (sv0-platform/src/evaluator/index.ts:625-635, SHIPPED). That is genuinely valuable, and it does not prove any particular control caused the result — the path could have disappeared for unrelated reasons. Only the provider receipt ties an outcome to a control.

What a denial receipt proves, and what it does not

  • It proves the provider refused the API call.
  • It does not prove the workload stopped attempting it.
  • It does not prove other paths to the same resource are closed.
  • It proves nothing about the cluster; every in-cluster row remains unknown.

Evidence outcome semantics

Publishing denial receipts depends on outcome-aware evaluation, and the gap is narrower than it first appears.

What already works (SHIPPED):

  • ExecutionEvidenceDoc.outcome is typed success | failure | unknown (sv0-platform/src/domain/evidence/types.ts:12).
  • The ingestion path carries it: graph-transformer.ts:375 maps node.properties.outcome onto that field, defaulting to "unknown".
  • The AWS connector already computes it from CloudTrail error counts — "failure" when every call in the summary errored (aws/core/transformer.py:2607). Other connectors emit it too.

What is genuinely missing (TARGET):

  1. Evaluator rules ignore outcome. Two rules treat evidence existence as activity: dormant-authority.ts:59-63 and unproven-execution.ts:39-41. A run of blocked attempts therefore makes an identity look successfully active — the opposite of the truth. Three meanings must be distinguished: executed successfully · attempted and denied · outcome unknown.
  2. No error code survives ingestion. ExecutionEvidenceDoc has no field for a provider error code or event identifier (src/domain/evidence/types.ts:16-40), so AccessDenied cannot be told apart from a throttle or a 5xx. outcome is also an aggregate over a summary window, not a single event.
  3. outcome is untyped at the graph boundary. It travels as a loose properties bag entry rather than a declared field of NormalizedNode, and silently defaults to "unknown" when absent.

A denial is excellent proof that enforcement worked. It is not proof that authority was exercised successfully. Until (1) is fixed, publishing denial records would make the evaluator read them backwards.

The GitHub Actions OIDC node — a correction

An earlier draft of this workstream asserted that a GitHub deployment node "cannot be drawn." That was overstated, and the correction matters because it changes what is defensible to show.

What is true (SHIPPED): the AWS connector already synthesises a foreign-system node from a GitHub Actions OIDC trust policy — nodeType: "connection", sourceSystem: "github_actions", properties.repoPattern carrying the sub condition (e.g. repo:org/repo:*), plus an AUTHENTICATES_AS edge to the IAM role (aws/core/transformer.py:2839-2877).

So the two statements differ:

StatementVerdict
"This IAM role trusts a GitHub Actions OIDC subject matching repo:org/repo:*"Defensible — configured authority, same evidence class as the IRSA subject
"A live GitHub repository, workflow or deployment exists and SecurityV0 is holding it"Not defensible — see the inventory below

The second row rests on the coverage statement, which is maintained in exactly one place — Connector inventory — and is deliberately not repeated here. In short: SecurityV0 has no packaged GitHub repository or workflow connector, so no repository, workflow or deployment is discovered and refreshed on a cadence.

Two further limits on the configured-authority claim:

  • The node is connection-typed, so per path-materializer.ts:69 it does not anchor a path or appear in a breakage cone. It is visible and inert.
  • The repoPattern value is read from the flat-merged condition dictionary described above, so it is subject to the same statement-scoping defect.

repoPattern would be the natural join key if SecurityV0 ever answered a deploy-time request from GitHub Actions: the pattern names the repository, and the node already points at the IAM role whose reach we compute. Recorded here as substrate only. Building on it is held — see CI/CD deploy-time blocking in the table below — and nothing in this design depends on it.

Held — not target state

These are recorded so that nobody reads their absence as an oversight. They are decisions, not backlog.

Held itemWhy
CI/CD deploy-time blocking for this workstreamAn earned expansion, released only after a validated read-only result and an explicit customer request to participate in deployment decisions. The buyer named the comfort barrier to anything in-line in production.
Automatic write-back into a customer environmentSecurityV0 makes no mutating call into a customer system. This is architecture, not a gap.
Autonomous enforcementThere is no path in this design where a control applies itself.
Publishing denial recordsBlocked on outcome-aware evaluation above.
In-cluster agent or daemon; Kubernetes Secrets accessCluster-side confirmation, if ever required, is a customer-run read-only job posting a normalized graph — never a daemon, never Secrets.
Tenant-wide revocation rehearsal at enterprise scaleSYNC_MAX = 10_000 (src/rehearsal/service.ts:55) hard-throws above ten thousand entities; ASYNC_MAX is an enum value, not a code path. With the loop closing through an applied control and a provider receipt, rehearsal is no longer the load-bearing capability for this workstream.

Cluster-side confirmation, if it is ever required

Optional, customer-run, and gated on the customer asking for it.

The built-in Kubernetes view ClusterRole is insufficient — it excludes Roles and RoleBindings, which are precisely the objects authority analysis needs. A purpose-built read-only ClusterRole should cover get/list on serviceaccounts, pods, namespaces, the apps controllers, batch jobs and all four rbac.authorization.k8s.io kinds — and explicitly not secrets.

Reference: Kubernetes RBAC — user-facing roles.

Reading both sides makes the disagreement itself a finding: an IRSA annotation with no matching trust policy is a broken configuration; a trust policy with no matching ServiceAccount is stale authority. Neither side alone catches both.

Prior art

Cartography models KubernetesServiceAccount with an aws_role_arn field and an ASSUMES_ROLE relationship to AWSRole, under Apache-2.0. The model and relationship naming may be borrowed freely; copied code requires attribution and a NOTICE. Its collection route differs from this design — it reads the IRSA annotation from inside the cluster, so it requires a cluster credential where the trust-policy route does not.

Reference: Cartography Kubernetes schema.

Open questions — customer-dependent, resolve before building

  1. EKS, self-managed Kubernetes on EC2, or both? Decides whether Pod Identity is reachable at all.
  2. IRSA or Pod Identity? They are alternatives; do not build both speculatively.
  3. Will the customer grant the CloudTrail archive read? Without it there is no event-sourced Observed column and no denial receipt. The Access Advisor and secret-last-accessed fallbacks are weaker and cannot verify.
  4. Is cluster-side confirmation in scope? If not, IRSA-from-AWS is the complete Kubernetes story and should be presented as such rather than as a stepping stone.
  5. Who applies, through which change process? The loop above assumes a customer change record and an infrastructure-as-code apply.