L27 · Access Control Models
Module 4: Identity and Access · ~20 min · Lesson 27 of 71
🎯 What you'll learn
- Three ways to answer "is this allowed?": roles (RBAC), attributes (ABAC), and relationships (ReBAC)
- Where each one already shows up in a typical API stack: Kubernetes, AWS IAM, a gateway such as Kong, and the services themselves
- Why you decide in one place (a policy decision point) but enforce at every layer
- How to write an authorization rule once, as a function or an OPA policy, and test it
🔥 Why this matters
A payments service has an approval rule: approvers only, up to their limit, and never their own
payment. It's checked in POST /v1/payments/{id}/approve. Then someone adds a bulk endpoint for an
Airflow job, copies the controller skeleton, and doesn't copy the check. Now any caller with a valid
token can approve any payment. Authentication (L21) worked perfectly. Authorization was scattered,
and one place forgot. That's why Broken Object Level Authorization is API1, the top risk, in the
OWASP API Security Top 10 (2023).
1. One question, four parts
Every authorization decision answers the same question: can this subject (a user or a service) perform this action on this resource, in this context?
"Can Alice approve payment PAY-7781 ($50,000) right now?"
│ │ │ │
SUBJECT ACTION RESOURCE CONTEXT
who, her approve which object: time, IP,
roles and amount, maker, device,
her limit status, region risk score
The answer starts at deny by default: nothing is allowed unless a rule explicitly allows it. That's L04's fail-safe defaults again. It also means missing data (Alice's profile has no approval limit) must produce "no", not "no rule said no".
The three models below differ in one thing: what information the rule is allowed to look at.
2. RBAC: permissions follow job titles
Role-based access control (RBAC) gives permissions to roles, and roles to people. Ferraiolo and Kuhn at NIST formalized it in 1992, and it became the ANSI INCITS 359 standard in 2004.
USERS ROLES PERMISSIONS
alice ──┐
├──▶ payment-approver ──┬──▶ payments:approve
dave ──┘ └──▶ payments:read
bob ─────▶ payment-maker ───┬──▶ payments:create
└──▶ payments:read
It's all over a typical stack: Kubernetes Roles and RoleBindings, Airflow's Admin/Op/User/Viewer roles,
MongoDB's built-in read and readWrite, and IAM Identity Center permission sets assigned to groups
(L13). It's easy to audit: "who has payment-approver?" is one query.
Its weakness shows up when the rule needs facts about this request. Approvers have different
limits, and EU approvers may only approve EU payments. Now you need approver-us-25k,
approver-us-100k, approver-eu-25k, and so on. This is role explosion: the number of roles
grows as the product of every dimension, until nobody knows what approver-eu-100k-temp2 was for.
And no role can say "not your own payment", because that depends on the payment.
3. ABAC: rules over attributes
Attribute-based access control (ABAC) decides with rules over attributes of the subject, the resource, the action, and the environment. NIST describes it in SP 800-162 (2014). The approval rule becomes one rule instead of forty roles:
allow "approve" if
subject.roles contains "payment-approver"
resource.amount <= subject.approval_limit
resource.region == subject.region
resource.created_by != subject.id (four eyes, L04)
AWS IAM is ABAC whenever you write conditions. Here a tag on the caller must match a tag on the secret:
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "*",
"Condition": {"StringEquals": {
"aws:ResourceTag/team": "${aws:PrincipalTag/team}"
}}
}
IAM Identity Center can pass user attributes from your identity source (such as a team name) into the AWS session as principal tags; AWS calls this attributes for access control. One permission set then serves every team, and moving someone to another team is a directory change, not a policy change.
The cost: ABAC is harder to audit. "Who can read this secret?" now depends on everyone's current tags, and anyone who can change a tag can change access. Protect attributes like policies.
4. ReBAC: rules over relationships
A wealth platform says: an advisor may view a client's account if they are that client's advisor, or if they manage the branch the account belongs to. That isn't a role or a simple attribute. It's a path through a graph.
Relationship-based access control (ReBAC) stores facts as relationships and asks "is there a
path from this user to this object?" Google's Zanzibar paper (USENIX ATC 2019) describes the
system behind permissions in Drive, Calendar, YouTube, Cloud and others, reporting trillions of ACLs
and millions of checks per second. Each fact is a relation tuple, written object#relation@user:
account:ACC-5521#client@user:erin
account:ACC-5521#advisor@user:alice
account:ACC-5521#branch@branch:boston
branch:boston#manager@user:frank
user:erin ──client──▶ ┌──────────────────┐
user:alice ─advisor─▶ │ account:ACC-5521 │
└────────┬─────────┘
│ branch
▼
user:frank ─manager─▶ branch:boston
check(alice, can_view, ACC-5521) → advisor edge → ALLOW
check(frank, can_view, ACC-5521) → branch → manager → ALLOW
check(dave, can_view, ACC-5521) → no path → DENY
Open-source systems built on these ideas include OpenFGA (started at Auth0/Okta; a CNCF incubating project since late 2025) and SpiceDB. In OpenFGA's modeling language the rule is:
model
schema 1.1
type user
type branch
relations
define manager: [user]
type account
relations
define branch: [branch]
define client: [user]
define advisor: [user]
define can_view: client or advisor or manager from branch
ReBAC fits ownership and sharing ("member of", "shared with", "parent folder"). The cost is a relationship store that must stay in sync with your real data.
| RBAC | ABAC | ReBAC | |
|---|---|---|---|
| Decides with | The user's roles | Attributes of user, resource, context | Relationships between objects |
| "Alice approves a $50k payment" | A role per limit and region | One rule | Awkward: limits aren't relationships |
| "Advisor views their client's account" | Can't express per client | Works if the account stores its advisor | Natural |
| "Who can do X?" audit | Easy | Hard | Medium (expand the graph) |
| In the example stack | k8s RBAC, Airflow, Mongo roles, permission sets | IAM conditions and tags | OpenFGA, Drive-style sharing |
Real systems mix them: roles for coarse access, attributes or relationships for the fine rule.
5. Decide in one place, enforce at every layer
The vocabulary comes from OASIS XACML and NIST SP 800-162. A policy enforcement point (PEP) intercepts a request, asks for a decision, and enforces it. A policy decision point (PDP) evaluates the policy and answers allow or deny. A policy information point (PIP) supplies extra attributes, and a policy administration point (PAP) is where policies are managed (often git).
client
│ 1. POST /v1/payments/PAY-7781/approve + token
▼
┌─────────────────┐ COARSE PEP: token valid? scope ok?
│ Kong │ consumer allowed on this route?
└───────┬─────────┘ (has no idea what PAY-7781 is)
│ 2. forwarded, X-Consumer-ID set by Kong (L09)
▼
┌─────────────────┐ 3. load payment ┌───────────────┐
│ payments-svc │ ───────────────▶ │ MongoDB (PIP) │
│ FINE-GRAINED │ └───────────────┘
│ PEP │ 4. {user, action, payment}
│ │ ───────────────▶ ┌───────────────┐
│ │ ◀─────────────── │ OPA (PDP) │
└───────┬─────────┘ 5. allow: false │ policies from │
│ │ git (PAP) │
▼ 6. 403 + audit log └───────────────┘
Kong is a good coarse PEP: valid token, right scope, consumer in a group the acl plugin allows.
Kong Gateway Enterprise also has an OPA plugin that sends the method, path, headers and
(optionally) the consumer to OPA. But the gateway doesn't know the amount or who created PAY-7781,
so object-level checks belong in the service (L43 goes further).
Open Policy Agent (OPA), a CNCF graduated project, is a general-purpose PDP: JSON in, a policy written in its language Rego, JSON out. In Kubernetes it runs as OPA Gatekeeper, an admission controller that can reject a deployment before it's stored ("no root containers", "images only from our registry"). Kyverno (CNCF graduated, March 2026) does the same in YAML.
"One place" means one source of policy logic, not one choke point. Every layer still enforces (defense in depth, L04), but they all ask the same question the same way.
🧪 Worked example: the endpoint that forgot
Before: each controller writes its own check.
@app.post("/v1/payments/<pid>/approve")
def approve(pid):
p, u = repo.get(pid), current_user()
if "payment-approver" not in u.roles:
abort(403)
if p.amount > u.approval_limit:
abort(403)
return service.approve(p, by=u) # BUG 1: self-approval
@app.post("/v1/payments/approve-batch")
def approve_batch():
u = current_user()
for pid in request.json["ids"]:
service.approve(repo.get(pid), by=u) # BUG 2: no check
return {"ok": True}
The copies drifted: the first forgot four-eyes, the second forgot everything (broken object- and function-level authorization, L40).
After: one policy function, enforced inside the operation itself, so no controller can skip it.
# authz.py: the ONE place that decides
def can_approve(user: User, p: Payment) -> bool:
return (
"payment-approver" in user.roles # RBAC
and p.status == "PENDING" # ABAC
and p.amount <= user.approval_limit # ABAC
and p.created_by != user.id # four eyes
) # else: deny
# service.py: every endpoint ends up here
def approve(p: Payment, by: User):
if not can_approve(by, p):
audit.deny(by.id, "payment.approve", p.id)
raise Forbidden()
...
# test_authz.py: the rule is now testable on its own
alice = User("u-alice", frozenset({"payment-approver"}), 100_000)
def test_within_limit():
assert can_approve(alice, pay(50_000))
def test_own_payment():
assert not can_approve(alice, pay(500, maker="u-alice"))
The same function can later call OPA instead of holding the logic, without any controller changing.
🛠 Try it (optional, ~5 min)
Run OPA locally against the payment rule. This downloads one binary into a throwaway folder: no
sudo, nothing installed system-wide, no network calls once it's downloaded. (On an Intel Mac, use
opa_darwin_amd64. If downloads are blocked, run the Python version above instead.)
mkdir -p ~/tmp/opa-l27 && cd ~/tmp/opa-l27
V=v1.21.4; F=opa_darwin_arm64_static
B=https://github.com/open-policy-agent/opa/releases/download
curl -sSLO "$B/$V/$F" && curl -sSLO "$B/$V/$F.sha256"
shasum -a 256 -c "$F.sha256" # expect: ...: OK
mv "$F" opa && chmod +x opa && ./opa version | head -1
Save this as payments.rego:
package payments
default allow := false
allow if {
"payment-approver" in input.user.roles
input.payment.status == "PENDING"
input.payment.amount <= input.user.approval_limit
input.payment.created_by != input.user.id
}
why contains "not an approver" if {
not "payment-approver" in input.user.roles
}
why contains "over approval limit" if {
input.payment.amount > input.user.approval_limit
}
why contains "maker can't be checker" if {
input.payment.created_by == input.user.id
}
Create bob.json, where Bob tries to approve his own payment above his limit:
{"user": {"id": "u-bob", "roles": ["payment-approver"],
"approval_limit": 25000},
"payment": {"id": "PAY-7781", "amount": 50000,
"created_by": "u-bob", "status": "PENDING"}}
Then ask OPA (real output from OPA 1.21.4 on macOS):
$ ./opa eval -d payments.rego -i bob.json -f pretty \
"data.payments"
{
"allow": false,
"why": [
"maker can't be checker",
"over approval limit"
]
}
Now copy it to alice.json, change the user to "id": "u-alice" with limit 100000, and you
should see "allow": true with an empty why. Finally, delete approval_limit from Alice's file
and run it again:
{
"allow": false,
"why": []
}
Look at that carefully. No reason fired, because 50000 > (missing) isn't true, it's undefined.
allow is still false because of default allow := false. If you'd written "allow when the list of
reasons is empty", missing data would have approved the payment: a fail-open bug (L04).
🧠 Key takeaways
- RBAC is simple and auditable but explodes when rules depend on the request; ABAC handles amounts, regions and tags; ReBAC handles "whose is this?" through relationships.
- Deny by default, and write
allowas positive conditions, so missing data means "no". - Decide in one place (one function or one PDP such as OPA), enforce at every layer: the gateway for coarse checks, the service for object-level checks.
- Put the check inside the operation, not in each controller, and give the rule its own tests.
- Scattered authorization is how BOLA (OWASP API1:2023) happens.
📖 Words to know
| Term | Meaning |
|---|---|
| RBAC | Role-based access control: permissions granted to roles, roles granted to users |
| Role explosion | Roles multiplying because every combination of conditions needs its own role |
| ABAC | Attribute-based access control: rules over attributes of subject, resource, action and environment |
| ReBAC | Relationship-based access control: access follows relationships between users and objects |
| Relation tuple | A Zanzibar-style fact, object#relation@user, such as account:ACC-5521#advisor@user:alice |
| PEP | Policy enforcement point: the code that asks for a decision and enforces it |
| PDP | Policy decision point: the engine that evaluates policy and returns allow or deny |
| OPA / Rego | Open Policy Agent, a general-purpose policy engine, and its policy language |
| Admission controller | A Kubernetes component that can reject or change objects before they're stored |
| BOLA | Broken Object Level Authorization: acting on an object the caller shouldn't be able to reach |
⏭ Next time
L28 is a case study: in 2022 an attacker spammed an Uber contractor with MFA prompts until one was accepted, then found admin credentials hard-coded in a PowerShell script.
AWS IAM policy evaluation: how allow, deny, SCPs and permission boundaries combine
If you build on AWS, IAM is the access control system you'll meet most often, and it mixes all the ideas above:
roles (RBAC), conditions and tags (ABAC), and several policy types that each get a say. The classic
confusion: "My permission set grants s3:*, so why do I get AccessDenied?" The answer is that
other policies can veto it.
The policy types
- Identity-based policy: attached to a role or user (an IAM Identity Center permission set becomes one). It grants.
- Resource-based policy: attached to the resource, such as a bucket policy or KMS key policy. It can grant, including to other accounts.
- Service control policy (SCP): set in AWS Organizations on an account or OU. It limits what principals in those accounts can do. It never grants anything by itself.
- Resource control policy (RCP): also in Organizations (added in late 2024). It limits what can be done to resources in those accounts, whoever is calling.
- Permissions boundary: attached to a role or user. It limits the maximum that the identity policy can grant.
- Session policy: passed when assuming a role. It limits that one session.
The order AWS applies them
For a request within a single account, AWS's documented logic is:
request arrives
│
▼
any explicit Deny in ANY policy? ──yes──▶ DENY (final)
│ no
▼
RCPs allow it? ───────────────────no───▶ DENY
│ yes (or no RCPs)
▼
SCPs allow it? ───────────────────no───▶ DENY
│ yes (or no SCPs)
▼
resource policy grants THIS ─────yes──▶ ALLOW (some cases,
principal directly? see note below)
│ no
▼
identity policy allows it? ──────no───▶ DENY (implicit)
│ yes
▼
permissions boundary allows it? ─no───▶ DENY
│ yes (or none)
▼
session policy allows it? ───────no───▶ DENY
│ yes (or none)
▼
ALLOW
Three rules fall out of it:
- Everything starts as an implicit deny. (The account root user is the exception.)
- An explicit deny always wins, wherever it's written.
- Limiting policies intersect. The effective permission is roughly identity policy ∩ SCP ∩
boundary ∩ session policy. A boundary that allows
s3:*grants nothing on its own.
The note: within one account, a resource-based policy that names a specific IAM user or role session can grant access even when the identity policy doesn't, and the details differ by principal type and by service (KMS key policies and role trust policies always need their own explicit allow). Cross-account requests are stricter: both sides must allow. When in doubt, check AWS's "Policy evaluation logic" page rather than memory.
Worked example: three requests
A developer's permission set has broad S3 rights. The organization and the role add limits:
identity (permission set):
Allow s3:* on *
permissions boundary:
Allow s3:*, logs:* on *
SCP on the account:
Deny * if aws:RequestedRegion != us-east-1
(plus the default FullAWSAccess allow)
bucket policy on example-pii-archive:
Deny s3:GetObject unless aws:PrincipalTag/team == "risk"
| Request | Walk the chart | Result |
|---|---|---|
s3:GetObject on example-reports, us-east-1 |
No deny; SCP ok; identity allows; boundary allows | Allow |
s3:ListBucket on a bucket in eu-west-1 |
SCP explicit deny on region | Deny |
s3:GetObject on example-pii-archive, team tag is payments |
Bucket policy explicit deny | Deny, even though the permission set says s3:* |
kms:Decrypt on any key |
Identity policy doesn't allow kms:* (and the key policy must allow it too) |
Deny (implicit) |
Read the third row twice. That's ABAC as a guardrail: a resource owner can protect sensitive data with one explicit deny, no matter how generous someone's role is. The fourth row shows why a permissions boundary is safe to set wide: it caps, it doesn't grant.
Why this matters for your design
It's the same pattern as the main lesson: one decision procedure, many layers that can only narrow. SCPs are the organization's guardrails, the permission set is the job role, boundaries stop delegated admins from creating something more powerful than themselves, and explicit denies on resources protect the crown jewels. IAM in full, including AssumeRole and the policy simulator, is L46.
🧠 Extra takeaways
- Default is deny; an explicit deny anywhere ends the evaluation.
- SCPs, RCPs, permissions boundaries and session policies only limit; identity and resource policies grant.
- Effective access is the intersection of the limits with what's granted.
- An explicit deny on the resource is a strong, simple guardrail for sensitive data.
Kubernetes RBAC: the verbs that are secretly admin
Kubernetes RBAC is pure RBAC: rules list verbs (get, list, watch, create, update,
patch, delete, and a few special ones) on resources in API groups. It's easy to write a Role
that looks harmless and isn't.
The four objects
namespaced cluster-wide
WHAT Role ClusterRole
(rules in one ns) (rules for all ns, or for
cluster objects like nodes)
▲ ▲ ▲
WHO │ roleRef │ │ roleRef
RoleBinding ──────roleRef──────┘ │
(grants in one ns; may ClusterRoleBinding
point at a ClusterRole) (grants everywhere)
│ │
└─────────── subjects ─────────────┘
users, groups, ServiceAccounts
Two facts drive everything else. First, the Kubernetes docs say permissions are "purely additive
(there are no 'deny' rules)", so you can't grant * and carve out an exception. Second, on EKS your
AWS identity is mapped to a Kubernetes user or group (access entries, or the older aws-auth
ConfigMap), so IAM decides who you are and RBAC decides what you can do.
The verbs that are secretly admin
The Kubernetes "RBAC good practices" page lists permissions that let someone escalate. The ones you are most likely to hand out by accident:
| Permission | Why it's more powerful than it looks |
|---|---|
list or watch on secrets |
The docs note a list response includes the contents of every Secret, so it's as good as get |
create on pods (or Deployments, Jobs, CronJobs) |
A pod can mount any Secret or ConfigMap in the namespace and run as any ServiceAccount there, inheriting its permissions |
Access to pods/exec |
Run commands inside existing pods, with their tokens and mounted secrets |
escalate on roles / clusterroles |
Lets you write a Role containing permissions you don't have |
bind on roles / clusterroles |
Lets you bind a Role you don't hold, such as cluster-admin, to yourself |
impersonate on users, groups or serviceaccounts |
Lets you act as someone else, including an admin |
create on serviceaccounts/token |
Mint tokens for a ServiceAccount, and act as it |
get on nodes/proxy |
The docs warn it's "not a read-only permission": it reaches the kubelet API, which can run commands in every pod on the node, bypassing audit logging and admission control |
Without escalate and bind, Kubernetes protects you: you can only create a Role, or a binding to
a Role, if you already hold every permission in it. Those two verbs remove that safety net.
Worked example: the "deploy-only" CI role
A pipeline needs to roll out new images. Someone writes:
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata: {name: ci-deployer, namespace: payments}
rules:
- apiGroups: ["", "apps"]
resources: ["*"]
verbs: ["get", "list", "watch", "create", "patch"]
It looks modest: no delete, one namespace. But resources: ["*"] in the core group includes
secrets (list reveals them all) and pods (create one that mounts any secret or runs as the most
privileged ServiceAccount in payments). Anyone who steals the pipeline's credentials owns the
namespace. Narrowed:
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["payments-svc"]
verbs: ["get", "patch"]
It's still powerful (patching a Deployment can change its pod template, and so its ServiceAccount and mounts), which is why the second layer matters: admission control. A Gatekeeper, Kyverno, or built-in ValidatingAdmissionPolicy rule can reject pod templates that use an unexpected ServiceAccount, mount Secrets they shouldn't, or run privileged. That's the main lesson's pattern again: RBAC decides who may submit, admission policy decides what may be submitted.
Checking what an identity can do
On a cluster you're authorized to use (a dev namespace; this is L50's Try it), kubectl auth can-i
asks the API server to evaluate RBAC for you:
kubectl auth can-i list secrets -n payments
kubectl auth can-i create pods -n payments \
--as=system:serviceaccount:payments:ci-deployer
kubectl auth can-i --list -n payments
The --as flag uses impersonation itself, so it only works if you hold the impersonate verb:
a nice reminder of how powerful that verb is.
🧠 Extra takeaways
- Kubernetes RBAC is additive only; there are no deny rules, so grant narrowly from the start.
create pods,list secrets,pods/exec,get nodes/proxy,escalate,bindandimpersonateare admin-adjacent.- Avoid
*inresourcesandverbs; useresourceNameswhere you can. - Pair RBAC with admission policy so even permitted users can't submit dangerous workloads.
The quiz for this lesson and the Tests section need JavaScript.