Read-only AWS access for contractors: the reads to deny

We built a read-only IAM Identity Center permission set for an outside engineering team. The five groups of config reads we denied on top of ReadOnlyAccess, why nodes/proxy came off the cluster role, and the PII finding read-only access does not fix.

By VVV Ops ·

Kubernetes made fine-grained kubelet authorization stable in v1.36, and the reason sits in the project's own security docs: get permission on nodes/proxy "is not a read-only permission, and authorizes access to endpoints which can be used to execute commands in any container running on the node". AWS has the same category of problem, spread across a dozen services. An outside engineering team needed to see a client's AWS accounts, EKS clusters and databases, and we built them a read-only IAM Identity Center permission set. The reads were the easy half. Taking the dangerous ones back out was the work.

The denies are the subject here. For the broader least-privilege picture in infrastructure code, see Terraform Security Best Practices for AWS; for how vendor access shows up in an audit, SOC 2 Compliance for SaaS Companies covers the evidence side. The rest of this is the permission set itself.

What the managed read-only policy returns

A contractor permission set usually starts from the AWS managed ReadOnlyAccess policy, and that is a reasonable place to start. Read the action list, though, and "read" turns out to cover the data plane. On "Resource": "*" it allows:

  • s3:Get* and s3:List*, which is object content, not bucket configuration
  • dynamodb:Scan and dynamodb:Query, both listed literally
  • sqs:Receive*
  • ssm:Get*
  • lambda:Get*, cloudformation:Get* and iot:Get*
  • apigateway:GET
  • sts:GetSessionToken

One line in that policy shows somebody thought about this. The Secrets Manager block is secretsmanager:Describe*, secretsmanager:GetResourcePolicy and secretsmanager:List*, and GetSecretValue is absent. The intent is clear enough. The problem is that a secret stored in Secrets Manager is the one case the policy protects, and secrets rarely stay in one place.

Five reads we took out

The permission set we shipped denies five groups of reads on top of the managed policy. The shape of it is below. Every account ID, Region and table name in it is an invented placeholder, so replace them with your own before this policy goes anywhere:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyConfigReadsThatCarrySecrets",
      "Effect": "Deny",
      "Action": [
        "lambda:GetFunction",
        "lambda:GetFunctionConfiguration",
        "lambda:GetLayerVersion",
        "cloudformation:GetTemplate",
        "iot:GetRetainedMessage"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyApiKeyValues",
      "Effect": "Deny",
      "Action": "apigateway:GET",
      "Resource": [
        "arn:aws:apigateway:eu-west-1::/apikeys",
        "arn:aws:apigateway:eu-west-1::/apikeys/*"
      ]
    },
    {
      "Sid": "DenyTableContents",
      "Effect": "Deny",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem",
        "dynamodb:Query",
        "dynamodb:Scan"
      ],
      "Resource": "arn:aws:dynamodb:eu-west-1:111122223333:table/acme-onboarding-forms"
    }
  ]
}

What each of those returns, according to the API references:

lambda:GetFunctionConfiguration returns an Environment object whose Variables map holds the function's environment variables. The same page notes that Environment is omitted from CloudTrail logs, which means the values a contractor reads do not land in your trail alongside the call. lambda:GetLayerVersion goes further and returns a download link for the layer archive, valid for 10 minutes.

cloudformation:GetTemplate returns TemplateBody, and the docs are explicit that CloudFormation "returns the same template that was used when the stack was created", including for stacks deleted up to 90 days ago. Anything hardcoded in a template years ago is still retrievable today.

apigateway:GET against /apikeys/{id}?includeValue=true returns the key's value field. The includeValue flag is a boolean on the request, so the API key value is one query parameter away from any caller who can list API keys. The API Gateway ARN format for that resource is arn:{partition}:apigateway:{region}::/apikeys/{id}, with no account ID segment.

iot:GetRetainedMessage returns a retained message payload. Its sibling, ListRetainedMessages, returns only topic names, payload sizes and QoS and tells you to call GetRetainedMessage with a topic name if you want the body. A retained message is whatever the last publisher put on that topic, which in practice can be device state, serial numbers or a provisioning blob.

The DynamoDB denies cover the tables that hold onboarding form submissions. We checked production tables for personal data before writing that statement rather than after.

One caveat on denies as a pattern. A deny list is a running inventory, and every new function, stack or table lands on the allow side until somebody adds it. The durable fix is to stop putting secrets in places a config read returns, and the deny statement is what holds while that migration runs.

nodes/proxy and the cluster read-only role

On the EKS side we started from an internal read-only cluster role and made nodes/proxy an option on it, then turned the option off for contractors. That subresource is the kubelet catch-all. The Kubernetes kubelet authorization reference maps /stats/* to the stats subresource, /metrics/* to metrics, /logs/* to log, /spec/* to spec, /checkpoint/* to checkpoint, and every other kubelet endpoint to proxy. "All others" includes the exec and attach endpoints.

The fine-grained behaviour that changed this is behind the KubeletFineGrainedAuthz gate, stable since Kubernetes v1.36 and enabled by default, first available in v1.32. With it on, the kubelet checks a specific subresource before falling back to proxy for /pods, /runningPods, /configz and /healthz. So a role that previously needed nodes/proxy to read the pod list on a node can now ask for nodes/pods instead and lose the exec path.

Two things make this worth the effort for an outside team specifically. Direct kubelet access is not subject to admission control and is not recorded in Kubernetes audit logging, so you lose both the gate and the record. And the port is usually 10250 on every worker node, which is a lot of surface for a permission granted once and never revisited.

Database reads through IAM authentication

Read-only database access for contractors is where a shared password usually appears. We skipped that: IAM database authentication on the Aurora cluster, a read-only database user, and rds-db:connect on the IAM side. The grant looks like this, with the cluster resource ID read from the live cluster rather than copied from a template:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["rds-db:connect"],
      "Resource": [
        "arn:aws:rds-db:us-east-2:111122223333:dbuser:cluster-ABCDEFGHIJKL01234/db_user"
      ]
    }
  ]
}

The ARN format is arn:aws:rds-db:{region}:{account-id}:dbuser:{DbClusterResourceId}/{db-user-name}, and AWS's docs are worth reading on two points. The rds-db: prefix is only valid for IAM database authentication and means nothing in any other context, which catches people who write rds:connect and wonder why nothing changes. And the cluster resource ID is Region-unique and never changes, which is what makes the ARN safe to commit.

Connecting uses a generated token rather than a password:

aws rds generate-db-auth-token \
  --hostname mycluster.cluster-ro-abc123.us-east-2.rds.amazonaws.com \
  --port 5432 \
  --region us-east-2 \
  --username db_user

The token is a signed URL, and the example output in the CLI reference carries X-Amz-Expires=900. Fifteen minutes, then the contractor generates another one. We enabled IAM authentication on the cluster, granted connect on staging first, ran a reader failover on the staging reader to confirm the pattern survived it, and pointed the vendor at the reader endpoint on a db.r7g.large.

Budget time for the handover. We walked an engineer on the other side through the SSO profile and the token command, and the first failure was neither: a region and profile mismatch between the CLI configuration and the cluster. A token signed for the wrong Region fails as a plain authentication error, so the database user gets blamed first.

Read-only Postgres still returns customer PII

We opened a finding against our own work here, and it was still open when that work wrapped up. The read-only production database user can read customer personal data. Every control above is intact. The IAM policy is scoped to one database user, the token expires in fifteen minutes, the permission set denies five groups of config reads, and the contractor can still run SELECT against a table of customers.

Read-only solves integrity. It does nothing for confidentiality, and a database read is the one place where that gap is widest, because the data plane is the whole point of the grant. The work that closes it is column-level grants on the read-only role, views that project only the columns the task needs, or masking. None of that is a permission-set change, which is why it tends to sit in a backlog behind things that look more urgent.

If you grant read-only production database access to anyone outside the company, treat the contents of those tables as disclosed from that moment. Then decide whether that is acceptable, and write down the answer.

Scoping a temporary read to one session

A separate request arrived during the same work: one person needed production database read access for a short period. The permission set pattern is wrong for that, because it outlives the reason for it.

What we did instead was limit rds-db:connect to that person's session. The resource ARN already names a single database user, so the session scoping is what stops the grant from being reusable by anyone else mapped to the same permission set. AWS's own documentation makes the risk concrete: attach a policy whose resource ARN ends in /jane_doe to Jane, Bob and Diego, and all three connect to the cluster as jane_doe. The ARN does not identify a human. Something else has to.

Per environment, and the internal teams who asked next

We applied the permission set per environment, four of them, rather than once at the organization level. The reason is that the deny list is environment-shaped: the tables that hold form submissions exist in production, the API keys that matter are production keys, and a lower environment with synthetic data does not need the same denies. One policy across four environments would have been tuned for the strictest and would have blocked legitimate reads in the other three. We have written before about what a shared cluster does and does not isolate, and permissions behave the same way.

The second-order effect was the useful one. Once a scoped read-only production permission set existed, an internal product team asked for its own instead of continuing with broad access to lower environments. That is the trade people accept when the narrow option exists and the broad one requires an argument. Build the contractor permission set well and you get a template for everyone who was quietly over-permissioned already.

If you are about to hand an outside team read-only access, start with an inventory rather than a policy document. List the Lambda functions that carry secrets in environment variables and the CloudFormation templates with values hardcoded in them, then list the database users that can read customer rows. The first two tell you what the denies need to cover. The third is the one a permission set cannot fix, and it tends to be the one nobody has looked at. Tell us what you are opening up and we will go through it with you.

Tags: read-only aws access for contractors, iam identity center permission set, least privilege iam, devsecops implementation, aws iam database authentication, kubernetes rbac nodes/proxy