Evolution of Access Control Explained Using Python

MIT’s CTSS was designed in 1961 to provide multiple users independent share of a large computer. The designers soon discovered there was a huge appetite to share programs and data with one another. This sparked some of the earliest conversations around computer security and led to security being an explicit design goal for Multics. Years after Multics’ release, Saltzer and Schroeder published The Protection of Information in Computer Systems which took lessons from its design and real-world use. Their work is one of the most cited security papers in history and the first to use many terms we use today including “Least Privilege”.
In this article, I will present three access control methods beginning with Access Control List (ACL) - the mechanism implemented by Multics - then explore Role Based Access (RBAC) followed by Attribute Based Access (ABAC). In a later post I will examine Purpose Based Access Control (PBAC) which was introduced in 2008 as the industry was prioritizing privacy on the web. PBAC provides a way to map data access intentions with the intentions found in privacy policies agreed to by users. It relies on mechanisms provided by RBAC and ABAC so to understand PBAC, I’d like to first get comfortable with the mechanisms powering both RBAC and ABAC. I often feel that implementing something in code helps me fully grok how it works. I will attempt to cement some of these access control concepts in the reader’s mind by writing some Python.
We’re going to work up to RBAC by first exploring ACLs since RBAC was designed to account for ACL’s drawbacks. Let’s begin by setting up some code to support our examples.
from types import NoneType
from typing import Callable, TypeVar, Generic, Optional
from dataclasses import dataclass
from enum import Enum
PrincipalId = int
# Represents an individual seeking access (e.g. an engineer responding to an
# incident)
@dataclass
class Principal:
id: PrincipalId
name: str
# Different access control methods require different types of metadata
# attached to records (e.g. ACLs, opt-in information)
RecordMetadata = TypeVar("RecordMetadata")
RecordId = int
# Single patient record
@dataclass
class Record(Generic[RecordMetadata]):
id: RecordId
patient_name: str
dob: int
metadata: Optional[RecordMetadata] = None
# An action the principal would take on a `Record` or `Record`s
class Action(Enum):
READ = 1
WRITE = 2
The components defined here will be used in each of three following access control examples.
- Principal - The thing attempting to gain access. An example could be an AWS IAM user for an engineer on your team who needs to get into production.
- Record - The thing the
Principal
is trying to access. In our examples, we’ll model simple patient records but in practice this could be a file or folder on a filesystem, a cloud resource such as AWS’ S3 or EC2 or a row in a database. The role thatRecord
plays in our model is referred to asobject
orresource
in many software systems or security research papers. I’ve chosenRecord
because it supports a concrete example in patient records. - Action - What the
Principal
wants to do with theRecord
.
Using these terms, we can state the purpose of any access control system as to answer the question: “Is this Principal
authorized to perform this Action
on this Record
?” ACL, RBAC and ABAC have different capabilities and qualities around scalability and flexibility but they will each allow us to get to the answer.
Let’s add one more class. We need to implement a System
that can perform Action
s on our Record
s.
Authorizer = Callable[[Principal, Action, Record], bool]
class System:
def __init__(self, records: list[Record], authorizer: Authorizer):
self.records = records
self.is_authorized = authorizer
def get(
self,
record_id: RecordId,
principal: Principal
) -> Optional[Record]:
"""Return a record if the Principal has Action.READ access to it and
return None if not
"""
for record in self.records:
if record_id == record.id and self.is_authorized(
principal, Action.READ, record
):
return record
return None
def update(
self,
record_id: RecordId,
principal: Principal,
updates: dict
):
"""Update the Record with id equal to record_id only if the
Principal has Action.WRITE access. Otherwise do nothing.
"""
for record in self.records:
if record.id == record_id and self.is_authorized(
principal, Action.WRITE, record
):
for (k, v) in updates.items():
setattr(record, k, v)
Notice that records
and an Authorizer
are passed into to create a new System
. It’s here where we’ll implement the specifics of each access control scheme. In ACL and ABAC we’ll attach different types of metadata to our records and our RBAC implementation won’t see any Record
metadata. But the biggest difference will exist in the logic and context brought by the authorizer
function and it’s closure.
Before coding up an implementation of access control lists, we should define the success criteria for all of our forthcoming implementations. The design goal of System
was to enable the same tests to be run on different implementations of Authorizer
. Before we define those tests let’s set up some common test data.
import pytest
# Members of your engineering team.
@pytest.fixture
def principals() -> tuple[Principal, Principal]:
return (Principal(1, "Alice"), Principal(2, "Bob"))
# Patient records we need to protect.
@pytest.fixture
def records() -> list[Record[NoneType]]:
return [
Record[NoneType](1, "Alyssa", 1965),
Record[NoneType](2, "Ben", 1974),
]
# In some cases, we are going to want metadata on our records. For example,
# ACLs will be attached to records and ABAC will allow us to use any
# attribute we want to attach to any record
def records_with_metadata(
metadata: tuple[RecordMetadata, RecordMetadata]
) -> list[Record[RecordMetadata]]:
return [
Record[RecordMetadata](1, "Alyssa", 1965, metadata[0]),
Record[RecordMetadata](2, "Ben", 1974, metadata[1]),
]
Now let’s create a test plan that will test that a given access control system can enforce the following scenario:
Principal | Alyssa’s Patient Record | Ben’s Patient Record |
---|---|---|
Alice | READ, WRITE | READ, WRITE |
Bob | — | READ |
def authorizer_tests(authorized: Authorizer, records: list[Record]):
"""Asserts that:
1. Alice gets READ and WRITE access to both records.
2. Bob only gets READ access to Ben's record.
"""
system = System(records, authorized)
assert records[0] == system.get(records[0].id, ALICE)
assert not system.get(records[0].id, BOB)
assert records[1] == system.get(records[1].id, BOB)
system.update(records[0].id, ALICE, {"dob": 1994})
assert 1994 == system.get(records[0].id, ALICE).dob
system.update(records[1].id, BOB, {"dob": 2006})
assert 1974 == system.get(records[1].id, BOB).dob
We can use these assertions to verify each System
we are about to create. One note: we are not supporting explicit denies which is a common capabiltiy in modern access control. It’s worth learning how explicit denies can effect interations of RBAC roles and ABAC rules but they are not in scope for the concepts we’ll cover today. All permissions we define below will have the effect of allowing the provided Action
.
Access Control Lists
ACLs were first implemented to protect the Multics filesystem in 1965. Today, they are commonly used in file systems, networking security, and cloud services. The first access controls for S3 were in fact ACLs.
If you take another look at the table of patient records above, each column can be thought to represent an ACL. ACLs are resource-oriented controls, which is they are defined in the context of a specific resource (in our case an individual Record
). So there is a one-to-one relationship between Record
s and ACL
s.
For this reason, we’re going to attach our ACLs directly to the Record
s rather than include a reference (e.g. record_id: RecordId
) as a property of the ACL. Though specific ACLs implementations may defer, this approach helps understand how ACLs are logically structured from the system administrator’s point of view. This will help us understand the strengths and weaknesses of ACLs and help us highlight their differences from the other access controls.
def test_acl(principals: tuple[Principal, Principal]):
@dataclass
class AccessControl:
principal: Principal
actions: set[Action]
(alice, bob) = principals
alice_rw = AccessControl(alice, {Action.READ, Action.WRITE})
bob_r = AccessControl(bob, {Action.READ})
# Create new records with ACLs directly attached
records: list[Record[list[AccessControl]]] = records_with_metadata(
([alice_rw], [alice_rw, bob_r])
)
def acl_authorizer(
principal: Principal,
action: Action,
record: Record[list[AccessControl]],
) -> bool:
from collections.abc import Iterable
if isinstance(record.metadata, Iterable):
for acl in record.metadata:
if acl.principal.id == principal.id and action in acl.actions:
return True
return False
# main.py::test_acl PASSED
authorizer_tests(acl_authorizer, records, principals)
Great! This passes our tests and helps us understand how a simple ACL-based system could work. The System
uses our implementation of Authorizer
to inspect the list of AccessControls
attached to a Record
to determine of the Principal
can perform the Action
on that Record
.
This also helps us understand it could be difficult to scale a team and number of records with a simple implementation like this. Some policy updates could require you to update every single Record
. Two features found in modern ACL implementations that help manageability include:
- References to groups of
Principal
s rather than individualPrincipal
s - References to groups or hierarchies of
Records
rather than individualRecord
s
If we were to replace the reference to Principal
with a reference to a new Group
object containing a list of Principal
s (or other Group
s), system administrators could move Principal
s in and out of Group
s without impacting ACLs. The Groups
can be configured in hierarchies for added flexibility and have names that map to business functions to help reason about who should get access to what.
Further, if we also allowed Records
to be grouped (perhaps via Table
s or Folder
s), we could add and remove references to Record
s without updating the ACLs themselves. ACL-based systems that support both capabilties approach a minimal RBAC implementation. The missing piece would be to externalize Actions
so they don’t need to be defined for every Record
and we could get rid of ACLs all together. RBAC provides the model we need to do just that.
Role Based Access
RBAC was introduced in 1992 to make it easier to define relationships between Principal
s and the Record
s we’re trying to protect. Roles contain references to both Users and Objects or in our case Principal
s and Record
s. They also contain the Action
s the Principal
s are able to perform on those Record
s.
From the 1992 paper introducing RBAC
Let’s build a naive RBAC implementation to get a feel for its capabilities.
def test_rbac(
principals: tuple[Principal, Principal],
records: list[Record[NoneType]]
):
# The Role allows us to define relationships between many Principals and
# many Records in one place.
@dataclass
class Role:
name: str
principal_ids: set[PrincipalId]
permissions: list[tuple[set[Action], set[RecordId]]]
def has_principal(self, id: PrincipalId) -> bool:
return id in self.principal_ids
def has_action_for_record(
self, action: Action, record_id: RecordId
) -> bool:
for (actions, record_ids) in self.permissions:
if action in actions and record_id in record_ids:
return True
return False
# Convenience method to pull ids out of records.
def get_ids(records) -> set[RecordId]:
return set(map(lambda r: r.id, records))
(alice, bob) = principals
roles = [
Role(
"Admin",
{alice.id},
[ ({Action.READ, Action.WRITE}, get_ids(records)) ],
),
Role(
"ReadOne",
{bob.id},
[ ( { Action.READ }, get_ids(records[1:])) ]
),
]
def rbac_authorizer(
principal: Principal, action: Action, record: Record[NoneType]
) -> bool:
for role in roles:
if (role.has_principal(principal.id) and
role.has_action_for_record(action, record.id)
):
return True
return False
# main.py::test_rbac PASSED
authorizer_tests(rbac_authorizer, records, principals)
Notice that our records
no longer have metadata
which is where we were storing our ACLs. That metadata has been promoted to its own Role
object which means we need to include references back to the Records
in the form of RecordId
s. Consider an example where we have a common permission set and many Record
s. RBAC makes our life easier by allowing us to create one Role
with that permission set rather than putting the same permissions in a list of AccessControl
s for each Record
.
Features not expressed in this example include role hierarchies in which a Role
can inherit permissions from its ancestors. This is handy since organizations often have HR hierarchies that roughly map to gradually more permissive permissions schemes. Another difference is that this code has no concept of an “active” Role. It just checks all Role
s that refer to the Principal
and Record
in question. Finally, another detail that could be considered missing from this example: most modern RBAC systems support Principal
groups so a Principal
hierarchy can be defined independent of the Role
hierarchy.
RBAC and Least Privilege
RBAC is extremely common in software systems and has brought us a long way since it was formalized in 1992. The downsides become apparent as least privilege gains priority in an organization. Whittling Role
s down to only provide the minimum number of permissions needed can yield a large number of permutations resulting in an explosion of Role
s to manage. Consider that there are 243 possible Actions for AWS’ S3 alone. Modern security policies are also incorporating new attributes that can offer more protection such as a user’s IP address or whether this person is attempting access during business hours. Pure RBAC is missing capabilities to support these requirements. This is where ABAC comes into the picture.
Attribute Based Access Control
RBAC and ACLs enable you to define relationships between subjects (Principal
s) and resources (Record
s). ABAC allows you to make those relationships conditional based on attributes. Those attributes can come from the Principal
, the System
or the data itself. The ability to define access logic vs strict relationships as well as the ability to incorporate such a rich set of information makes ABAC very powerful.
ABAC is also known as policy-based access control. To model it out, we’ll create a Policy
class that contains a set of Rule
s as well as the Principal
s and Record
s those rules pertain to. Let’s see if we can implement a simple attribute-based control system that can help us pass the tests we’ve been using.
def test_abac(
principals: tuple[Principal, Principal],
records: list[Record[NoneType]]
):
import operator as op
# Comparison operators for comparing attributes to values in our Policies
operators = {
"=": op.eq,
"!=": op.ne,
"any": lambda _1, _2: True,
"true": lambda x, _: bool(x),
"false": lambda x, _: not x,
}
# A Rule holds the information required to look up an attribute in the
# right entity and compare it to a given value. An example of a rule
# could be "RecordAttributes.email_opt_out = True" to represent a record
# related to a user who has opted out of email correspondence.
@dataclass
class Rule:
entity_name: str
attribute_name: str
operator: str
compare_value: Optional[Any] = None
# Policy has Principals and Actions but not Records. Whether a Principal
# has access to a given record is determined by the conditions defined
# in the Rules
@dataclass
class Policy:
name: str
principal_ids: set[PrincipalId]
actions: set[Action]
rules: list[Rule]
def has_principal(self, id: PrincipalId) -> bool:
return id in self.principal_ids
def has_action(self, action: Action) -> bool:
return action in self.actions
(alice, bob) = principals
# Define policies to pass our tests (even if this is not a realistic use
# case for ABAC)
policies = [
Policy(
"Admin",
{alice.id},
{Action.READ, Action.WRITE},
[Rule("Record", "id", "any")],
),
Policy(
"ReadOne",
{bob.id},
{Action.READ},
[Rule("Record", "id", "=", 2)],
),
]
# This will evaluate a policy for every record passed in. We're only
# going to support attributes in Records to get the tests to pass.
def abac_authorizer(
principal: Principal, action: Action, record: Record[NoneType]
) -> bool:
for policy in policies:
if (policy.has_principal(principal.id) and
policy.has_action(action)
):
for rule in policy.rules:
if rule.entity_name == "Record":
# Evaluate the Rule by pulling the attribute from the
# Record and comparing it to the value in the Rule
record_value = getattr(record, rule.attribute_name)
if operators[rule.operator](
record_value, rule.compare_value
):
return True
return False
# main.py::test_abac PASSED
authorizer_tests(abac_authorizer, records, principals)
We did the minimum to get our tests to pass by creating rules that referred directly to RecordId
s. This is not a likely or practical application of ABAC but it demonstrates how the mechanics differ from RBAC and ACLs. Most ABAC-based access control systems work along with RBAC so you can assign Rule
s to Role
s intead of a static list of PrincipalId
s. If we were using a hybrid ABAC/RBAC system, we’d probably just set up our Role
s to pass our tests and not bother with Rule
s.
So what is a better example to get sense of the power of ABAC? Well, let’s take a minute to imagine all the types of attributes we could possibly have available to us so that we can craft extremely secure policies.
Examples of attributes that an ABAC system could have available to it
Let’s add code to the same method to help us further explore the capabilities of an ABAC-based system.
# Convenience function to get the value of an attribute that a rule is
# referring to (e.g. `Environment.business_hours`)
def get_attribute_value_from_entity(rule: Rule, entities: list):
for entity in entities:
if rule.entity_name == type(entity).__name__:
return getattr(entity, rule.attribute_name)
# A class to hold System attributes
@dataclass
class Environment:
business_hours: bool # Is the current time within business hours
# We want to test a few individual policies. We'll do that by redefining
# abac_authorizer with different combinations of Policy and Environment
# objects
def get_system(
policy: Policy,
records: list[Record],
env: Environment = Environment(True)
) -> System:
def abac_authorizer(
principal: Principal, action: Action, record: Record[NoneType]
) -> bool:
if (policy.has_principal(principal.id) and
policy.has_action(action)):
for rule in policy.rules:
value = get_attribute_value_from_entity(
rule, [record, record.metadata, env, principal]
)
if operators[rule.operator](value, rule.compare_value):
return True
return False
return System(records, abac_authorizer)
# Let's see if we can create some interesting policies ...
# Policy 1: Alice can only access Records during business hours
business_hours_policy = Policy(
"BusinessHoursAccess",
{alice.id},
{Action.READ},
[Rule("Environment", "business_hours", "true")],
)
# Should be able to access during business hours
system = get_system(business_hours_policy, records, Environment(True))
assert system.get(1, alice)
# Should not be able to access after business hours
system = get_system(business_hours_policy, records, Environment(False))
assert not system.get(1, alice)
# Policy 2: Make sure we ignore `Record`s where the user opted out
# Let's start by attaching opt out attributes to each record
@dataclass
class RecordAttributes:
user_opt_out: bool
# Create a set of `Record`s with an attribute that indicates whether the
# user has opted out of email correspondence
opt_outs: list[Record[RecordAttributes]] = records_with_metadata(
(RecordAttributes(True), RecordAttributes(False))
)
opt_out_policy = Policy(
"IgnoreOptOuts",
{alice.id},
{Action.READ},
[Rule("RecordAttributes", "user_opt_out", "false")],
)
system = get_system(opt_out_policy, opt_outs)
# Should not be able to access the first record (user_opt_out == True)
assert not system.get(1, alice)
# Should be able to access the second record (user_opt_out == False)
assert system.get(2, alice)
# Policy 3: Sorry Bob
no_bobs = Policy(
"NoBobs",
{bob.id},
{Action.READ},
[Rule("Principal", "name", "!=", bob.name)],
)
system = get_system(no_bobs, records)
assert not system.get(1, bob)
assert not system.get(2, bob)
bob.name = "Robert"
assert system.get(1, bob)
# Still passing!
# main.py::test_abac PASSED
Hopefully these examples provide a glimpse into the power and flexibility of ABAC. Modern ABAC systems include RBAC capabilities so you can get the power of conditional policies combined with the flexibility of hierarchies. They also include much more expressive policy languages than I’ve created here. Check out XACML to get an idea of how expressive ABAC policies can be.
If you’ve spent anytime managing access for software systems, you almost certainly used some version of ABAC and RBAC so these mechanisms should be familiar to you. The point to coding some of this logic is to set the foundation for exploring Purpose Based Access Control which requires capabilities of ABAC to create Conditional Roles. Look for a code-heavy exploration of PBAC in a future post.