RBAC tells you what; ABAC tells you whether

Most access incidents are not exotic exploits. They are authorization-model failures: roles sprawl until nobody can say what a job title actually grants, two duties that should be separated land on one person, a federated login carries the wrong claims into an app, and a service-account key minted three years ago still works. Role-based access control (RBAC) is necessary but not sufficient. It answers what is this principal entitled to in general — and it is genuinely good at that, because identity → role → entitlement keeps access intentional instead of a pile of one-off grants. What RBAC cannot say is whether this specific request, right now should proceed. That is the job of attribute-based access control (ABAC), and an enterprise authorization layer needs both.

The thing RBAC structurally can't express is a relationship between attributes: this user's clearance must meet or exceed the record's classification; the requester must be in the resource's owning department; a high-value wire approval requires step-up MFA; nothing sensitive is served from an untrusted network. None of those are roles. They are predicates over the subject, the resource, the action, and the environment — the four attribute categories XACML formalized and every modern policy engine still uses.

Policy as data, deny by default

The discipline that keeps an ABAC engine maintainable is treating policy as data, not as branches buried in application code. A rule is a predicate plus an effect plus optional obligations — side instructions like “require MFA” or “log this read” that ride along with the decision. Rules combine under an explicit algorithm. For anything security-sensitive that algorithm is deny-overrides: a single applicable DENY wins over any number of ALLOWs, and when no rule is applicable the answer is DENY. Closed by default is not a nicety; it is the entire posture.

guardrail.add(Rule(
    "require-mfa-for-high-value-wire",
    Effect.DENY,
    all_of(
        equals("action", "operation", "approve"),
        equals("resource", "type", "wire"),
        greater_than("resource", "amount_usd", 100_000),
        equals("subject", "mfa", False),
    ),
    obligations=["step-up:require-mfa"],
))

Because the condition is composed from small combinators, the engine itself has zero domain knowledge — the same evaluator that gates a wire approval gates a production deploy or a record read. That separation is also what makes the policy testable in isolation, the same way I argue for making background jobs idempotent before reaching for any library in Sidekiq Idempotency and Reliability: get the decision boundary clean and provable, then layer the machinery on top.

Federation is a claim-transformation problem

SSO with PingFederate, PingAccess, or Entra ID looks like a protocol problem and is really a data-mapping problem. The identity provider holds raw directory attributes; the relying party wants claims in its vocabulary. The trust establishes who may assert identities to whom; the claim mapping translates — renaming attributes, defaulting missing ones, and turning an AD/Entra group like Finance-Controllers into the application role controller. That translation layer is exactly where real federation deployments break, so it deserves to be a first-class, tested transform rather than a string hack inside a login handler. Model it explicitly and the issued assertion — the SAML-assertion or OIDC-ID-token analogue, with issuer, audience, validity window, and transformed claims — becomes something you can assert on in a test.

Non-human identities outnumber the humans

The fastest-growing identity population in any enterprise is the one without a face: service accounts, API teams, CI/CD runners, workload identities. They routinely lack the joiner/mover/leaver discipline humans get. Governing them means three concrete things: every non-human identity has an accountable owner; credentials are issued with an explicit expiry and can be rotated; and a scan continuously surfaces what's wrong — expired-but-still-active keys, credentials expiring soon, stale ones nobody uses, and ownerless identities. Rotation has a correctness subtlety worth stating plainly: issue the new credential before retiring the old one, so a running workload is never left with zero valid secrets during the cutover. Get the ordering wrong and you've turned a routine rotation into an outage — the same class of operational care I describe for schema changes in Zero-Downtime Migrations at Scale.

One provable trail across all of it

RBAC, ABAC decisions, federated logins, and credential lifecycle all need to land on a single tamper-evident audit log — a hash chain where each record commits to the previous one, so any retroactive edit, deletion, or reorder is detectable. Without that, “who approved this access and when” is an unanswerable question, and the difference between an audit you pass and one you fail is exactly your ability to answer it. The architecture that makes this tractable is the same one that makes a slow query tractable: a single, well-defined source of truth that every control reads from, rather than several subsystems quietly disagreeing — the systematic mindset I bring to performance work in Hunting N+1 Queries Systematically. Identity is, in the end, a systems-engineering problem: model the entities precisely, make the controls read from one notion of effective access, and prove every change.

Run it

The full source is on GitHub — github.com/tachyurgy/iam-access-patterns (MIT). Cloned fresh, it runs with its base toolchain and nothing else. Here is an actual run:

$ python3 app.py
========================================================================
1. IDENTITY PROVISIONING (human + non-human) & RBAC
========================================================================
  Human identities : 12
  Roles            : 8
  Entitlements     : 13
  Non-human ids    : 4 (service_account, api_client, automation, workload)
  Sample RBAC assignments:
    Blair Nunez    roles: controller
    Harlow Reed    roles: sre
    Frankie Ito    roles: engineer
========================================================================
2. ABAC POLICY DECISIONS (attribute-based, deny-overrides)
========================================================================
  on-call eng -> deploy prod (trusted)           ALLOW (permitted; no policy denied) | obligations: log:prod-deploy, notify:release-channel
  on-call eng -> deploy prod (untrusted net)     DENY  (deny-overrides: rule 'block-untrusted-network' denied) | obligations: alert:security-soc
  finance user -> read finance record            ALLOW (permitted; no policy denied) | obligations: log:read-access
  finance user -> read top-secret record         DENY  (deny-overrides: rule 'deny-insufficient-clearance' denied)
  controller -> approve $250k wire (no MFA)      DENY  (deny-overrides: rule 'require-mfa-for-high-value-wire' denied) | obligations: step-up:require-mfa
  unknown action -> default decision             DENY  (no applicable policy (deny by default))
========================================================================
3. FEDERATION / SSO LOGIN WITH CLAIM MAPPING
========================================================================
  IdP: Example Corp Entra ID
  RP: Engineering Workspace [OIDC]
    subject (NameID/sub): frankie.ito
    issuer  : https://login.example-corp.com
    audience: urn:app:workspace
    valid   : True  (lifetime 3600s)
    mapped claims (IdP attrs -> app claims):
        sub        = 'frankie.ito'
        email      = 'frankie.ito@example-corp.com'
        dept       = 'Engineering'
        roles      = ['engineer', 'sre']
        amr        = ['mfa']
  RP: Finance Portal [SAML2]
    subject (NameID/sub): blair.nunez
    issuer  : https://login.example-corp.com
    audience: urn:app:finance-portal
    valid   : True  (lifetime 1800s)
    mapped claims (IdP attrs -> app claims):
        NameID     = 'blair.nunez'
        Department = 'Finance'
        AppRole    = ['controller']
========================================================================