AUTHORIZATION AND POLICY · LAB
Permit and forbid rules in a token policy
Turn the lesson's "editors may download, nobody before the embargo" into a real token policy, and watch the same request change answer when only the time moves.
Partly readyUses your lab tenant
The lesson
Builds on: Codes, links, and approval prompts.
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.
- G21 Group and custom-attribute token claims; directory roles fixed to `member`
- G22 End-user authorization engine (PDP, RBAC/ABAC/ReBAC for application data)
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.
Create the temporary policy manager
Recorded as
tenant.oauth.managers.createsucceeded.Assign it to lab-printer-app only
Recorded as
tenant.oauth.managers.assignsucceeded forlab-printer-app.Get tokens decided by the rules
Recorded as
oauth.tokensucceeded forlab-printer-app.A policy that widens scopes fails closed
Recorded as
oauth.tokenfailed (policy_unavailable) forlab-printer-app.
Setup
The tenant has no decision point for application data yet (G22), and directory roles are fixed (G21). Its access token policy is a real rule hook, though: it runs on every token issuance with subject, client, scope and time attributes, and it can narrow scopes or refuse. Here prints.create stands in for the lesson's photo.download: ordering a print hands over the full-resolution file.
Complete Sign in by email link, email code and authenticator code first. Mia's address is verified there, and this lab relies on it.
In OAuth > Scopes, check that
photos.read(common) andprints.create(exclusive) exist.If
lab-printer-appdoes not exist, create it in OAuth > Clients with the single-page application preset: public, PKCE required, redirect URIhttp://127.0.0.1:8765/callback. Assign itprints.create.In OAuth > Access token managers, create
lab-tmp-policy: JWT format, the default signing key. Assign it tolab-printer-apponly, so no other client is affected.Paste this policy into
lab-tmp-policy. Replace the address with Mia's, andEMBARGO_UNTILwith the Unix time 10 minutes from now, whichdate -d '+10 min' +%sprints.
// Stand-in for "picture editor": directory roles are fixed (G21), so a verified-address allowlist plays the role.
const EDITORS = ['[email protected]'];
const EMBARGO_UNTIL = 1790000000;
const now = context.claims.iat; // the tenant's clock, never a time from the request
let scopes = [...context.scopes];
const drop = name => { scopes = scopes.filter(item => item !== name); };
// permit prints.create when the subject is an editor; anything else stays at no
if (!(context.subject.email_verified === true && EDITORS.includes(context.subject.email))) drop('prints.create');
// forbid prints.create before the embargo, whoever asks
if (now < EMBARGO_UNTIL) drop('prints.create');
return { allow: true, claims: {}, scopes };
Define two helpers in your shell.
authorizeprints a URL to open in the person's window and waits for the callback;redeemexchanges the code you paste.
export CLIENT_ID="<lab-printer-app client ID>"
authorize() { # $1 = scopes, $2 = extra parameters such as "&max_age=0"
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://127.0.0.1:8765/callback&scope=${1// /%20}&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256$2"
btl-lab callback
}
redeem() {
read -rsp "Code: " CODE; echo
RESP=$(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_ID" -d "code_verifier=$VERIFIER")
echo "$RESP" | jq '{scope, error, error_description, reference_id}'
TOKEN=$(echo "$RESP" | jq -r '.access_token // empty'); ID_TOKEN=$(echo "$RESP" | jq -r '.id_token // empty')
}
Walkthrough
Map each phrase to an attribute before running anything. "A picture editor": a subject attribute, here a verified address on the list. "Before its embargo ends": a resource attribute, the embargo time, held as a constant because there is no photo record yet. "Download": the action, the scope
prints.create. "Now": an environment attribute, the tenant's own clock.
Why it matters: the rules name properties, not people. Nobody is named in a role; the sentence holds for whoever asks.
As Mia, before the embargo: run
authorize "openid photos.read prints.create", open the URL in Mia's window, approve, andredeem. Thescopelacksprints.create.
Why it matters: the permit matches, but so does the embargo forbid, and a forbid wins.
As Ava, the same request. No
prints.create.
Why it matters: no permit matches, so default deny refuses, whatever the time.
After
EMBARGO_UNTIL, repeat as Mia.prints.createis granted, andbtl-lab decode "$TOKEN"shows it in the token'sscope.
Why it matters: only the time moved. No role, rule or code changed, which is the lesson's 14:00 regatta photo.
Repeat as Ava after the embargo. Still refused.
Why it matters: a forbid that stops matching grants nothing. The permit is what grants.
Write the lesson's three-row table with your own results: who, when, result, and why.
Why it matters: a rule set is understood by the requests it refuses as well as the ones it allows, and the table is the start of the tests a later lab writes.
Break it
Add
'[email protected]'toEDITORSand save. After the embargo, requestprints.createas Ben. He is still refused: his address has never been verified, and the permit tests both attributes.
Restore: remove Ben's address from EDITORS and save.
Why it matters: an allowlist of email text is only as trustworthy as the verification behind it. The next lab follows where each attribute comes from.
Make the policy return a scope that was not requested: change the last line to
return { allow: true, claims: {}, scopes: [...scopes, 'photos.delete'] };and requestopenid photos.readas Ava. The token request fails withserver_errorand areference_id. Search Logs for that reference:oauth.tokenfailed with reasonpolicy_unavailable.
Restore: put the original last line back and confirm a token is issued again.
Why it matters: a policy may only narrow what was asked for. A result that tries to widen it is treated as a broken policy, and the request fails closed.
Check your work
Press Check my progress. The checks follow the manager, its assignment, tokens decided by the rules and the policy that failed closed.
Also confirm by hand:
Four token responses whose
scopematches your table: only Mia after the embargo hasprints.create.Audit shows
tenant.oauth.managers.updateeach time you saved the policy.
Cleanup
Assign
lab-printer-appback to the default access token manager, then deletelab-tmp-policy. Later labs create their own.
Missing infrastructure
G22, object-level attribute rules. With the photo library API, the same rules would read
resource.statusandresource.embargo_untilfrom each photo andenvironment.device.managedfrom the request, and a decision endpoint would return the lesson's three results for photos 9001 and 9002.G21, role and custom-attribute claims. With real directory roles and attributes in the policy context,
subject.rolesandsubject.deskreplace the address allowlist.