CyberNugget

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 allow as 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.

Deep dive 1

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:

  1. Everything starts as an implicit deny. (The account root user is the exception.)
  2. An explicit deny always wins, wherever it's written.
  3. 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.
Deep dive 2

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, bind and impersonate are admin-adjacent.
  • Avoid * in resources and verbs; use resourceNames where 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.