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
SHIPPEDrows carryfile:linecitations intosv0-platformandsv0-connectors; theTARGETrows carry none, because there is nothing to cite yet.The vocabulary used here — approved, applied, verified, and the
Observed → Decided → Approved → Applied → Verifiedrail — 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.
| Question | Answered 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 API | Yes — EKS access entries |
| Does that ServiceAccount actually exist | No |
| Which Pods or Deployments run as it | No |
| What it can do inside the cluster (RBAC) | No |
| Who can create a Pod using it — the real escalation | No |
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
AssumeRoleWithWebIdentityrecord 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.
| IRSA | EKS Pod Identity | |
|---|---|---|
| Carrier of the binding | The IAM role trust policy | The EKS association |
| Legible from IAM alone | Yes | No — the principal is pods.eks.amazonaws.com and names no namespace or ServiceAccount |
| API cost | None beyond existing IAM reads | ListPodIdentityAssociations then one DescribePodIdentityAssociation per association — the list summary omits roleArn |
| Works on self-managed Kubernetes | Yes | No |
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.comappearing 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 object | nodeType | sourceSystem | Edge out |
|---|---|---|---|
| ServiceAccount | identity | kubernetes:{cluster} | AUTHENTICATES_AS → aws_iam_role |
| Namespace | resource | kubernetes:{cluster} | — |
| Cluster | resource | kubernetes:{cluster} | — |
| Pod / Deployment / CronJob (only with cluster-side read) | workload | kubernetes:{cluster} | RUNS_AS → ServiceAccount |
| Role / ClusterRole (only with cluster-side read) | permission_set | kubernetes:{cluster} | APPLIES_TO → resource |
| RoleBinding / ClusterRoleBinding | no node — model as an edge | — | HAS_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:
| Control | Direction |
|---|---|
| NetworkPolicy | over-states reach |
| Admission control (Gatekeeper, Kyverno) | over-states reach |
| Pod Security admission | over-states reach |
| Namespace-wildcard subject scope | over-states reach |
| In-cluster RBAC — who can create a Pod as this ServiceAccount | under-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:
| Source | Status | What it gives | Permission |
|---|---|---|---|
Secret LastAccessedDate | SHIPPED — secrets_extractor.py:122, surfaced at aws/core/transformer.py:1582 | A date per secret | Already granted |
| IAM Access Advisor | Permission granted and unused — iam:GetServiceLastAccessedDetails in cfn/securityv0-readonly-role.yaml:82-103, grep-negative in connector source | Per-service last-used date for a role | Already granted |
| CloudTrail evidence | Partial — see below | Per-action counts and outcomes | A 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,Decryptare all inCLOUDTRAIL_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 authority | Control point | Applied by |
|---|---|---|
| Kubernetes / EKS → AWS (this page) | AWS IAM | Customer |
| Customer-published proprietary systems | The customer's existing control process | Customer |
| Agent registries, gateways and vendor-hosted workflows | Varies by system | Customer |
| SecurityV0's own AWS organisation | AWS IAM / SCP | SecurityV0 |
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 proves | When | |
|---|---|---|
| Provider enforced | The provider holds the control and refused the identical action. Read-back plus a denial receipt. | Immediately after apply |
| SecurityV0 reconciled | Our 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.outcomeis typedsuccess | failure | unknown(sv0-platform/src/domain/evidence/types.ts:12).- The ingestion path carries it:
graph-transformer.ts:375mapsnode.properties.outcomeonto 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):
- Evaluator rules ignore outcome. Two rules treat evidence existence as
activity:
dormant-authority.ts:59-63andunproven-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. - No error code survives ingestion.
ExecutionEvidenceDochas no field for a provider error code or event identifier (src/domain/evidence/types.ts:16-40), soAccessDeniedcannot be told apart from a throttle or a 5xx.outcomeis also an aggregate over a summary window, not a single event. outcomeis untyped at the graph boundary. It travels as a loosepropertiesbag entry rather than a declared field ofNormalizedNode, 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:
| Statement | Verdict |
|---|---|
"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 perpath-materializer.ts:69it does not anchor a path or appear in a breakage cone. It is visible and inert. - The
repoPatternvalue 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 item | Why |
|---|---|
| CI/CD deploy-time blocking for this workstream | An 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 environment | SecurityV0 makes no mutating call into a customer system. This is architecture, not a gap. |
| Autonomous enforcement | There is no path in this design where a control applies itself. |
| Publishing denial records | Blocked on outcome-aware evaluation above. |
| In-cluster agent or daemon; Kubernetes Secrets access | Cluster-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 scale | SYNC_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
- EKS, self-managed Kubernetes on EC2, or both? Decides whether Pod Identity is reachable at all.
- IRSA or Pod Identity? They are alternatives; do not build both speculatively.
- 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.
- 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.
- Who applies, through which change process? The loop above assumes a customer change record and an infrastructure-as-code apply.
Related
- ADR-034 — definitions of approved, applied and verified; the control case
- Connector Framework — where the IRSA emitter sits
- Data Model — node and edge vocabulary
- Processing Pipeline — where verification and reconciliation sit in the pipeline
- Execution Evidence Fidelity — evidence strength and what each class proves
- Access Paths — path materialization and anchoring