Ask a platform team why an engineer reading one log group during an incident holds
ReadOnlyAccess across a whole production account, and the answer is rarely carelessness.
It is arithmetic. A pre-built role has to serve every request that will ever be made against it, so it
is as wide as the widest need anyone has ever had. Least privilege written once, in advance, for a
population of people and tasks, is never least privilege for the task in front of you.
This post explains the alternative: write the permission per request, from what the person actually asked for, inside a ceiling the organisation set in advance. It follows two requests from the demo organisation, Northwind Labs, through the least-privilege scoper in InsightKeys, and is frank about where the approach stops.
In short
- Pre-built roles fail by design. A role shared by many tasks must cover all of them, so every grant carries the permissions of the widest one.
- Scope each grant from three inputs. The request’s own words, the ceiling the resource owner set for that entitlement, and what similar past grants actually used.
- The ceiling is a governance decision; the scope is an operational one. Owners decide the most anyone may get. The scoper decides how much less this request needs, and never more.
- Narrow grants change the risk and the route. In the demo, naming one log group or one table takes 10 points off the risk score, which can be the difference between an automatic approval and a wait.
- It is not magic. A scope can be too narrow, and the scoper only knows what the catalogue and the history tell it. A new, small request is the remedy, and it is quick.
Why roles drift wide
Most access programmes start with a sensible-looking catalogue: a read role, an operator role and an
administrator role per account or database. Each is designed in a workshop, approved by a committee
and then left alone. The trouble begins the first time a real task does not fit. An engineer needs
to read CloudWatch logs and also start a Logs Insights query; the read role lacks
logs:StartQuery, so someone adds it, or adds logs:* to save the next
person the trouble. Repeat that for two years and the read role is a collection of every action
anyone has needed once.
Nobody reviews the sum, because nobody owns it. The role is shared by dozens of people and
hundreds of tasks, so narrowing it would break someone. The safe move for any individual is always
to add, never to remove. That is how organisations end up granting *:Describe*,
*:Get* and *:List* across an account to someone who needs to read the logs
of one service.
data
Cloud permission studies have repeatedly found that the large majority of permissions granted to identities are never used. Separately, breach reports year after year list stolen or misused credentials among the most common ways attackers get in, which means unused permissions are not harmless: whatever a credential can do, an attacker holding it can do too.
Sources: Microsoft, State of Cloud Permissions Risks report (2023); Verizon Data Breach Investigations Report (annual editions). Exact percentages vary by study and year.
A read role sized for the widest read task anyone has had. Granted whole, every time, to everyone who asks for “read”.
The same read entitlement as a ceiling, and each grant cut down to the actions and resources this task needs, for this hour.
Three inputs: the words, the ceiling, the history
Per-request scoping only works if it is safe by construction. InsightKeys gets there by separating who decides the most that can be granted from who decides how much this request gets.
The ceiling. Every resource in the catalogue has entitlements, typically Read,
Write and Admin, and each entitlement has a ceiling: the widest permission that entitlement can ever
become, set in the catalogue and agreed with the resource owner. For payments-db (Postgres) the Write entitlement’s
ceiling is DELETE, INSERT, SELECT and UPDATE on every table. The scoper works strictly inside it. It
can remove actions and name resources; it cannot add an action the ceiling does not hold.
The words. The request is a sentence: “look at the payments-api logs on prod-payments for INC-4412, 1 hour.” The intake agent has already matched it to a resource in the catalogue (it never invents one) and a level. The scoper reads the same sentence for the things a role cannot know: which service, which table, which namespace, whether the task reads or changes.
The history. Similar past grants show what people doing this kind of task actually used. If everyone who has read payments-api logs during an incident used the same six log actions, a seventh is unlikely to be needed.
Two further limits sit outside the control plane altogether. The broker that does the granting
runs in the customer’s network and has its own local policy file, broker.yaml,
which lists actions it will never grant whatever it is told: by default iam:*,
organizations:* and sts:*. It can also refuse admin altogether. So there
are three layers: the owner’s ceiling, the scoper’s narrowing, and the broker’s
own refusal list, owned by the customer and enforced where the credential lives.
set by the owner"] --> S S --> P["Scoped permission
actions and named resources"] P --> R["Risk score
narrow scope -10"] R --> B["Broker local policy
never-grant list"] B --> G["Grant in AWS,
PostgreSQL or Kubernetes"]
The ceiling bounds the scoper; the broker’s own policy bounds everything the control plane sends.
Worked example: four log actions instead of read on everything
Priya, an SRE at the demo organisation, is on call for payments when incident INC-4412, “Settlement batch delayed for EU merchants”, opens. Priya types one sentence: “look at the payments-api logs on prod-payments for INC-4412, 1 hour.” The request becomes AR-0001.
The Read entitlement on the prod-payments AWS account has a ceiling of
*:Describe*, *:Get*, *:List* and
cloudwatch:GetMetricData. Granted whole, that would let Priya describe and read
almost anything in an account the catalogue marks as critical and holding payment data and
personal data.
The scoper narrowed it to six CloudWatch Logs actions — logs:DescribeLogGroups,
logs:DescribeLogStreams, logs:FilterLogEvents,
logs:GetLogEvents, logs:GetQueryResults and logs:StartQuery
— on one resource pattern:
arn:aws:logs:*:411122223333:log-group:/ecs/payments-api*. That is the log groups of
one service, in one account, and nothing else.
The narrowing shows up in the risk score too. AR-0001 scored 30, Medium: production +30, critical resource +20, sensitive data +8, one hour or less −3, first time Priya has had this access +5, narrow scope on named resources −10, on call for the resource’s team −10, and an open incident referenced −10. Flow #10, “Read access: automatic when the risk is low”, allows automatic approval at 30 or below. Without the narrowing, the same request would have scored 40 and waited for a person.
That is the operational case for per-request scoping. It is not only safer; it is faster for the engineer, because a narrow grant is a cheap grant to approve.
What happened next shows the security case. During the grant, Priya’s session made the two
log calls the request asked for, then a call to secretsmanager:GetSecretValue on
prod/payments/stripe-key. AWS denied it, because the scoped policy did not include it.
Under the unscoped ceiling, *:Get* would have allowed it. Session watch flagged the
call as sensitive and asked security to approve a revoke. Whatever the explanation turns out to be,
the payment provider’s key was never read.
Worked example: one table instead of every table
Databases show the same pattern. Sam, an engineer, asks: “write access to fix one invoice
status in the payments database for 30 minutes, OPS-219.” That is AR-0004. The Write
entitlement on payments-db (Postgres) has a ceiling of DELETE, INSERT, SELECT and
UPDATE on *.*. The request mentions invoices and a status fix. The scoper narrowed it
to INSERT, SELECT and UPDATE on billing.invoices. DELETE went; every other table
went.
When the broker creates the grant, it creates a PostgreSQL login role for this grant alone, with
VALID UNTIL set to the expiry and table-level privileges on
billing.invoices. At revoke, the broker terminates that role’s sessions and
removes it. The same mechanism produced AR-0008, a read request during INC-4412, which the scoper
narrowed from SELECT on *.* to SELECT on billing.invoices.
| Request | Ceiling (what a pre-built role would grant) | Scoped grant | Effect |
|---|---|---|---|
| AR-0001 · logs on prod-payments | *:Describe*, *:Get*, *:List*, cloudwatch:GetMetricData | Six logs: actions on /ecs/payments-api* | Secret read attempt denied by AWS; auto-approved at 30 |
| AR-0004 · fix an invoice | DELETE, INSERT, SELECT, UPDATE on *.* | INSERT, SELECT, UPDATE on billing.invoices | No delete; one table; owner approves |
| AR-0005 · roll back a deploy | AdministratorAccess (*) | ecs:Describe*, ecs:List*, ecs:StopTask, ecs:UpdateService on service/auth* | Admin request still goes to two people, but for a fraction of the power |
| AR-0007 · crash-looping pods | Read on the namespace | get, list, watch on eks-prod/payments | Read verbs only; auto-approved at 14 |
| AR-0008 · read invoices | SELECT on *.* | SELECT on billing.invoices | A real Postgres login for one table, gone at expiry |
What changes for the business
The reason to care is not elegance. Per-request scoping changes three things a security leader is accountable for.
Exposure. If a credential is stolen during a grant, the damage is bounded by what that grant allows. A session that can read one service’s logs for an hour is a very different incident from a session that can read every secret and object in a production account. Exposure is the product of breadth and time; JIT shrinks the time, scoping shrinks the breadth.
Approval quality. Approvers asked to approve “Write on payments-db” are asked to trust. Approvers asked to approve “INSERT, SELECT and UPDATE on billing.invoices for 30 minutes, ticket OPS-219” can actually judge. A concrete permission is the precondition for an approval meaning anything.
Evidence. An auditor sampling access changes wants to see that what was granted matched the business need. The record for every request shows the ceiling, the scoped permission, who or what narrowed it, and what the person did with it.
For the CISO
Least privilege stops being a policy statement and becomes a property of every individual grant, visible on each request.
The broker’s never-grant list means a mistake in the control plane cannot hand out IAM or Organizations permissions.
For the platform lead
Owners set ceilings once; nobody maintains a growing zoo of special-case roles.
Engineers get a narrower grant faster, because narrow scope lowers the risk score and more requests clear automatic approval.
For the board
The question “what could someone have done with that access?” has a short, specific answer for every grant.
That answer is what limits the cost of a stolen credential.
Rolling it out
Per-request scoping is only as good as the ceilings and the catalogue it works from. The order of work matters.
Connect the broker and discover AWS accounts and permission sets, PostgreSQL roles and Kubernetes bindings. Use what it finds to build the catalogue: each resource, its entitlements and a named owner.
Write down the widest Read, Write and Admin each resource should ever allow. Existing role definitions are a reasonable first draft; trim them.
Review the never-grant list in broker.yaml, the longest grant it will create, and whether admin may be granted at all.
The scoper defaults to L3, act and tell. Organisations that want to watch first can set it to L2, ask first, and review its narrowing before it applies.
Look at session events for denied calls that were in fact needed, and at follow-up requests raised minutes after a grant. Adjust ceilings or catalogue wording.
Once requests flow through the catalogue, convert standing access to the old roles into on-request access.
Where it stops
What per-request scoping does not do
The scoper narrows from the request’s words and from history; it does not read the application’s code, so it can cut a permission the task turns out to need. When that happens the person sees an access-denied error and asks again with a narrower or different request — quick, but not free. Granularity follows the integration: action and resource level for AWS, table level for PostgreSQL (not row or column level), namespace and verbs for Kubernetes. A ceiling the owner set too wide is still too wide; the scoper narrows within it, it does not question it. Drivers for Google Cloud, Azure, MySQL, MongoDB and Snowflake are on the roadmap, not available today.
There is also a point of judgement. Some teams will prefer a slightly wider grant that never fails over a narrow one that occasionally does, particularly in break-glass situations. Flow #5 in the demo organisation, break-glass for whoever is on call, exists for exactly that: it trades breadth for speed under step-up, tells security, caps the grant at one hour and puts it in front of a reviewer afterwards. Per-request scoping is the default; it does not have to be the only path.
A role is a guess about every future task. A scoped grant is an answer to this one.
Let owners set the most anyone may have, let each request take only what its own words and history justify, and let the broker refuse what nobody should be granted at all.