AUTHORIZATION AND POLICY · LAB
Deny-first tests, staged rollout and rollback of a token policy
Test a policy change by the requests it should refuse, run it beside the current version, enforce it on one client, roll it back without editing code, and design an emergency path.
Partly readyIncludes a simulationUses your lab tenant
The lesson
Builds on: Writing rules with attributes.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.
- G22 End-user authorization engine (PDP, RBAC/ABAC/ReBAC for application data)
- G23 Access requests and approvals, access reviews, JIT or temporary access
- G49 Token policy test cases with custom contexts, version history and rollback
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Put the new version on lab-tmp-mobile only
Recorded as
tenant.oauth.managers.assignsucceeded forlab-tmp-mobile.Enforce the new version on lab-printer-app
Recorded as
tenant.oauth.managers.assignsucceeded forlab-printer-app.Roll lab-printer-app back to version 14
Recorded as
tenant.oauth.managers.assignsucceeded forlab-printer-app.See a policy denial explained safely
Recorded as
oauth.tokenrejected (policy_denied) forlab-tmp-mobile.
Setup
You can run a real deny-first test table against the token endpoint, stage a policy by assigning it to one client first, and roll back by reassigning. Saved test cases, shadow evaluation and version history are not built in (G49), and emergency grants are G23.
Complete Permit and forbid rules in a token policy for
lab-printer-appand Mia's verified address.In OAuth > Flow policy, allow the refresh token grant, and allow it on
lab-printer-app.Create
lab-tmp-mobilein OAuth > Clients with the single-page application preset: public, PKCE required, refresh token grant, redirect URIhttp://127.0.0.1:8765/callback, assignedprints.create. It plays the lesson's mobile app.Create two access token managers, JWT with the default key, and do not assign them yet.
lab-tmp-policy-v14gets the policy below.lab-tmp-policy-v15gets the same policy plus the line marked new in v15, the News desk's stricter rule with a stale sign-in standing in for a risky session. SetEMBARGO_UNTILto 5 minutes after you finish step 5, withdate -d '+5 min' +%s.
const EDITORS = ['[email protected]'];
const EMBARGO_UNTIL = 1790000000;
const now = context.claims.iat;
let scopes = [...context.scopes];
const drop = name => { scopes = scopes.filter(item => item !== name); };
if (!(context.subject.email_verified === true && EDITORS.includes(context.subject.email))) drop('prints.create'); // editor-print
if (now < EMBARGO_UNTIL) drop('prints.create'); // embargo
// new in v15: if (typeof context.auth_time !== 'number' || now - context.auth_time > 900) drop('prints.create');
return { allow: true, claims: {}, scopes };
While both clients still use the default manager, collect one refresh token per person and client. A refresh request runs the client's current policy again, with the time of the original sign-in, which makes it a repeatable test request. Define the helpers, then run
signin printer mia,signin printer ava,signin mobile miaandsignin mobile ava, opening each URL in that person's window.
declare -A RT
PRINTER_ID="<lab-printer-app client ID>"; MOBILE_ID="<lab-tmp-mobile client ID>"
client() { [ "$1" = printer ] && echo "$PRINTER_ID" || echo "$MOBILE_ID"; }
signin() { # $1 = printer or mobile, $2 = person
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$(client "$1")&redirect_uri=http://127.0.0.1:8765/callback&scope=openid%20photos.read%20prints.create%20offline_access&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256"
btl-lab callback
read -rsp "Code: " CODE; echo
RT[$1:$2]=$(curl -s "$ISSUER/oauth/token" -d grant_type=authorization_code -d "code=$CODE" -d redirect_uri=http://127.0.0.1:8765/callback \
-d "client_id=$(client "$1")" -d "code_verifier=$VERIFIER" | jq -r '.refresh_token // empty')
[ -n "${RT[$1:$2]}" ] && echo "stored a refresh token for $1 $2"
}
row() { # $1 = printer or mobile, $2 = person, $3 = expected: yes or no
local resp next got
resp=$(curl -s "$ISSUER/oauth/token" -d grant_type=refresh_token -d "refresh_token=${RT[$1:$2]}" -d "client_id=$(client "$1")" -d "scope=photos.read prints.create")
next=$(echo "$resp" | jq -r '.refresh_token // empty'); [ -n "$next" ] && RT[$1:$2]=$next
got=$(echo "$resp" | jq -r 'if .error then "error" elif ((.scope // "") | split(" ") | index("prints.create")) then "yes" else "no" end')
if [ "$got" = "$3" ]; then echo "PASS $1 $2 expects $3"; else echo "FAIL $1 $2 expected $3, got $got"; fi
}
Walkthrough
Write the expected results before running anything. Half the rows expect a refusal, and most refusals sit next to an allowed row that differs in one value: the time, the person, or the age of the sign-in.
| When | printer, Mia | printer, Ava | mobile, Mia | mobile, Ava |
|---|---|---|---|---|
| Before the embargo | no | no | no | no |
| After the embargo, within 15 minutes of signing in | yes | no | yes | no |
| More than 15 minutes after signing in | yes | no | no | no |
Why it matters: a suite of allowed requests alone passes against a policy that permits everything. The mistakes that matter most are requests that should have been refused.
Assign
lab-tmp-policy-v14tolab-printer-app, thenlab-tmp-policy-v15tolab-tmp-mobile. Version 14 decides for the client people use; version 15 runs beside it on a client nobody depends on.
Why it matters: this is a manual shadow mode. You learn what version 15 would refuse before it decides for everyone.
Run the four rows in each time window, for example
row printer mia no, with the expected value from your table. Then compare the two clients' answers in the last window: version 15 refuses Mia where version 14 allows her.
Why it matters: the difference between the columns is exactly what the change would refuse, the lesson's 412 traveling editors in miniature. Decide whether that is what you meant before enforcing it.
Staged enforcement. Assign
lab-tmp-policy-v15tolab-printer-appas well, and runrow printer mia no. In Audit, thetenant.oauth.managers.assignevent shows you as actor and the client as subject.
Why it matters: the change is recorded and reviewable, and it reaches one client at a time.
Roll back. Reassign
lab-printer-apptolab-tmp-policy-v14and runrow printer mia yes. It passes again.
Why it matters: the token endpoint never changed, so rollback is a configuration change that takes effect at once, not a deployment.
Explaining a denial. Add
if (context.client_id === '<lab-tmp-mobile client ID>') return { allow: false, claims: {} };as the first line oflab-tmp-policy-v15and save. Ask for a token directly:
curl -s "$ISSUER/oauth/token" -d grant_type=refresh_token -d "refresh_token=${RT[mobile:mia]}" -d "client_id=$MOBILE_ID" | jq .
The answer is invalid_grant with "The tenant's access token policy refused to issue this token." and no rule names. In Audit, the same request appears as oauth.token rejected with reason policy_denied.
Restore: remove the line you added to lab-tmp-policy-v15 and save.
Why it matters: the message is true and reveals nothing about how the policy is built. The detail stays in the record, found by its request ID, as the lesson separates the requester's message from the operator's record.
Emergency access, as a written design.
Simulation. the tenant has no emergency grant or temporary access (G23), so break-glass cannot be run here.
Write the emergency rule for your tenant in the lesson's style: one resource, a two-hour expiry, and an approver who is not the requester. Add who is alerted the moment it is used, and when the use is reviewed.
Why it matters: an emergency path that is planned, alerted and reviewed is a control; one improvised at midnight is a back door.
Break it
A deny test that passes for the wrong reason. In
lab-tmp-policy-v14, setEMBARGO_UNTILone day ahead, save, and runrow printer mia noandrow printer ava no: both pass. Now delete the embargo line and save. Run both rows again: Mia's row fails, as it should, but Ava's still passes.
Why it matters: Ava is refused anyway because she is not an editor, so her row proves nothing about the embargo. A deny test proves something only when the rule under test is the reason for the denial.
Restore: put the embargo line back in lab-tmp-policy-v14 and save.
Check your work
Press Check my progress. The checks follow the shadow assignment, the staged enforcement, the rollback and the safely explained denial.
Also confirm by hand:
Your three runs of the table, and the difference between the two clients in the last window.
Cleanup
Assign
lab-printer-appback to the default access token manager.Delete
lab-tmp-mobile, thenlab-tmp-policy-v14andlab-tmp-policy-v15.
Missing infrastructure
G49, token policy test cases and versions. Saved test cases with contexts you choose and expected results, run on every save; version history with a diff and rollback; and shadow evaluation that records disagreements in Logs instead of needing a second client.
G22, decision records. Records with the policy version, the deciding rules and the attribute values they read, like the lesson's record of Omar's 13:30 attempt.
G23, emergency grants. A grant for one resource with an expiry, a second-person approval, an immediate alert and a scheduled review, so step 7 can be run for real.