Warmup Guide — RBAC & Azure Policy Evaluation
Zero-to-principal primer for Phase 04: the two authorization gates every Azure write passes through. We start from "what is a permission" and end at the exact evaluation order ARM runs — role assignments,
NotActionssubtraction, the control-plane / data-plane split, deny assignments, scope inheritance, and the Azure Policy condition engine and its effects. By the end you can answer "why does this fail even though I'mOwner?" from first principles, and you will have built both evaluators.
Table of Contents
- Chapter 1: The Two Gates — Authorization vs Governance
- Chapter 2: Actions and the Wildcard Matcher
- Chapter 3: Role Definitions — Actions, NotActions, and the Plane Split
- Chapter 4: Scope, Hierarchy, and Inheritance
- Chapter 5: Deny Assignments and the Evaluation Order
- Chapter 6: Azure Policy — The Governance Gate
- Chapter 7: Effects and Their Precedence
- Chapter 8: Least Privilege as an Engineering Discipline
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Two Gates — Authorization vs Governance
From zero. When a client calls Azure to create or change a resource, the request is a
single idempotent PUT (or PATCH/DELETE) to a resource ID — you built the parser for
that ID in Phase 00. Before any resource provider acts on it, Azure Resource Manager
(ARM) runs the request through a pipeline:
client ──JWT──▶ Entra (authenticate: is the token genuine? — Phase 03)
│
▼
ARM ──▶ RBAC (authorize: may THIS principal do THIS action HERE?)
│ ──▶ Azure Policy (govern: is the resulting RESOURCE allowed to exist?)
│
▼
resource provider (Microsoft.Storage, …) performs the idempotent PUT
What it is. Two distinct gates, in series, both deny-biased:
- RBAC (Role-Based Access Control) is authorization: it decides whether the
identity is permitted to perform the action at the scope. Failure looks like
AuthorizationFailed(HTTP 403). - Azure Policy is governance: even when RBAC says yes, Policy can block or mutate the
write based on the shape of the resource — its region, SKU, tags, encryption flag.
A blocking failure looks like
RequestDisallowedByPolicy(HTTP 403, different code).
Why it exists. They answer different questions and are owned by different people. RBAC
is "who is on the team and what may they touch" (identity/security). Policy is "what is the
organization allowed to build" (governance/compliance). Conflating them is the root of most
"I have permission but it still fails" tickets: you check RBAC, see Owner, and miss that
Policy denied the non-compliant resource.
Production significance. The single most useful debugging move in an Azure incident is
to read the error code: AuthorizationFailed sends you to RBAC (assignments, scope,
NotActions); RequestDisallowedByPolicy sends you to Policy (which definition, which
effect, which field). They are never the same investigation.
Common misconception. "Policy is just RBAC for resource properties." No — Policy never
looks at who is calling; it looks at what is being created. A principal with full
rights is still stopped by a deny policy, and a principal with no rights never reaches
Policy because RBAC stopped them first.
Chapter 2: Actions and the Wildcard Matcher
From zero. Every operation in Azure has a name — a slash-delimited string:
Microsoft.Storage/storageAccounts/read
Microsoft.Storage/storageAccounts/write
Microsoft.Storage/storageAccounts/listKeys/action
Microsoft.Compute/virtualMachines/read
The shape is {ResourceProvider}/{resourceType}/{operation} (sometimes deeper, with
nested types and an /action suffix for POST-style operations).
What it is. A role grants patterns over these strings, and the one wildcard is *.
The critical semantic: * matches any run of characters, including the / separator,
so it can span one or more path segments and match within a segment:
| Pattern | Matches | Doesn't match |
|---|---|---|
* | everything | — |
*/read | any …/read | …/write |
Microsoft.Storage/*/read | Microsoft.Storage/storageAccounts/read, …/blobServices/read | Microsoft.Compute/…/read |
Microsoft.Storage/storageAccounts/* | every storage-account op | Microsoft.Compute/… |
| exact string | only itself | anything else |
Under the hood. You translate the pattern to a regex: * → .*, and escape every
other character so the literal . in Microsoft.Storage is not a regex "any char".
Anchor both ends and match case-insensitively (Azure treats action names as
case-insensitive):
def _compile(pattern: str) -> re.Pattern:
out = ["(?i)^"]
for ch in pattern:
out.append(".*" if ch == "*" else re.escape(ch))
out.append("$")
return re.compile("".join(out))
def action_matches(pattern: str, action: str) -> bool:
return _compile(pattern).fullmatch(action) is not None
The escaping is the bug that bites everyone: without it, Microsoft.Storage/... would
match MicrosoftXStorage/... because . is "any character" in a raw regex. The lab tests
exactly this (test_literal_dot_not_treated_as_regex).
Production significance. This is the matcher ARM runs millions of times a second. When
you read a custom role definition and ask "does this grant listKeys?", you are running
this function by hand. Reader is literally Actions: ["*/read"].
Common misconception. "* only matches one segment." It matches zero or more
characters, slashes included — Microsoft.Storage/*/read will match
Microsoft.Storage/storageAccounts/blobServices/read. (Azure's own model is "* is a
wildcard"; segment counting is your mental shorthand, not a real boundary.)
Chapter 3: Role Definitions — Actions, NotActions, and the Plane Split
From zero. A role definition is a named bundle of permission patterns. The four built-ins to know cold:
| Role | Actions | Note |
|---|---|---|
Owner | ["*"] | full control plane + can manage access |
Contributor | ["*"] minus Microsoft.Authorization/*/Write, …/Delete, …/elevateAccess | manage everything except grant access |
Reader | ["*/read"] | read everything, change nothing |
User Access Administrator | ["*/read", "Microsoft.Authorization/*"] | manage access, little else |
What it is. A role has four permission lists:
Actions— control-plane operations the role allows.NotActions— operations subtracted from this role's ownActions.DataActions— data-plane operations the role allows.NotDataActions— operations subtracted fromDataActions.
The effective permission is set arithmetic, per plane:
$$\text{effective} = \Big(\bigcup \text{Actions}\Big) \setminus \Big(\bigcup \text{NotActions}\Big)$$
def role_permits(role, action, is_data=False):
allow, deny = (role.data_actions, role.not_data_actions) if is_data \
else (role.actions, role.not_actions)
return any(action_matches(p, action) for p in allow) \
and not any(action_matches(p, action) for p in deny)
Under the hood — NotActions is subtraction, not deny. This trips up everyone.
Contributor has Actions: ["*"] and NotActions: ["Microsoft.Authorization/*/Write"].
That NotActions removes the grant from this role only. If the same principal also
has User Access Administrator (which does grant Microsoft.Authorization/*), the union
of assignments restores it. To truly block something regardless of other grants you need a
deny assignment (Chapter 5). The mental model:
NotActions → "this role doesn't grant X" (another role still can)
deny assignment → "nobody gets X here, period" (overrides every grant)
Under the hood — the control-plane / data-plane split. Actions manage the
resource; DataActions govern the data inside it. They are separate sets. So:
Owner: Actions=["*"], DataActions=[] → can create/configure a storage account
→ CANNOT read a blob (no DataActions!)
To read a blob you add a data role: Storage Blob Data Reader
(DataActions: ["Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read"]).
This is the famous "Owner can't read a blob," and it is by design: separating who
manages a store from who reads its data is a real security boundary (an admin who can
rotate keys need not be able to read every customer's file). The same split exists for Key
Vault (Key Vault Secrets User), Service Bus, Cosmos DB, and Event Hubs.
Production significance. When someone says "I'm Owner but I get 403 reading the
blob," you know immediately: control-plane grant, data-plane action, missing data role. The
fix is one assignment, not "make me more Owner."
Common misconception. "Data access is implied by Owner." It is not — and Microsoft
deliberately made it not, because key-management and data-read are different trust levels.
Some portals hide this by auto-adding data roles when you click into a blob, which makes
the SDK/service-principal case (no portal magic) more confusing, not less.
Chapter 4: Scope, Hierarchy, and Inheritance
From zero. A scope is where an assignment applies — an ARM resource ID, or a prefix of one. The hierarchy, top to bottom:
(tenant root)
└─ management group /providers/Microsoft.Management/managementGroups/{mg}
└─ subscription /subscriptions/{sub}
└─ resource group /subscriptions/{sub}/resourceGroups/{rg}
└─ resource …/resourceGroups/{rg}/providers/{ns}/{type}/{name}
What it is. An assignment at a scope applies to that scope and everything below it.
Assign Reader at the subscription and the principal can read every RG and every resource
in it. This is inheritance, and combined with additivity (Chapter 3) it means a
principal's effective access at a resource is the union of every assignment from the
resource all the way up to the root.
Under the hood — containment is segment-aware. "Is resource R below scope S?" is a
prefix match on whole path segments, not a raw string startswith. The trap:
scope = /subscriptions/s/resourceGroups/rg
resource1 = /subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/... ✓ contained
resource2 = /subscriptions/s/resourceGroups/rg2/providers/Microsoft.Storage/... ✗ NOT contained
"…/rg2/…".startswith("…/rg") is True — and wrong. The fix is to require the scope plus
a trailing / to prefix the resource (or exact equality, or the root /):
def scope_contains(scope, rid):
scope, rid = scope.rstrip("/").lower(), rid.rstrip("/").lower()
if scope in ("", "/"): # tenant root contains everything
return True
return rid == scope or rid.startswith(scope + "/")
ARM IDs are case-insensitive and a trailing slash is insignificant, so normalize both. The
lab's test_scope_contains_rg_vs_rg2_trap is precisely this boundary.
Production significance. Scope is your blast-radius dial. Owner at a management
group is access to every subscription beneath it — a much bigger grant than Owner on
one RG, for the identical role. Tightening scope is almost always a better least-privilege
move than inventing a narrower role. And az role assignment list --include-inherited
shows you exactly the up-the-tree union this chapter describes.
Common misconception. "I removed the assignment on the resource, so they lost access." Not if they still have an inherited assignment at the RG, subscription, or MG. Effective access is the union up the tree; you must find every contributing scope.
Chapter 5: Deny Assignments and the Evaluation Order
From zero. RBAC is allow-based: there is no "deny role." So how do you guarantee
nobody — not even an Owner — can delete a platform-managed resource? With a deny
assignment.
What it is. A deny assignment is an explicit block on a set of actions, for a set
of principals, at a scope. It has Actions and NotActions (here NotActions are
exclusions from the deny — "block everything except read"). Crucially, you cannot
create one with a role. They are emitted by Azure Blueprints, Deployment Stacks
(denySettings), and managed applications — the platform-control mechanisms — which is
why they are the system's last word.
Under the hood — the evaluation order. ARM computes the RBAC decision like this:
def can_perform(principal, action, resource):
# 1. DENY WINS — checked FIRST.
for d in deny_assignments_for(principal):
if scope_contains(d.scope, resource) and d.blocks(action):
return False # ← short-circuit, no allow can save it
# 2. ALLOW — some in-scope role assignment must permit the action.
for a in role_assignments_for(principal):
if scope_contains(a.scope, resource) and role_defs[a.role].permits(action):
return True
# 3. DEFAULT DENY.
return False
Three rules fall out, and they are the whole chapter:
- Deny always wins. A deny in scope ends the evaluation
False, regardless of any grant — even subscriptionOwner. - Default deny. No matching allow →
False. Least privilege is the floor, not an opt-in. - Scope still applies to deny. A deny at
rgdoes not block an action on a resource inrg2— same containment rule as allows.
$$\text{allowed} = \big(\exists,\text{in-scope allow}\big)\ \wedge\ \neg\big(\exists,\text{in-scope deny}\big)$$
Production significance. This is how you build guardrails that survive privileged
users: a Deployment Stack with denySettings: denyDelete means even the break-glass
Owner cannot delete the locked resources out-of-band — drift protection at the control
plane. (Compare resource locks — CanNotDelete/ReadOnly — which are simpler but a
different mechanism that even Owner can remove; deny assignments are stronger.)
Common misconception. "I'll just add a NotActions to deny them." NotActions only
limits that role; the principal's other roles still grant it. Real, unconditional denial
is a deny assignment — and you provision it through a stack/blueprint, not a CLI role
command.
Chapter 6: Azure Policy — The Governance Gate
From zero. RBAC said yes, this identity may write a storage account here. Azure
Policy now asks: is this particular storage account allowed to exist? — e.g. "no
storage account may permit non-HTTPS traffic," "every resource must carry a cost-center
tag," "VMs only in approved regions."
What it is. A policy definition's policyRule has two parts:
{
"if": { "field": "...", "equals": "..." }, // a condition tree
"then": { "effect": "deny" } // what to do on a match
}
If the if condition matches the resource, the effect fires; otherwise the resource is
compliant with respect to this policy.
Under the hood — the condition tree. Leaves test a field/alias; logical nodes combine them:
| Construct | Meaning |
|---|---|
{"field": F, "equals": V} | field F equals V (case-insensitive strings) |
{"field": F, "notEquals": V} | field F ≠ V |
{"field": F, "in": [V1, V2]} | field F is one of the list |
{"field": F, "exists": true} | field F is present (vs absent) |
{"field": F, "like": "prefix/*"} | glob match (* any chars, ? one char) |
{"allOf": [c1, c2]} | logical AND |
{"anyOf": [c1, c2]} | logical OR |
{"not": c} | logical NOT |
You evaluate it as a recursive tree-walk (the lab's _eval_condition). The field is a
dotted path/alias resolved into the resource dict — properties.supportsHttpsTrafficOnly
walks resource["properties"]["supportsHttpsTrafficOnly"].
Under the hood — exists needs an absent-vs-falsey distinction. This is the subtle
one. exists: true must be True for a field that is present but false — "present"
and "truthy" are different questions. So a missing key must resolve to a sentinel, not
to None/False, or exists can't tell them apart:
_MISSING = object()
def _resolve(resource, path):
cur = resource
for part in path.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return _MISSING # absent, distinct from a real False
return cur
# exists: (actual is not _MISSING) == want
The lab's test_policy_exists_distinguishes_absent_from_falsey is exactly this trap: a
storage account with supportsHttpsTrafficOnly: false has the field, so
exists: true matches it.
Under the hood — aliases. Real Policy doesn't read raw JSON; it reads aliases — a
curated map from a friendly name to the property's API path at a given version, e.g.
Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly →
properties.supportsHttpsTrafficOnly. Array aliases use [*] to mean "for any element."
The lab models the dotted lookup; the extension wires real alias names.
Production significance. This is policy-as-code — the mechanism behind a compliant landing zone (Phase 05). Denying non-HTTPS storage, requiring private endpoints, enforcing allowed regions, appending tags: all are condition trees plus an effect, assigned at a management group so every subscription inherits them.
Common misconception. "Policy runs after the resource is created." deny/append/
modify run at the request, before the resource exists — that's how deny can stop
the create. Only audit* and deployIfNotExists run on the periodic compliance scan
(Chapter 7).
Chapter 7: Effects and Their Precedence
From zero. The then.effect is what Policy does on a match. The seven that matter:
| Effect | When it runs | What it does |
|---|---|---|
disabled | — | turns the policy off (no-op) |
audit | scan | logs non-compliance; does not block |
auditIfNotExists | scan | non-compliant if a related resource is missing |
append | request | adds fields to the request (e.g. a default tag) |
modify | request | changes/adds/removes properties or tags (needs a managed identity) |
deployIfNotExists | scan | deploys a remediating resource if one is missing (managed identity) |
deny | request | blocks the create/update |
Under the hood — precedence. When multiple policies (or an initiative) evaluate the same request, the strongest effect wins. The cheat-sheet ordering, low → high:
disabled < audit / auditIfNotExists < append / modify < deployIfNotExists < deny
So if one policy would audit and another would deny the same write, the request is
denied. deny is the most restrictive and dominates; disabled is the weakest.
Under the hood — request-time vs scan-time. This split is the principal-level nuance:
REQUEST TIME (before the resource exists): deny, append, modify
SCAN TIME (over existing resources): audit, auditIfNotExists, deployIfNotExists
deny can stop a create because it runs first, on the payload. audit cannot stop
anything — it only marks the resource non-compliant in the compliance dashboard later.
deployIfNotExists runs a remediation deployment after the fact, which is why it needs a
managed identity with rights to deploy the missing piece (e.g. a diagnostic setting).
Production significance. Choosing the effect is a real design decision: deny is the
guardrail that stops the mistake, but it can break legitimate deploys and frustrate teams;
audit is the observe-first mode you roll out before flipping to deny; deployIfNotExists
auto-fixes (e.g. "every resource gets a Log Analytics diagnostic setting") so humans don't
have to. A mature landing zone uses all three deliberately.
Common misconception. "audit protects me." It does not block anything — it only
reports. If the requirement is "this must never be created," the effect is deny. audit
is for visibility and gradual rollout, not enforcement.
Chapter 8: Least Privilege as an Engineering Discipline
From zero. "Least privilege" means grant the minimum access required, no more — and in Azure that decomposes into two independent dials you now understand: the role (which actions) and the scope (which resources).
The discipline, in priority order:
- Narrow the scope before you narrow the role.
Contributoron one RG beatsOwneron the subscription for a CI identity. Same role, far smaller blast radius. Scope is the cheaper, safer lever. - Prefer the narrowest built-in role. Need to read blobs?
Storage Blob Data Reader, notOwner. Microsoft maintains hundreds of granular built-ins for exactly this. - Custom role only as a last resort. When no built-in fits, author a custom role with
the exact
Actions/DataActionsand a tightAssignableScopes. It's more to maintain, so justify it. - Separate control and data planes by person. Platform admins manage the Key Vault
(
Owner); they should not read its secrets. App identities read secrets (Key Vault Secrets User); they should not change the vault firewall. - Use deny assignments for guardrails that must survive privilege. Protect
platform-managed resources from even
Ownervia stackdenySettings.
Why it's an engineering problem, not a checkbox. The standing question in a review is "what is the minimum this identity needs to do its job, and at the tightest scope?" — and the way you prove the answer is to remove access and watch the deploy still pass (the integrated scenario in the phase README). Over-provisioning is the default failure mode because it's easy and nothing breaks immediately; the breach happens later, through the identity you over-granted.
Production significance. Every audit, every breach post-mortem, and every well-run
landing zone is downstream of this. The identity that got phished and had subscription
Owner is the headline; the identity that had Contributor on one RG is a footnote.
Common misconception. "Least privilege slows teams down." Done well it does the opposite: self-service with policy guardrails lets teams move fast inside a safe box, which is the whole thesis of landing zones (Phase 05).
Lab Walkthrough Guidance
Lab 01 — RBAC & Azure Policy Evaluation Engine, suggested order:
action_matches(pattern, action)(Ch. 2) — translate*→.*,re.escapeevery other char, anchor, case-insensitivefullmatch. Test the boundaries first:*/read, mid-pattern*, exact, the literal-dot trap, case-insensitivity.RoleDefinition+role_permits(Ch. 3) —union(allow) − union(deny)per plane; coerce list args to tuples in__post_init__. The two key tests:NotActionssubtraction, andOwner(Actions=["*"], noDataActions) failing a data action.scope_contains(Ch. 4) — normalize (lower, strip trailing/), then equality orscope + "/"prefix; root/contains all. Nail therg-vs-rg2test.RoleAssignment/DenyAssignment/can_perform(Ch. 5) — deny loop first (short-circuitFalse), then the allow loop, then defaultFalse. Raise on an unknown role name. Test deny-wins, out-of-scope deny, wrong-principal deny, default-deny.evaluate_policy(Ch. 6) — recursive_eval_condition(handleallOf/anyOf/notthen the leaf operators), a dotted_resolve_fieldwith a_MISSINGsentinel, and thethen.effectreturn. Validate the shape and effect. Thencompliance_summarytallies effects across resources (Ch. 7).
Run red (pytest test_lab.py -v), implement top-to-bottom, run green. Then run
LAB_MODULE=solution pytest test_lab.py -v and python solution.py for the three demos.
Success Criteria
You are ready for Phase 05 when you can, from memory:
- Draw the two authorization gates (RBAC then Policy) and name the error code each emits.
- State the RBAC formula
union(Actions) − union(NotActions)and explain whyNotActionsis subtraction, not a deny. - Explain the control-plane / data-plane split and why
Ownercan't read a blob (and what role fixes it). - Recite the RBAC evaluation order — deny first, then allow, default deny — and why deny is checked first.
- Explain the
rg-vs-rg2containment trap and how segment-aware matching avoids it. - Name the four core roles and the operation
Contributorlacks. - List the seven Policy effects, order them by precedence, and say which run at request time vs the compliance scan.
- Hand-evaluate a nested
allOf/anyOf/notpolicy against a resource JSON, including anexistson a present-but-falsefield.
Interview Q&A
Q: I'm Owner on the subscription but I get a 403 reading a blob. Why, and what do you
do?
Owner grants the control plane (Actions: ["*"]) — it can create, configure, and
delete the storage account — but it has no DataActions, and reading a blob is a
data-plane action
(Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read). The two planes
are separate permission sets by design, so an admin who manages the account isn't
automatically allowed to read every customer's data. The fix is one assignment:
Storage Blob Data Reader (or …Data Contributor) at the account/container scope. The
portal sometimes hides this by auto-adding the data role, which is why service principals
and SDK calls hit it more often.
Q: How do you guarantee that nobody, not even an Owner, can delete a production
resource?
Not with a role — RBAC is allow-based, there is no "deny role," and a NotActions only
limits the role it's on (another grant restores it). You use a deny assignment, which
overrides every role assignment and is evaluated first. You can't author deny
assignments with the CLI directly; they come from a Deployment Stack (denySettings: denyDelete), a Blueprint, or a managed app. That's stronger than a resource lock, which
even Owner can remove. Document a break-glass account that sits outside the deny scope.
Q: Walk me through the RBAC evaluation order.
For (principal, action, resource): (1) Deny wins — if any deny assignment for this
principal is in scope of the resource and matches the action, return False immediately,
no allow can override it. (2) Allow — return True iff some in-scope role assignment's
role definition permits the action, where "permits" is matched-by-some-Action AND not-matched-by-any-NotAction, on the data plane if it's a DataAction. (3) Default
deny — no matching allow → False. Scope containment (segment-aware prefix up the MG →
Sub → RG → resource hierarchy) gates both the deny and the allow checks.
Q: What's the difference between NotActions and a deny assignment?
NotActions subtracts from a single role's own grant — it means "this role doesn't
give you X," but another assignment can still grant X (assignments are additive). A deny
assignment is an unconditional block at a scope that overrides all grants and is
evaluated before them. NotActions shapes a role; a deny assignment overrides the whole
decision.
Q: A deployment fails with RequestDisallowedByPolicy even though the service principal
is Contributor. What happened?
RBAC and Policy are two separate gates. Contributor cleared RBAC — the identity may
create the resource — but Azure Policy has a deny effect that matched the resource's
shape (wrong region, missing required tag, supportsHttpsTrafficOnly: false, public
network access, a disallowed SKU). The error code is the tell: AuthorizationFailed is
RBAC; RequestDisallowedByPolicy is Policy. You find the assignment, read its if
condition to see which field tripped it, and either fix the resource to comply or (with
governance approval) adjust/except the policy.
Q: Order the Policy effects and tell me which run at request time.
Precedence low→high: disabled < audit/auditIfNotExists < append/modify <
deployIfNotExists < deny; the strongest wins, so deny dominates. Request-time
(before the resource exists): deny, append, modify. Scan-time (over existing
resources): audit, auditIfNotExists, deployIfNotExists. That's why audit can't
block a create — it only marks non-compliance later — and why deployIfNotExists needs a
managed identity to run its remediation deployment.
Q: An assignment exists at a management group. How does it reach a single blob's
container?
By inheritance. A scope applies to itself and everything below it in MG → Sub → RG → resource. A role at the MG is in scope of every descendant resource ID — containment is a
segment-aware prefix match — so the assignment authorizes control-plane actions on that
container resource. Reading the blob's data, though, still needs a DataAction from a
data role; control-plane inheritance doesn't grant data-plane access.
References
- Azure RBAC overview — the allow-based, additive, inherited model.
- How Azure RBAC determines access — the deny-then-allow evaluation order.
- Azure built-in roles
—
Owner,Contributor,Reader,User Access Administrator, and the data roles. - Understand role definitions
—
Actions,NotActions,DataActions,NotDataActions,AssignableScopes. - Deny assignments — how deny overrides and who creates them.
- Azure RBAC scope — the MG → Sub → RG → resource hierarchy and inheritance.
- Azure Policy overview and policy definition structure.
- Policy effects — the seven effects, precedence, and request-vs-scan timing.
- Policy aliases — how a field name maps to a resource property at an API version.
- The track's own CHEATSHEET.md (RBAC evaluation + effect precedence) and GLOSSARY.md.