AWS authentication & IAM¶
ACK controllers on the management cluster (ambient credentials)¶
All ACK controllers (S3, RDS, IAM) run on the management cluster only (issue #346). ACK controllers talk to the AWS API directly, so they do not need to run inside the cluster whose resources they manage; the workload clusters run no controllers and hold no credentials at all.
The controllers authenticate with credentials seeded imperatively from the
ambient AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (optionally AWS_SESSION_TOKEN),
which the aws-credentials mise task encodes into AWS_B64ENCODED_CREDENTIALS
for capa-system; the bootstrap/pivot code decodes it into a shared-credentials
file for ack-system, at bootstrap/pivot time (issue #379). This mirrors the Azure and GCP posture
("none at rest"): no static credentials are stored in Git. The kind management
cluster runs on kind (not EKS), so IRSA/Pod Identity is not available;
credentials are created imperatively by the bootstrap and pivot phases.
One-time migration for a running management cluster¶
This step is needed only if a management cluster is already running with the
aws-credentials Secrets created by Flux from the now-deleted SOPS files.
Fresh bootstraps from the new main need no migration.
The two Flux Kustomizations use prune: true. Flux strips kubectl-written
annotations on its next apply, so adding the prune-disable annotation while
the old revision is still reconciled does not help. Suspend both
Kustomizations, annotate the live Secrets, merge to main, then resume:
flux suspend kustomization capa-system -n flux-system
flux suspend kustomization ack-controllers -n flux-system
kubectl annotate secret aws-credentials -n capa-system \
kustomize.toolkit.fluxcd.io/prune=disabled --overwrite
kubectl annotate secret aws-credentials -n ack-system \
kustomize.toolkit.fluxcd.io/prune=disabled --overwrite
# after merging to main:
flux reconcile source git flux-system -n flux-system
flux resume kustomization capa-system -n flux-system
flux resume kustomization ack-controllers -n flux-system
kubectl get secret aws-credentials -n capa-system
kubectl get secret aws-credentials -n ack-system
If the Secrets were already pruned before this runbook ran, re-create them by
re-running bootstrap.sh (or the equivalent pivot.sh path) with the
correct ambient credentials.
Note: this is a deliberate, one-time exception to golden rule 1 (never mutate the clusters); the annotation is harmless and persists after bootstrap/pivot; it only prevents Flux from pruning the Secret during the transition.
Least-privilege trade-off: the static principal's union scope¶
Before #346 each workload-cluster controller assumed its own scoped IAM role
via EKS Pod Identity (krops-ack-s3-controller, krops-ack-rds-controller,
krops-ack-iam-controller, declared in the deleted
mgmt/aws/infrastructure/ack-pod-identity/). Moving the controllers to the
management cluster deleted those roles, so the single static principal behind
aws-credentials now needs the union of their former policies (granted
outside this repo, same as the CAPA permissions):
- S3:
s3:ListAllMyBuckets+s3:GetBucketLocationon*, and bucket management onarn:aws:s3:::krops-*only:s3:CreateBucket,s3:DeleteBucket,s3:GetBucket*/s3:PutBucket*,s3:DeleteBucketPolicy,s3:GetEncryptionConfiguration/PutEncryptionConfiguration,s3:GetAccelerateConfiguration,s3:GetAnalyticsConfiguration,s3:GetIntelligentTieringConfiguration,s3:GetInventoryConfiguration,s3:GetLifecycleConfiguration,s3:GetMetricsConfiguration,s3:GetReplicationConfiguration, lifecycle/replication/accelerate/ analytics/inventory/metrics/intelligent-tiering configuration Get+Put,s3:ListBucket,s3:TagResource(theCreateBuckettagSet is authorized against it, see #352/#353). The DeleteBucketTagging API used for tag-removal drift requiress3:PutBucketTagging, covered bys3:PutBucket*.s3:UntagResource/s3:ListTagsForResourceare not needed: the ACK S3 controller only calls them for directory buckets, which krops does not create. - RDS:
rds:Describe*+rds:ListTagsForResourceon*; instance management (rds:CreateDBInstance/ModifyDBInstance/DeleteDBInstance/RebootDBInstance/StartDBInstance/StopDBInstance,rds:AddTagsToResource/RemoveTagsFromResource) onarn:aws:rds:*:*:*:krops-*;secretsmanager:CreateSecret/TagResource/RotateSecretonarn:aws:secretsmanager:*:*:secret:rds!*(required bymanageMasterUserPassword);kms:DescribeKeyon*pluskms:CreateGrant/ListGrants/RevokeGrantrestricted withkms:GrantIsForAWSResource(so RDS can use the defaultaws/rdsandaws/secretsmanagerKMS keys; without theseCreateDBInstancefails withKMSKeyNotAccessibleFault);iam:CreateServiceLinkedRolescoped toAWSServiceRoleForRDS(needed the first time an RDS instance is created in the account) - IAM: role management (
iam:CreateRole/DeleteRole/GetRole/UpdateRole/UpdateRoleDescription/UpdateAssumeRolePolicy/PutRolePolicy/DeleteRolePolicy/GetRolePolicy/ListRolePolicies/ListAttachedRolePolicies/ListInstanceProfilesForRole/TagRole/UntagRole/ListRoleTags) scoped toarn:aws:iam::*:role/krops-*, plus the user actions for thekrops-readerconsole user (iam:CreateUser/UpdateUser/DeleteUser/PutUserPolicy/DeleteUserPolicy/GetUser/GetUserPolicy/ListUserPolicies/ListAttachedUserPolicies/TagUser/UntagUser/ListUserTags). The pinned IAM controller reads attached and inline policies and tags on every refresh, and removes inline policies and tags during drift reconciliation (even though krops declares no attached managed policies)
The trade-off is real: name-scoped iam:CreateRole + iam:PutRolePolicy is
still a privilege-escalation surface (any permission can be granted to a
role, as long as it is named krops-*), and the union now sits on one
long-lived static principal instead of three short-lived pod-identity
sessions. Accepted because the management cluster is already the only
cluster with static AWS credentials in Git and already owns every Cluster
object; the workload clusters shed their last credential and controller in
exchange.
Per-cluster read-only IAM roles¶
mgmt/aws/infrastructure/workload-resources/role.yaml has the management
cluster's ACK IAM controller create one read-only IAM role per workload
cluster (krops-<cluster>-reader). IAM is global, so the cluster name is
part of the role name to keep the two clusters from fighting over one role:
- trust policy: the AWS account root (
arn:aws:iam::<account>:root,sts:AssumeRole): any principal in the account that is itself allowed to assume the role can use it - read-only permissions covering the resources this repo creates on both
clusters:
krops-*S3 buckets (bucket + object reads) andkrops-*RDS instances (rds:DescribeDBInstances,rds:ListTagsForResource: only Describe actions that support resource-level scoping)
Console access: the krops-reader IAM user¶
mgmt/aws/infrastructure/aws-global-iam/reader-user.yaml has the
management cluster's ACK IAM controller create one IAM User
(krops-reader) whose only permission is sts:AssumeRole on
arn:aws:iam::*:role/krops-*-reader: it can see nothing directly and is
just a doorway into the per-cluster reader roles above.
The ACK IAM controller has no LoginProfile resource, so the console
password cannot be declared in Git. Set it once imperatively after the
user has been reconciled:
aws iam create-login-profile --user-name krops-reader \
--password '<initial-password>' --password-reset-required
Then, to browse the repo-created resources in the AWS console:
- Sign in at
https://<account-id>.signin.aws.amazon.com/consoleaskrops-reader(you will be prompted to set a new password on first login). - Use Switch Role (account menu, top right) with the account ID and role
name
krops-eu-north-1-workload-readerorkrops-eu-west-1-workload-reader, or use the direct link:
https://signin.aws.amazon.com/switchrole?roleName=krops-eu-north-1-workload-reader&account=<account-id>
- Browse the
krops-*S3 buckets and RDS instances (switch the console region to eu-north-1/eu-west-1 for the databases).
CI access: GitHub Actions OIDC and the krops-ci-e2e role¶
CI authenticates to the e2e account (120392301094) through GitHub's OIDC
provider and assumes a least-privilege role for the e2e lifecycle (issue
379). No AWS access keys are stored; every session is short-lived. Created¶
imperatively, additive only.
Resources¶
- OIDC provider:
arn:aws:iam::120392301094:oidc-provider/token.actions.githubusercontent.com(audiencests.amazonaws.com). - Role:
arn:aws:iam::120392301094:role/krops-ci-e2e(max session duration 4 hours; e2e runs must complete within this window). - Five customer-managed policies, grouped by concern:
krops-ci-e2e-capa-ec2,krops-ci-e2e-capa-eks,krops-ci-e2e-ack-mgmt,krops-ci-e2e-sweep,krops-ci-e2e-self-deny.
Trust policy¶
The role trusts only the GitHub OIDC provider with audience
sts.amazonaws.com and sub matching repo:polarsquad/krops:* (any ref in
that repo). To narrow later, replace the StringLike sub value with
explicit refs, for example
repo:polarsquad/krops:ref:refs/heads/main, via
iam:UpdateAssumeRolePolicy.
Permission groupings¶
Derived from the repo, not guesswork: the CAPA surface comes from the pinned
clusterawsadm 2.13.1 print-policy output evaluated with krops' feature
gates (EKS=true,EKSEnableIAM=true,EKSAllowAddRoles=true,MachinePool=true),
the sweep surface from teardown.sh / bootstrap-rs/src/teardown.rs, and
the ACK surface from the prerequisites in AWS environment.
- EC2/VPC (
krops-ci-e2e-capa-ec2): the full describe set; creates only ineu-north-1andeu-west-1(aws:RequestedRegion); mutations (delete, modify, attach/detach, associate, authorize/revoke, release) only on resources carrying asigs.k8s.io/cluster-api-provider-aws/roletag (CAPA-created), plus security-group rule and delete operations on the EKS-created cluster security groups (aws:eks:cluster-name=default_*);CreateTags/DeleteTagsonly with CAPA tag keys. - EKS (
krops-ci-e2e-capa-eks): cluster, nodegroup, addon, and access-entry CRUD scoped todefault_*clusters in the two e2e regions (the non-kropstrainingcluster is out of scope); the two EKS service-linked roles; CAPA per-cluster IAM roles oncapa_*and the cluster name prefixes;iam:PassRoletoeks.amazonaws.comfor the clusterawsadm stack roles andcapa_*; SSM optimized-AMI parameters, KMS grants onalias/cluster-api-provider-aws-*, Secrets Manager onaws.cluster.x-k8s.io/*secrets. - Management ACK controllers (
krops-ci-e2e-ack-mgmt):krops-*IAM role and user management (thekrops-ack-*roles, thekrops-*-readerroles, thekrops-readeruser) and pod identity associations ondefault_*clusters. No OIDC provider management anywhere: IRSA is unused (pod identity instead), which also protects the GitHub OIDC provider itself. - Orphan sweep (
krops-ci-e2e-sweep):sts:GetCallerIdentity; RDS delete onkrops-*instances; S3 version purge and bucket delete onkrops-*buckets; IAM cleanup onkrops-*/capa_*roles, their instance profiles, andkrops-*users; CloudFormation delete on thecluster-api-provider-aws-sigs-k8s-iostack in both regions. - Self-escalation guard (
krops-ci-e2e-self-deny): explicit Deny on the role's own ARN forPutRolePolicy,DeleteRolePolicy,AttachRolePolicy,DetachRolePolicy,UpdateAssumeRolePolicy,DeleteRole, and permissions-boundary changes. Therole/krops-*globs inack-mgmtandsweepmatchkrops-ci-e2eitself; without this Deny a workflow session could grant itself arbitrary inline permissions or widen its own trust (verified withsimulate-principal-policy: the four escalation actions are nowexplicitDeny, lifecycle actions on otherkrops-*roles remainallowed).
Narrowed relative to the upstream CAPA policy, with the repo as ground
truth: no ec2:RunInstances/TerminateInstances (no EC2 machine pools), no
ELB/Auto Scaling writes (no AWSMachinePool or AWSCluster), no spot or
fargate service-linked roles (all pools onDemand, no fargate profiles), no
IPAM/IPv6 actions (IPv4 clusters), no launch-template writes (the
AWSManagedMachinePool specs declare none).
Not covered (operator steps)¶
- First-time creation of the clusterawsadm CloudFormation stack
(the
aws-bootstraptask, run in the toolbox per docs/aws.md) needscloudformation:CreateStackplus IAM writes on the*.cluster-api-provider-aws.sigs.k8s.ioroles. The stack is alreadyCREATE_COMPLETE(2026-09-01, per #143), so recreating it stays an operator step. - Service-quota increases (see Operations) and the
one-time
iam:CreateLoginProfileforkrops-reader(above) stay operator steps. - CAPA and ACK data-plane actions: the
krops-ci-e2erole must hold the same AWS action grants that were previously held by thecapi-demouser principal, covering the full S3, RDS, and IAM action sets documented above for a successful full e2e reconciliation. These actions are account-side prerequisites the operator must add to the role policies.
Assuming the role from a workflow¶
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::120392301094:role/krops-ci-e2e
aws-region: eu-north-1
The action exchanges the workflow's OIDC token for session credentials;
nothing is stored. The aws-orphan-report workflow assumes this role for
read-only orphan discovery (issue #380). Full e2e CI wiring is tracked in #185.
Revocation¶
- Cut all CI access: delete the OIDC provider
(
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn arn:aws:iam::120392301094:oidc-provider/token.actions.githubusercontent.com). The role remains but can no longer be assumed from GitHub. Note this stops NEW assumptions only: sessions already issued run until their granted expiry (4 hour maximum), so access is not cut instantly during an incident. - Narrow instead: tighten the trust policy's
subcondition to specific refs (above). - Rotate: nothing to rotate; STS sessions are short-lived (1 hour typical, 4 hour maximum) and independent of the short-lived OIDC token.
E2E account incident and credential revocation¶
If a credential is suspected compromised, contain it immediately, then assess impact.
Containment¶
(a) capi-demo long-lived IAM key (stored in .env only; used by ambient credential flow):
- Disable the key immediately:
aws iam update-access-key --user-name capi-demo --access-key-id <ACCESS_KEY_ID> --status Inactive - Issue a new key and update
.env, following Setting / rotating AWS credentials. No re-encryption of SOPS secrets is needed: credentials are seeded imperatively from.envat bootstrap/pivot time. - Verify Flux reconciliation and successful AWS operations from CAPA and all management ACK controllers (S3, RDS, IAM), plus replacement adoption by any running bootstrap process. Keep the compromised key disabled throughout recovery, then delete it once those checks pass.
(b) krops-ci-e2e OIDC role (normally covers itself; see CI access):
- Delete or update the OIDC provider to block new assumptions; verify that attempts to obtain new sessions fail after IAM propagation.
- Attach the following inline Deny to the role to revoke existing sessions.
Use an explicit UTC cutoff (
YYYY-MM-DDTHH:MM:SSZ) covering sessions issued while the assumption block was propagating. Refresh the cutoff if later sessions were issued before the block became effective. See AWS role-session revocation for the cutoff and propagation behavior:{ "Version": "2012-10-17", "Statement": [ { "Effect": "Deny", "Action": "*", "Resource": "*", "Condition": { "DateLessThan": { "aws:TokenIssueTime": "<ISO-8601 timestamp of the revocation>" } } } ] } - Verify the deployed policy and denied access with affected session
credentials. Keep the Deny until every affected session has expired,
even if the investigation finishes sooner: removing it can restore access
to unexpired credentials. Read
MaxSessionDurationwithaws iam get-role --role-name krops-ci-e2e; the documented maximum is four hours, but use the maximum applicable when the affected sessions were issued, not a newly lowered limit. If individual expiry times are unknown, retain the Deny until at least the final cutoff plus that maximum duration. Restore new assumptions only after fixing the compromise; new trusted sessions issued after the cutoff can work while the Deny remains.
(c) krops-reader console login:
- Delete the login profile to revoke console access:
aws iam delete-login-profile --user-name krops-reader - Deleting a login profile prevents new password logins; it does not revoke
existing console or assumed-role credentials. In
mgmt/aws/infrastructure/aws-global-iam/reader-user.yaml, add an explicit inline Deny ofsts:AssumeRoleonarn:aws:iam::*:role/krops-*-readerto block new assumptions by the reader, including from existing user sessions. Also add the timestamp-conditioned Deny from (b) as a separate user inline policy in that same Git-owned file, with a UTC cutoff covering all compromised IAM-user console sessions. This revokes their permissions as described in AWS user-session revocation. Preserve the unconditional assumption block through investigation and password recovery. Refresh the user cutoff if sessions were issued during containment propagation; verify the deployed user policy and that old user credentials cannot callsts:AssumeRole. The user-session policy does not revoke credentials already issued by a role, so step 3 is also required. - In
mgmt/aws/infrastructure/workload-resources/role.yaml, add the timestamp-conditioned inline Deny from (b) to bothkrops-eu-north-1-workload-readerandkrops-eu-west-1-workload-reader. A user-policy change alone does not remove permissions from credentials already issued by those roles. This role Deny affects all sessions before the cutoff, including other readers. Merge these Git-owned policy changes tomain; verify Flux readiness, ACKResourceSyncedstatus, the actual AWS user/role policies, blocked new assumptions, and denied access with pre-cutoff role credentials. Do not patch the cluster or rely on an out-of-band IAM edit that ACK could undo. Containment is not established while reconciliation or IAM propagation remains incomplete. Refresh the UTC cutoff to cover sessions issued during that interval. - Check
MaxSessionDurationon each deployed role withaws iam get-role. Retain each role Deny until all affected sessions expire, using the final cutoff plus the maximum duration applicable at issuance if exact expiries are unknown. Investigation completion or password replacement is not a reason to remove it early. - Recreate the login profile with a new, unique password and required reset
only after addressing the compromise. Restore the user's assumption
permission through Git only after the user-session Deny has converged and
been verified to block pre-cutoff user credentials; retain that Deny so old
user sessions cannot obtain fresh role credentials after password recovery.
Verify a trusted post-cutoff login can assume the roles while an old user
session cannot. Keep the user-session Deny until all affected IAM-user
sessions expire: establish their expiry times or a verified upper bound
for the console/temporary credential sessions actually issued. A role's
MaxSessionDurationis not the IAM-user session lifetime. If user-session expiry cannot be established, leave that Deny in place. Likewise retain both role-session Denies until their separate expiry condition in step 4 passes. Remove each Deny through Git only after its respective user or role credentials have expired, then verify Flux/ACK/AWS convergence.
(d) age key compromise:
The age identity exposes both AWS profiles, regional GitHub pull PATs, and both konflate tokens, including still-valid values in historical ciphertext. Shared bootstrap credentials may also be affected. Follow Age-key compromise recovery to revoke exposed credentials and replace the encryption identity and data keys; routine credential rotation alone is insufficient.
Impact assessment¶
Review CloudTrail event history for the affected principal (90 days of management events):
# IAM/global-service events (these are always recorded in us-east-1)
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=capi-demo \
--region us-east-1
# Resource events in the krops e2e regions
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=capi-demo \
--region eu-north-1
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=capi-demo \
--region eu-west-1
The member-account CloudTrail covers API calls made within the account. The long-term organization trail is in the log-archive account (Control Tower-managed); contact the account owner (joseph.shriner@polarsquad.com) to retrieve it.
Cleanup and notification¶
- Clean up any resources provisioned by the compromised credential using the teardown path.
- Notify the account owner (joseph.shriner@polarsquad.com) and, for OIDC or age-key compromise, the repository maintainers.
EKS worker lifecycle tag reconciliation¶
mgmt/aws/infrastructure/worker-run-tags/ runs a minute CronJob in
ack-system, using the existing SOPS aws-credentials Secret's credentials
key and default profile. It uses no Kubernetes API token or node IMDS
credentials. Its effective IAM permissions are unknown from Git: an operator
must grant and verify the following additional permissions on the static
principal before deployment. This repository does not modify that principal.
The read-side ASG/EC2 actions require Resource: "*"; requested regions are
limited. EKS reads use the three named clusters and their nodegroups, following
AWS EKS action/resource authorization.
EC2 mutation uses the resource and tag conditions documented in
AWS EC2 tagging permissions.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:GetCallerIdentity",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": ["eks:DescribeCluster", "eks:ListNodegroups"],
"Resource": [
"arn:aws:eks:eu-north-1:120392301094:cluster/default_eu-north-1-management-control-plane",
"arn:aws:eks:eu-north-1:120392301094:cluster/default_eu-north-1-workload-control-plane",
"arn:aws:eks:eu-west-1:120392301094:cluster/default_eu-west-1-workload-control-plane"
],
"Condition": {"StringEquals": {"aws:RequestedRegion": ["eu-north-1", "eu-west-1"]}}
},
{
"Effect": "Allow",
"Action": "eks:DescribeNodegroup",
"Resource": [
"arn:aws:eks:eu-north-1:120392301094:nodegroup/default_eu-north-1-management-control-plane/*/*",
"arn:aws:eks:eu-north-1:120392301094:nodegroup/default_eu-north-1-workload-control-plane/*/*",
"arn:aws:eks:eu-west-1:120392301094:nodegroup/default_eu-west-1-workload-control-plane/*/*"
],
"Condition": {"StringEquals": {"aws:RequestedRegion": ["eu-north-1", "eu-west-1"]}}
},
{
"Effect": "Allow",
"Action": ["autoscaling:DescribeAutoScalingGroups", "ec2:DescribeInstances"],
"Resource": "*",
"Condition": {"StringEquals": {"aws:RequestedRegion": ["eu-north-1", "eu-west-1"]}}
},
{
"Effect": "Allow",
"Action": "ec2:CreateTags",
"Resource": [
"arn:aws:ec2:eu-north-1:120392301094:instance/*",
"arn:aws:ec2:eu-west-1:120392301094:instance/*"
],
"Condition": {
"StringEquals": {
"aws:RequestedRegion": ["eu-north-1", "eu-west-1"],
"ec2:ResourceTag/aws:eks:cluster-name": [
"default_eu-north-1-management-control-plane", "default_eu-north-1-workload-control-plane", "default_eu-west-1-workload-control-plane"
]
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"krops.io/run-id", "krops.io/revision", "krops.io/expires-at", "krops.io/run-kind"
]
},
"Null": {"aws:TagKeys": "false"}
}
}
]
}
These restrictions apply to this policy's grant; existing broader grants on the
shared principal can still authorize other actions. Verify the effective policy
and exercise a real missing-tag backfill before claiming adoption. The script
also checks the caller account, exact region/cluster allowlist, fresh ASG
membership, and the instance's aws:eks:cluster-name ownership immediately
before writing only the four lifecycle keys. The IAM ownership condition checks
ownership at write time. API denials produce a failed Job; they never trigger
an unscoped fallback or a credential replacement.