Skip to content

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:

(test(submit) ; (test(approve) ; act(provision) | test(reject) ; act(close)))*

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

  1. At service start, workflow_module = build(). A policy nested too deeply fails here, before any request is served.
  2. After every access review, refresh REVIEWED_GRANT_PEERS_WITHOUT_SHARES.
  3. 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.

  1. Concurrency: the module is not thread-safe. handle_request restores state at the start of every call; use one module per worker or a lock.

Run it yourself

uv run grounded-decision examples workflow_liveness
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