Home › Enterprise Intelligence Architecture
Access Control & Policy Enforcement
Where RBAC actually collapses, what ABAC costs you, how a policy engine survives production, and why the rule belongs in the data plane rather than in the application.
In this area
Access control fails less often because someone wrote the wrong rule, and more often because the rule sits in the wrong place. An application protects exactly the paths you wrote; the warehouse has five more — the BI tool, the notebook, the Spark job, the read replica and the backup restore. This module works through the three decisions that determine the fate of the whole model: when to move from roles to attributes, how to externalise the decision into a policy engine without losing auditability or latency, and how to push enforcement down to rows and columns together with purpose limitation.
RBAC and Its Collapse Point: From Role Explosion to Attributes
RBAC works for exactly as long as an access right is a function of job title alone. The model is minimal: a subject gets a role, the role carries permissions, the check is a table lookup. Its real value is not simplicity but analysability: the question 'who can see this table?' is answered by one SELECT, and the answer is complete.
The collapse begins the moment a decision starts depending on properties of the resource or the context — the record's classification, its jurisdiction, project membership, time of day, the declared purpose of the request. RBAC has nowhere to hold those properties, so the only way to express them is to encode the combination into the role name itself. The catalogue becomes a Cartesian product:
|jobs| × |jurisdictions| × |classifications| × |teams|= 14 × 9 × 4 × 30 = 15,120 roles standing in for 14 actual jobs;- each new jurisdiction costs not one role but 14 × 4 × 30 = 1,680 new roles;
- entitlement review stops being possible: no human meaningfully attests to fifteen thousand rows.
ABAC (NIST SP 800-162) moves those properties out of the role name and into data. The decision is computed as a function over four attribute groups — subject, resource, action and environment. The role does not disappear: it survives as one subject attribute among many.
- What ABAC buys: a new jurisdiction or classification tier becomes a value in a reference table, not a new object in the entitlement catalogue. The policy text is unchanged.
- What ABAC costs — attribute explosion: every attribute needs an owner, a system of record, a TTL and a revocation path. An attribute with no owner is a decision no one can audit.
- Loss of analysability: in RBAC 'who can see X' is a query; in ABAC it is a search over the policy set across the attribute space. The reverse question becomes computationally hard and has to be materialised separately.
- The hybrid that survives production: RBAC coarsely decides which call you may make (50–200 roles); ABAC precisely decides which rows and columns that call returns.
There is one practical test. If a new requirement is satisfied by adding a value to an attribute reference table, you are in ABAC. If it requires creating a role, you are already inside role explosion — you just have not counted it yet.
-- RBAC: every new axis multiplies the entitlement catalogue.
-- 14 job families x 9 jurisdictions x 4 classification tiers x 30 deal teams
SELECT count(*) AS role_count
FROM roles
WHERE name ~ '^analyst_[a-z]{2}_(public|internal|confidential|restricted)_deal[0-9]+$';
-- role_count = 15120 <- fourteen real jobs hide behind these
-- ABAC: the same 15120 grants collapse into one predicate over attributes.
-- The role does not vanish; 'analyst' survives as ONE subject attribute.
SELECT d.*
FROM deals d
WHERE current_setting('app.job_family') = 'analyst'
AND d.jurisdiction = ANY (string_to_array(current_setting('app.jurisdictions'), ','))
AND d.classification <= current_setting('app.clearance')::int
AND d.deal_team_id = ANY (string_to_array(current_setting('app.teams'), ',')::int[]);
-- Adding jurisdiction 'PL': one row in a reference table, zero new roles.
-- Under RBAC the same change would have minted 1680 of them.
In practice
A European investment bank ran its semi-annual entitlement review against a catalogue of 15,120 roles (14 job families × 9 jurisdictions × 4 classification tiers × 30 deal teams). 41% of roles had not been exercised once in 12 months, the average user held 3.2 roles, and the review itself consumed six weeks of two analysts' time. Remodelling to a hybrid — 62 roles plus 4 attributes sourced from HR and CRM reference data — shrank the catalogue 244-fold and the review to two days.The anti-pattern
Minting a role for every new combination (`analyst_de_confidential_deal17`) and calling it 'fine-grained RBAC'. The diagnostic is simple: to satisfy a business requirement you open the role catalogue rather than an attribute reference table.The Policy Engine: PDP/PEP/PIP, Policy-as-Code and the Latency Budget
Externalising the decision means separating four roles — fixed by the XACML architecture and reproduced by every modern engine: the PEP intercepts the access and applies the verdict, the PDP computes it, the PIP supplies attributes, the PAP administers policy. The value is not in the acronyms but in the consequence: the rule stops being scattered across if blocks in 340 services and becomes one artefact you can read, test and version.
Policy-as-code carries exactly five engineering obligations.
- Deny-by-default.
default allow := falseis the most consequential line in the file. A policy that opens with a permit leaks through every gap in its own coverage. - The combining algorithm is semantics, not style.
deny-overridesmeans an explicit prohibition — a legal hold, an embargo — beats every permit above it.permit-overridesover the same policy set and the same data returns the opposite verdict. - Testability. A policy is a pure function
(input) → decision, so it takes regression tests and passes CI exactly like code. - Reproducibility of the decision. The log must carry three things: the input, the policy version hash, and the as-of timestamp of the attributes. Without them you cannot demonstrate a year later why access was granted.
- A staleness bound on the bundle itself. A sidecar PDP serves whatever policy it last managed to pull. Stamp every bundle with a maximum permitted age and let the sidecar fail closed past it: without that bound, a node cut off from the distribution service keeps honouring revoked grants for days while reporting itself perfectly healthy.
Latency is where the naive architecture dies. A network PDP call costs 1–5 ms. If the PEP asks per row over a 50,000-row result set, you have just added 50 to 250 seconds to the query. The answer is not more PDP replicas but partial evaluation: the PDP receives the known subject attributes, evaluates everything it can, and returns not a yes/no but a residual predicate — a condition the query engine splices into its WHERE clause. One engine round trip instead of fifty thousand, and the filtering happens where the data lives.
Attribute freshness defines revocation lag. If the PIP caches attributes with a 15-minute TTL, a terminated employee retains access for up to 15 minutes. That is not a bug; it is a property of the design, to be either accepted deliberately or removed with push invalidation on lifecycle events.
package data_access
# Deny-by-default. The single most consequential line in this file.
default allow := false
# Coarse capability still comes from the role - RBAC survives here.
allow if {
input.action == "read"
input.subject.roles[_] == "claims_analyst"
clearance_ok
purpose_ok
not embargoed
}
clearance_ok if input.subject.clearance >= input.resource.classification
# Purpose is a request-time claim, never a property of the person.
purpose_ok if input.purpose in data.consent.allowed_purposes[input.resource.basis]
# deny-overrides: an explicit prohibition beats every permit above.
embargoed if input.resource.legal_hold == true
# Decision log contract - all three fields, or the decision is not reproducible.
decision := {
"allow": allow,
"policy_sha": data.build.git_sha,
"attrs_as_of": input.subject.attributes_fetched_at
}
# Partial evaluation with resource.* left unknown returns a RESIDUAL, not a boolean:
# classification <= 2 AND legal_hold = false
# The query engine splices that into WHERE. One engine call, not 50k.
In practice
An insurance marketplace kept authorisation logic inside 340 microservices — on average across 11 call sites per service. After consolidation into a single Rego bundle with a sidecar PDP: p99 authorisation latency 0.4 ms thanks to partial evaluation pushing the filter into the query; policy change lead time dropped from three weeks to 12 minutes; and the very first regression run over the policy uncovered 7 services that had been failing open on PDP timeout.The anti-pattern
Putting a network PDP call on every row of a result set and then, when queries start taking minutes, adding fail-open on timeout 'so users are not blocked'. That converts system overload into an automatic waiver of access control, precisely at the moment of greatest risk.Enforcement in the Data Plane: RLS, Column Masking and Purpose Limitation
An application protects exactly the paths you wrote. The same warehouse table is reachable through at least five other doors: a BI tool with its own connector, a data scientist's notebook, a Spark job, a logical replica, and a backup restore. A check in the portal controller governs none of them. This is not a question of team discipline — it is a question of the enforcement point sitting above the storage point.
The fix is to push the predicate into the data plane. Row-level security in Postgres, row filters and column masks in Snowflake, Databricks or Ranger, or access exclusively through one governed object layer — this is the problem Palantir Foundry's ontology layer and its policy tier solve. The mechanism differs; the invariant does not: no consumer has a path to the row that bypasses the rule.
- The table owner is not an exception. In Postgres the table owner bypasses RLS until
FORCE ROW LEVEL SECURITYis set; superusers and roles carryingBYPASSRLSbypass policies always, and FORCE does not reach them. An ETL job running as the owner reads everything — the most common silent hole — and a service role holding BYPASSRLS has to be taken out of the pipeline rather than covered by a flag. - Multiple policies on one table combine with
OR. A broader policy never narrows its neighbour; adding a 'research' policy alongside a 'treatment' policy widens access rather than refining it. - Mask the column, do not drop it. Dropping breaks the contract with downstream consumers; a deterministic token (HMAC under a shared pepper) preserves both the schema and joinability without exposing the value. A random UUID per read destroys joins silently.
Purpose limitation (GDPR art. 5(1)(b)) cannot be expressed as a role. The same clinician treats a patient on Tuesday and runs a retrospective study on Wednesday. Identity, job title and even the attribute set are identical — the lawful basis is not. Purpose must therefore be a request-time claim: the client asserts it explicitly, the policy checks it against the record's processing basis, and the log stores it alongside the decision. Effective access is the intersection of asserted purpose and lawful basis: if the policy permits research but the record carries no research consent, the verdict is deny.
Derived data inherits the restriction. A mart built from restricted rows without inherited rules becomes a laundering channel for access: what may not be read as a row is read as a sum. The minimum defence is a k-anonymity threshold on aggregates plus end-to-end lineage of the classification from source to mart.
-- One enforcement point in the data plane. The BI tool, the notebook and the
-- nightly export all traverse the same predicate. No application code involved.
ALTER TABLE encounters ENABLE ROW LEVEL SECURITY;
ALTER TABLE encounters FORCE ROW LEVEL SECURITY; -- applies to the owner too
-- Purpose is a request-time claim bound to the session, never a role.
CREATE POLICY encounters_treatment ON encounters FOR SELECT
USING (
current_setting('app.purpose', true) = 'treatment'
AND care_team_id = ANY (string_to_array(current_setting('app.care_teams', true), ',')::int[])
);
CREATE POLICY encounters_research ON encounters FOR SELECT
USING (
current_setting('app.purpose', true) = 'research'
AND consent_research = true -- lawful basis on the ROW
AND discharged_at < now() - interval '30 days'
);
-- Two policies on one table are OR-ed: adding one WIDENS access. Verify, do not assume.
-- Column level: mask, never drop. A deterministic token keeps the join key alive.
CREATE VIEW encounters_governed AS
SELECT id,
care_team_id,
CASE WHEN current_setting('app.purpose', true) = 'treatment'
THEN patient_mrn
ELSE encode(hmac(patient_mrn, current_setting('app.pepper', true), 'sha256'), 'hex')
END AS patient_mrn,
diagnosis_code
FROM encounters;
In practice
A hospital warehouse served three BI tools, the data science team's notebooks and a nightly export to the insurer, while access checks lived only in the portal. The audit found that 100% of restricted rows were readable from a notebook, bypassing the portal entirely. After moving the rules into RLS with a mandatory asserted purpose: one enforcement point, a 6% throughput cost at 220k rows/s, and every access carrying a purpose code in the log — data-subject request turnaround fell from nine days to forty minutes.The anti-pattern
Cutting a 'clean' copy of the table per audience instead of one rule over one source. The copies diverge by the second release, a revocation propagates to none of them, and the classification lineage terminates at the first `CREATE TABLE AS SELECT`.Sources this area derives from
- A Comparison of Attribute Based Access C
- A Role and Attribute Based Access Contro
- A Comprehensive Review of Access Control
- A Literature Review on Access Control in
- A Distributed Access Control Architectur
- A Multipolicy Authorization Framework fo
- Authentication and Authorization in Microservices
- A Comparison of Logical Formula and Enum
- Cryptographically Secure Information Flow Control on Key V
- A Framework for Policies over Provenance
- A Semantic Hierarchy for Erasure Policies
- A Practical Attribute Based Document Col
Work through it interactively
Every area has questions, spaced-repetition cards and a progress record. Those need an account, which is free and takes a moment.
Open the interactive track Create a free account