Access requests: provision on approval, or hold for review¶
Managers approve almost every access request they see, so "approved"
on its own says little about whether someone should hold production
access. Every rubber-stamped grant becomes standing access that shows
up later as an audit finding. This page shows how the library lets an
access workflow provision automatically when the requester's peers
already hold the same access, and send the request to security review
when they don't. It is written for identity-governance (IAM) owners and
access-desk leads. The runnable example id is workflow_liveness; the
page calls it the access-request workflow.
At a glance
| Decision | After a manager approves, grant the entitlement automatically, or hold for a security reviewer |
| Owner | The IAM / identity-governance team; security reviewers handle holds |
| Today's rule | Provision as soon as a manager approves |
| What this adds | Peer-group analysis: do the requester's peers hold this entitlement, consistently? |
| Demo outcome | 2 requests: the payments SRE is provisioned, the marketing analyst goes to security review |
| Example id | workflow_liveness (uv run grounded-decision examples workflow_liveness) |
The decision today¶
An employee submits a request ("read access to the production payments database"), a manager approves or rejects it, and on approval the identity system provisions it. The workflow is written as a short policy, the text that lists which events lead to which actions:
Read it as: wait for submit; then either approve, which proposes
provision, or reject, which proposes close; repeat for the next
request. test(x) means "event x arrives", act(y) is the action
proposed at that point, ; is "then", | is "or", * is "repeat".
Write a policy explains the language.
| Mistake | What it costs |
|---|---|
| Provisioning someone who shouldn't have it | Standing over-provisioned access; an audit finding; a larger blast radius |
| Holding someone who should have it | A new hire waits for a reviewer before they can do their job |
Where today's rule goes wrong¶
Two requests in the demo, both approved by a manager:
- A new payments SRE asks for production payments-database read access. All 13 peers (their own team of 7 plus the sister payments-API team of 6) already hold it. Provisioning is clearly right.
- A marketing analyst asks for the same access. Of four teammates, one (an ex-engineer) holds it. The manager approved anyway. Naive workflow code provisions.
Peer-group analysis is a standard identity-governance technique: an entitlement that the requester's peers consistently hold is low-risk; one that almost none of them hold deserves a second look.
What the system checks, in your terms¶
Before provision runs, the library asks six yes/no checks. Any
"no" means the decision is held. Four read the peer-group data sent
with the request; two check the policy once, when it is compiled. Three
of the four are below; the fourth, support, is not measured in this example and passes with support not measured.
| Check | Question in IAM terms | Data it reads | Fails when | What a hold means |
|---|---|---|---|---|
| Neighbour agreement | Do linked peers tend to agree on holding this entitlement, so the peer group tells us something? | Who holds it, and the peer links (same team; sister-team leads) | Holding is scattered with no pattern (the analyst's team: one holder among four) | "The peer group gives no signal; a reviewer decides" |
| Knife-edge | Is the peer group's link density so close to our cut-off that one more or fewer link would flip the reading? | Peer count and links; your density cut-off (0.15) | Density sits within the window around the cut-off; the window is wide for small groups | "The peer reading is unstable" |
| Unusual case | Does this request look like grants that survived past access reviews? | The share of peers without the entitlement, against the same share for grants that survived review | More peers lack it than for almost any surviving grant (the analyst: 75% lack it) | "We have never approved a grant like this that survived review" |
| Policy shape | Is the policy simple enough to compile and audit? | The policy text | Never at decision time; see the box below | — |
| Progress | Can the workflow keep moving? | The policy | Never in this demo; see the warning | — |
What these checks do not verify
Direction. Neighbour agreement and the knife-edge check measure whether the peer group is informative and stable, not whether it supports granting. If all 13 peers lacked the entitlement, they would agree perfectly and pass both. In this example the unusual-case check sees the share of peers without the access, so that request is held anyway (we ran it: unusual-case score 0.032, held). Keep that score pointed this way if you adapt the example.
"Can the workflow keep going?" assumes events keep arriving.
The progress check passes because, from the start, every loop of
this policy goes through provision or close: it cannot cycle
forever without proposing an action. It does not detect a
request that waits forever because nobody sends the next event (a
manager who never answers): a state waiting for an event is not a
loop. An approval that arrives before the request is not handled
(nothing moves, nothing is proposed), so it cannot strand a request
either. Despite the example's id, nothing here proves the workflow
always finishes. Put an expiry on unanswered requests in your
ticketing system.
The policy is checked when it is written
When the workflow starts, the library compiles the policy and
refuses one whose repetitions are nested more than three deep (here
the depth is 1). The demo also tries ((((test(a))*)*)*)*, four
levels deep; compiling it raises StarHeightError: star-height 4
exceeds max 3. A policy that deep is hard for a person to audit, so
the error stops it before any request is served. The library does
not rewrite it for you.
Data you provide¶
| Input | Source system | Refresh | Owner | Minimum and quality bar |
|---|---|---|---|---|
Team membership: team -> [employee ids], requester excluded, lead first |
HR system | Daily | HR / IAM | Include sister teams under the same director |
| Sister-team pairs (their leads are linked) | Org chart | Monthly | IAM | — |
| Current holders of the entitlement | Identity provider | Per request | IAM | Read at request time |
Density cut-off (PEER_GROUP_DENSITY_THRESHOLD, 0.15) |
Past access reviews | Per review cycle | IAM lead | The density where peer groups that justified a grant and those that didn't were hardest to tell apart |
Calibration set: share of peers without the entitlement, for grants that survived review (REVIEWED_GRANT_PEERS_WITHOUT_SHARES, 30 values) |
Quarterly access review | After every review | IAM | At least 9 entries at the default 10% setting; only grants that survived review; never the request being decided |
The calibration values (0.00 to 0.58) are synthetic. Outcomes arrive only at the next quarterly review, so the set lags by up to a quarter. New teams have few peers; see the knife-edge note under tuning.
Worked example¶
Each request sends two events: submit, then approve.
| Request | Event | What the system sees | Checks | Verdict |
|---|---|---|---|---|
| Payments SRE | submit |
All 13 peers hold it | All pass | Waiting: nothing is due until a manager decides |
| Payments SRE | approve |
Same | All pass | Act provision |
| Marketing analyst | submit |
1 of 4 peers holds it | Neighbour agreement and unusual case fail | Waiting with failed checks: nothing was proposed, but the record already shows the problem |
| Marketing analyst | approve |
Same | Same failures | Hold, proposed provision; goes to security review |
A submit is not a hold. The record's verdict says abstain with no
proposed action and the reason "no action marked on current state"; the
command-line output labels it await_next_event. On the analyst's
submit, checks failed but nothing was proposed, so there was nothing
to hold yet; the CLI still prints hold_for_human for that row. Treat
it as an early signal that the approval will be held.
The numbers
From uv run grounded-decision examples workflow_liveness (and
--json). \(n\) is the number of peers, \(d\) the average links per
peer, \(\lambda\) how much more often linked peers agree than chance
(0 = no better than chance), \(d\lambda^2\) the evidence strength
(must exceed 1.05), \(\hat p\) the link density, \(p\) the
unusual-case score (must exceed 0.10).
| Payments SRE | Marketing analyst | |
|---|---|---|
| \(n\) peers, links | 13, 37 (21 + 15 + 1 lead link) | 4, 6 |
| \(d\), \(\lambda\) | 5.69, 1.0 | 3.0, 0.0 |
| \(d\lambda^2\) (> 1.05) | 5.69 pass | 0.00 fail |
| \(\hat p\) vs cut-off 0.15 | 0.474, half-window 0.245, pass | 1.0, half-window capped at 0.5, pass |
| share of peers without it | 0.00 | 0.75 |
| \(p\) (> 0.10) | 1.0 pass | 1/31 = 0.032 fail |
| saved state after submit / approve | s2 / s3 |
s2 / s3 |
The states s0, s2, s3 are numbered by the compiler from the
policy text. Editing the policy can renumber them.
Summary: provisioned sre_new_hire; sent to security review
analyst_4; policy depth 1; the four-deep policy rejected.
What happens next¶
Waiting (after submit): park the request as "submitted", save the
returned state, and wait for approve or reject. Do not provision.
Act (approve, all checks pass): grant the entitlement and log the
record.
Hold (approve, a check failed): do not provision. Open a
security-review ticket with proposed_action and reason, and tell the
requester it is under review. Give reviewers a target (for example two
business days) and an expiry after which the request is rejected and
must be resubmitted, so holds don't pile up. A reviewer who grants
manually should record why; at the next access review that grant either
survives (and enters the calibration set) or is revoked.
Reject: the policy proposes close; close the ticket.
State after a hold. The saved state moves to s3 even on a hold,
because the policy moves before the checks run. In this policy that is
harmless: the next submit starts a new request from there. In policies
where later events depend on the action having run, see the
incident example.
An unknown event (a typo such as aprove) returns the same "no action
marked" reason as waiting and leaves the state unchanged. Check that the
returned state moved if you need to tell them apart.
Tuning and expected workload¶
- Density cut-off (0.15): see Choose thresholds and calibration.
- Small peer groups. With 6 or fewer peers the knife-edge window is ±0.5 around the cut-off. A small fully linked team (density 1.0) passes a 0.15 cut-off; a sparse one will be held. New teams with very few peers will mostly go to review; that is usually what you want.
- Unusual-case level (default 10%): raising it holds more.
- Review load. Replay last quarter's approvals with Estimate impact before launch to estimate how many go to reviewers.
Limits, risks and compliance¶
- Peer-group data is synthetic in the demo.
- Peer-based approval reproduces existing access patterns, including over-provisioned ones. Keep periodic access reviews; this does not replace them.
- Separation-of-duties rules (a person may not hold both of two entitlements) are a separate check your IAM system must enforce.
- Saved states belong to one policy version. Store a policy fingerprint with each request's state (State and policy versions).
Wiring it in¶
- At service start,
workflow_module = build(). A policy nested too deeply fails here, before any request is served. - After every access review, refresh
REVIEWED_GRANT_PEERS_WITHOUT_SHARES. - Per event, build the peer-group evidence and round-trip the request's saved state:
from grounded_decision.integrate import handle_request
evidence = peer_group_evidence(team_members, sister_teams, entitlement_holders)
response = handle_request(
workflow_module,
{
"state": load_request_state(request_id), # None on submit
"event": Event("approve", {"requester": requester}).to_dict(),
"evidence": evidence.to_dict(),
},
)
save_request_state(request_id, response["extras"]["state"])
if response["verdict"] == "act":
grant_entitlement(requester, response["action"])
elif response["proposed_action"]:
open_security_review(request_id, response["reason"])
# else: waiting for the next event
On submit, "state" is None (the module starts from s0) and
comes back as s2; pass it back on approve.
load_request_state, save_request_state, grant_entitlement and
open_security_review are yours.
- Concurrency: the module is not thread-safe.
handle_requestrestores state at the start of every call; use one module per worker or a lock.
Run it yourself¶
| CLI label | Meaning here |
|---|---|
fire, ACT |
act: provision |
HOLD, hold_for_human |
hold (the record's verdict is abstain) |
await_next_event |
waiting: nothing was due at this step |
cursor, extras['state'] |
saved state |
flicker band |
knife-edge window |
bag |
calibration set |
KS, need > 1.05 |
the evidence-strength line |
max nested loops, star-height |
how deeply repetitions nest in the policy |
Further reading¶
- Write a policy and The six checks
- Act, abstain, degrade and Troubleshoot: why did it abstain?
- Glossary
- Theory: star height (catalog result 134), parity and mean-payoff games (104), Kesten–Stigum reconstruction (229), Friedgut–Kalai thresholds (186), conformal prediction.