Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

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.

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.

  1. Put the new version on lab-tmp-mobile only

    Recorded as tenant.oauth.managers.assign succeeded for lab-tmp-mobile.

  2. Enforce the new version on lab-printer-app

    Recorded as tenant.oauth.managers.assign succeeded for lab-printer-app.

  3. Roll lab-printer-app back to version 14

    Recorded as tenant.oauth.managers.assign succeeded for lab-printer-app.

  4. See a policy denial explained safely

    Recorded as oauth.token rejected (policy_denied) for lab-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.

  1. Complete Permit and forbid rules in a token policy for lab-printer-app and Mia's verified address.

  2. In OAuth > Flow policy, allow the refresh token grant, and allow it on lab-printer-app.

  3. Create lab-tmp-mobile in OAuth > Clients with the single-page application preset: public, PKCE required, refresh token grant, redirect URI http://127.0.0.1:8765/callback, assigned prints.create. It plays the lesson's mobile app.

  4. Create two access token managers, JWT with the default key, and do not assign them yet. lab-tmp-policy-v14 gets the policy below. lab-tmp-policy-v15 gets 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. Set EMBARGO_UNTIL to 5 minutes after you finish step 5, with date -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 };
  1. 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 mia and signin 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

  1. 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.

Whenprinter, Miaprinter, Avamobile, Miamobile, Ava
Before the embargonononono
After the embargo, within 15 minutes of signing inyesnoyesno
More than 15 minutes after signing inyesnonono

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.

  1. Assign lab-tmp-policy-v14 to lab-printer-app, then lab-tmp-policy-v15 to lab-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.

  1. 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.

  1. Staged enforcement. Assign lab-tmp-policy-v15 to lab-printer-app as well, and run row printer mia no. In Audit, the tenant.oauth.managers.assign event 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.

  1. Roll back. Reassign lab-printer-app to lab-tmp-policy-v14 and run row 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.

  1. Explaining a denial. Add if (context.client_id === '<lab-tmp-mobile client ID>') return { allow: false, claims: {} }; as the first line of lab-tmp-policy-v15 and 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.

  1. 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

  1. A deny test that passes for the wrong reason. In lab-tmp-policy-v14, set EMBARGO_UNTIL one day ahead, save, and run row printer mia no and row 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-app back to the default access token manager.

  • Delete lab-tmp-mobile, then lab-tmp-policy-v14 and lab-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.

Back to all labs

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

The Lab