pogi
Tutorials

Least privilege

Record what your code calls, get a least-privilege policy, then enforce it on AWS.

By the end, you have the smallest set of permissions your code needs, and you've proved it works. All on your machine.

IAM (identity and access management) decides who may call what in a cloud. Least privilege means giving code the permissions it needs and no more. The hard part is knowing which ones those are. You do it in four moves:

  1. Record the calls a user makes.
  2. Read the policy pogi writes from them.
  3. Switch to strict mode, where every call without a policy fails.
  4. Attach the policy. Allowed calls pass and everything else still fails.

Before you start: pogi is running, your shell has eval "$(pogi env)" loaded, and the AWS CLI is installed. See First run.

1. Create a user and a key

The default test credentials are always allowed. Create a user with no permissions, and a key for it:

aws s3 mb s3://demo
aws iam create-user --user-name dev
aws iam create-access-key --user-name dev

The last command prints an AccessKeyId and a SecretAccessKey. Keep them for step 3.

2. Switch to soft mode

Soft mode allows every call and records what each user and role calls.

curl -X POST -d mode=soft http://localhost:7644/_pogi/iam/mode
pogi status | head -1

The first command prints nothing. The second shows the mode at the end of its line:

Output
pogi 0.1.0 at http://localhost:7644 · up 57s · region us-east-1 · persistence false · iam soft

3. Run your code as that user

In the same terminal, use the new key in place of the test credentials:

export AWS_ACCESS_KEY_ID=<AccessKeyId from step 1>
export AWS_SECRET_ACCESS_KEY=<SecretAccessKey from step 1>
aws s3 ls
aws sns create-topic --name alerts
aws sns list-topics
Output
2026-10-13 21:08:23 demo
{
    "TopicArn": "arn:aws:sns:us-east-1:000000000000:alerts"
}
{
    "Topics": [
        {
            "TopicArn": "arn:aws:sns:us-east-1:000000000000:alerts"
        }
    ]
}

Every call was allowed, and pogi wrote down each action and the resource it touched. Calls signed with the default test credentials are never recorded. Only calls from an IAM user's access key, or from an assumed role, are.

4. Read the policy

curl "http://localhost:7644/_pogi/iam/policy?principal=arn:aws:iam::000000000000:user/dev"
Response
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "sns:ListTopics",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "sns:CreateTopic",
      "Resource": "arn:aws:sns:us-east-1:000000000000:alerts"
    }
  ]
}

There is one statement for each action. Resources are narrowed to the ARNs (resource names) your code used. Add &fmt=tf to the address to get Terraform instead.

The console shows the same. Open IAM policy. For each user and role you see the actions called, whether strict mode would deny them, and the policy in JSON or Terraform.

The IAM page in soft mode: three called actions flagged "would deny" and the generated JSON policy.

5. Switch to strict mode

Strict mode denies every call that no policy allows. The dev user has no policy yet.

curl -X POST -d mode=strict http://localhost:7644/_pogi/iam/mode

This prints nothing. Then the IAM page flags every recorded action:

The IAM page in strict mode before the policy is attached: three called actions flagged "would deny".

Don't run the user's calls yet. Strict mode records denied calls too, so attaching later would allow them.

6. Attach the policy

Attaching saves the policy in pogi and links it to the user. In the console, choose Attach on the IAM page. pogi shows what the new version adds, and you confirm.

The attach step: the policy name, the new version and the three actions it adds.

From the command line, post the form the button sends:

curl -X POST --data-urlencode "principal=arn:aws:iam::000000000000:user/dev" \
  http://localhost:7644/_pogi/iam/attach

This prints nothing either. To check, open a new terminal and run eval "$(pogi env)". That gives you the default credentials, which are never checked. Then:

aws iam list-attached-user-policies --user-name dev
Output
{
    "AttachedPolicies": [
        {
            "PolicyName": "pogi-dev",
            "PolicyArn": "arn:aws:iam::000000000000:policy/pogi-dev"
        }
    ]
}

The policy is named pogi-dev. pogi keeps its older versions, and you can undo an attach in the console.

Back on the IAM page, all three actions now show "allowed".

The IAM page in strict mode after attaching: all three actions now show "allowed".

7. Run your code again

Run as the dev user again, with the key from step 1. In the terminal where you set the key, run:

aws s3 ls
aws sns list-topics --query 'Topics[0].TopicArn' --output text
aws sqs list-queues
aws sns publish --topic-arn arn:aws:sns:us-east-1:000000000000:alerts --message hi
Output
2026-10-14 01:58:01 demo
arn:aws:sns:us-east-1:000000000000:alerts

aws: [ERROR]: An error occurred (AccessDeniedException) when calling the ListQueues operation: User: arn:aws:iam::000000000000:user/dev is not authorized to perform: sqs:ListQueues on resource: * because no identity-based policy allows the sqs:ListQueues action

aws: [ERROR]: An error occurred (AccessDenied) when calling the Publish operation: User: arn:aws:iam::000000000000:user/dev is not authorized to perform: sns:Publish on resource: arn:aws:sns:us-east-1:000000000000:alerts because no identity-based policy allows the sns:Publish action

The two calls you recorded pass. The two you did not record fail with AccessDenied, with the same message AWS gives. In the console, Activity has an IAM denied filter. The IAM page now lists the two denied calls too, flagged "would deny".

Your code can now make the calls it made, and no others. If it needs another action, run it in soft mode, then attach again. pogi keeps the older versions of the policy.

The same on Google Cloud and Azure

The IAM page has tabs for AWS, Google Cloud and Azure. The steps are the same. Only the names change.

Google Cloud

pogi records calls made with an access token of a service account you created in IAM.

  1. Create a service account. gcloud prints its email address. Use it wherever you see <service-account-email>.

    gcloud iam service-accounts create worker1
    Created service account [worker1].
  2. Get a token for it:

    curl -X POST "http://localhost:7644/v1/projects/pogi/serviceAccounts/<service-account-email>:generateAccessToken" \
      -H "content-type: application/json" \
      -d '{"scope":["https://www.googleapis.com/auth/cloud-platform"]}'
    {"accessToken":"ya29.pogi-eyJlIjoid29ya2VyMUBw…","expireTime":"2026-10-13T16:39:04.390322Z"}
  3. Make a call with the token:

    curl -H "Authorization: Bearer <accessToken>" "http://localhost:7644/storage/v1/b?project=pogi"
    {"items":[],"kind":"storage#buckets"}
  4. Read the roles the service account needs, as a gcloud command:

    curl "http://localhost:7644/_pogi/iam/gcp/snippet?principal=serviceAccount:<service-account-email>"
    gcloud projects add-iam-policy-binding pogi --member=serviceAccount:<service-account-email> --role=roles/storage.admin
  5. Attach the roles. Choose Attach on the Google Cloud tab, or post to /_pogi/iam/gcp/attach with principal=serviceAccount:<service-account-email>. Then gcloud projects get-iam-policy pogi lists the binding.

Azure

pogi records calls made with the token of a service principal or managed identity that you created. It turns them into a least-privilege custom role. The Azure tab shows the role as JSON or as Azure CLI commands, and has the same Attach button.

Calls made with pogi's default credentials are never recorded or denied on any cloud.

See also

On this page