---
name: iam-least-privilege-review
description: "Reviews an existing IAM / RBAC policy for grants that are broader than the workload needs — wildcard actions (`*`, `s3:*`), `Resource: \"*\"`, unscoped `iam:PassRole`, `NotAction`/`NotResource` inversions, self-grant escalation actions, wildcard principals, and the Kubernetes analogs (`verbs: [\"*\"]`, cluster-wide secret reads, `escalate`/`bind`) — and proposes the scoped-down equivalent for each. Use when tightening an AWS IAM policy, a Kubernetes Role/ClusterRole, or similar access grant. Do NOT use for deciding whether to approve a person's access request, for writing an application's authorization logic, or for secret-scanning."
metadata:
  decimalai-skill-id: "38e9af4c-7ed5-4c7f-b881-6197c56ca381"
  decimalai-version-id: "f42c1156-8702-4460-973b-6f917064914e"
  decimalai-version: "1"
  decimalai-body-sha256: "6c8e3077458ad9d8b04ae6d0ce60b6306100b71dc081772b5f490a9a1550f11b"
  decimalai-name: "iam-least-privilege-review"
  decimalai-format: "1"
  decimalai-invocation: "model"
  decimalai-category: "security"
  decimalai-source: "https://app.decimal.ai/s/iam-least-privilege-review@1/SKILL.md"
---
# IAM Least-Privilege Review

A policy that grants more than the workload uses is a standing liability: every extra action is something a leaked credential, a compromised pod, or a confused-deputy call can do. The default review waves a policy through because it *works* — the job runs — but "it runs" only proves the grant is big enough, never that it is small enough. This skill reviews an existing grant for the ways it is broader than intent, and names the scoped-down replacement for each, so the output is an actionable tighter policy rather than "looks fine" or a vague "use least privilege".

## When to activate

Activate when the request hands you an **existing access grant to review or tighten** — an AWS IAM policy (identity or resource/trust), a Kubernetes `Role`/`ClusterRole` + binding, an Azure/GCP role assignment, or a similar policy document — especially one carrying wildcards.

Do **not** activate for: deciding whether to approve/deny a *person's* access request (that is a policy-decision task, not a policy-rewrite), writing an application's own authorization/permission-check code, tuning firewall/network rules, or scanning source for leaked secrets.

## Over-permission pattern catalog

Each entry: the broad grant, why it exceeds intent (the risk), and the scoped replacement to recommend.

**Full tables:** see `references/iam-lookups.md` — the body covers each pattern and its scope-down; the specific escalation method behind a given `iam:` action (the Rhino-style catalog), and the exact list of actions where `Resource: "*"` is legitimately required, are lookups.

### AWS IAM

| Grant | Why it's over-permissioned | Scoped replacement |
|---|---|---|
| `"Action": "*"` | every action in every service — the whole account | enumerate only the actions the workload calls |
| service wildcard `"Action": "s3:*"` | folds destructive/control-plane verbs (`s3:DeleteBucket`, `s3:PutBucketPolicy`, `s3:PutBucketAcl`) in with the data-plane `GetObject`/`PutObject` you meant | list the specific actions; prefer the read-only subset when no write is needed |
| `"Resource": "*"` on a resource-scoped action | grants the action on *every* bucket/table/queue, not the one in use | pin to the specific ARN(s) |
| `iam:PassRole` with `"Resource": "*"` | privilege escalation — the principal can hand **any** role (including admin) to a service it launches | scope to the exact role ARN(s); add an `iam:PassedToService` condition |
| `iam:Attach*` / `iam:PutRolePolicy` / `iam:CreatePolicyVersion` / `iam:CreateAccessKey` / `iam:UpdateAssumeRolePolicy` | self-grant paths — whoever can attach a policy or mint a key can make themselves admin | remove unless the role's job *is* IAM administration; if kept, scope + condition |
| `NotAction` / `NotResource` in an `Allow` | inverts to "everything **except** …" — an implicit broad grant that silently widens as the provider ships new actions | rewrite as an explicit `Action`/`Resource` allow-list |
| `AdministratorAccess` / `PowerUserAccess` managed policy on a workload role | grants the account to something that touches a few services | replace with a scoped customer-managed policy for just those services |
| `"Principal": "*"` or `"AWS": "*"` in a resource/trust policy | anyone (any AWS account) can access or assume | pin to specific account IDs / role ARNs; add `sts:ExternalId` or `aws:SourceAccount` |
| no `Condition` on a sensitive grant | nothing constrains *where from* or *how* | add guardrails: `aws:SourceArn`, `aws:PrincipalOrgID`, `aws:SourceIp`, `aws:MultiFactorAuthPresent` |

### Kubernetes RBAC

| Grant | Why it's over-permissioned | Scoped replacement |
|---|---|---|
| `verbs: ["*"]` | includes `delete`, `deletecollection`, `patch` alongside the reads you wanted | enumerate verbs; `get`/`list`/`watch` for read-only |
| `resources: ["*"]` / `apiGroups: ["*"]` | every kind in every API group | scope to the specific resources/groups the workload uses |
| `ClusterRole` + `ClusterRoleBinding` where a namespaced `Role` suffices | grants cluster-wide what only one namespace needs | use a `Role`/`RoleBinding` in that namespace |
| `get`/`list` on `secrets` cluster-wide | list-secrets is effectively read-all-credentials | restrict to named secrets via `resourceNames`, or drop |
| verbs `escalate` / `bind` / `impersonate` on roles/users | let the subject grant themselves (or act as) more than they hold | remove unless the workload is an RBAC/admission controller |
| `nonResourceURLs: ["*"]` | every non-resource endpoint | list the specific paths |

## Scope-down method

1. **Establish intent.** What does this identity actually need to do? Derive it from the workload, or from last-used data (AWS IAM Access Analyzer / CloudTrail, `kubectl auth can-i`) — not from the current grant.
2. **Narrow actions.** Replace each wildcard action with the enumerated actions in use; drop write/delete verbs when the job only reads.
3. **Narrow resources.** Replace `Resource: "*"` (or `resources: ["*"]`) with the specific ARNs / kinds / `resourceNames`.
4. **Strip escalation & control-plane grants.** Remove unscoped `iam:PassRole`, `iam:Attach*`, K8s `escalate`/`bind`, and admin managed policies unless administration is the role's actual job; if genuinely needed, keep the narrowest form + a condition.
5. **De-invert.** Turn `NotAction`/`NotResource` allows into explicit allow-lists; turn cluster-wide into namespaced.
6. **Add conditions** as guardrails (source account/org, IP, MFA, `PassedToService`).
7. **Deny by default & verify.** Everything not explicitly needed is simply absent — but check last-access data before removing a grant so the tighter policy doesn't break the workload.

### Don't invent problems (false-positive guards)

- Some actions **only** accept `Resource: "*"` — `s3:ListAllMyBuckets`, most `ec2:Describe*`/`List*`, `cloudwatch:PutMetricData`. `Resource: "*"` there is correct, not a finding.
- Read-only breadth (`Describe`/`List`/`get`) is lower-risk than write breadth — call it out but don't treat it like a write wildcard.
- If a grant is already enumerated and ARN-scoped, say it's least-privilege and move on; confirm what it did right rather than manufacture a rewrite.
- An admin grant on a role whose job genuinely *is* administration is intent, not over-permission — flag missing conditions, not the breadth itself.

## Output

- **Findings:** one per over-permission — the grant, why it exceeds intent (name the escalation risk where there is one), and the concrete scoped replacement.
- **Scoped policy:** the rewritten least-privilege equivalent.
- **Verdict:** *least-privilege* / *N grant(s) to scope down before applying*.

## Example

**Reviewing (an execution role for a job that reads one config bucket and launches one task):**

```json
{
  "Effect": "Allow",
  "Action": ["ssm:*", "iam:PassRole"],
  "Resource": "*"
}
```

**Review:**

- `ssm:*` on `Resource: "*"` — the service wildcard includes control-plane actions (`ssm:DeleteParameter`, `ssm:PutParameter`, `ssm:*Document`) the job never calls, on every parameter in the account. The job only reads its own config path. Scope to `ssm:GetParameter`/`ssm:GetParametersByPath` on `arn:aws:ssm:REGION:ACCT:parameter/job/config/*`.
- `iam:PassRole` on `Resource: "*"` — privilege escalation: this lets the role pass **any** role, including an admin one, to whatever service it starts. Scope to the single task role ARN and add a `Condition` on `iam:PassedToService`.

**Scoped:**

```json
[
  { "Effect": "Allow", "Action": ["ssm:GetParameter", "ssm:GetParametersByPath"],
    "Resource": "arn:aws:ssm:REGION:ACCT:parameter/job/config/*" },
  { "Effect": "Allow", "Action": "iam:PassRole",
    "Resource": "arn:aws:iam::ACCT:role/job-task-role",
    "Condition": { "StringEquals": { "iam:PassedToService": "ecs-tasks.amazonaws.com" } } }
]
```

**Verdict:** 2 grants to scope down before applying.

## Edge cases

- **Wildcard that only supports `*`** (e.g. `ec2:DescribeInstances`): keep it — flagging it would produce an invalid policy.
- **Break-glass / admin role by design:** don't demand you strip its power; check for conditions and MFA instead.
- **Star inside an ARN** (`arn:…:bucket/logs/*` for an object prefix) is resource-scoping, not `Resource: "*"` — that's the good form.
- **Under-scope:** this reviews the breadth of an *existing* grant. It is not an approval decision for a person, not authorization-code review, and not a secrets scan.
