Evolution of Access Control Explained Using Python

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.

  1. 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.
  2. 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 that Record plays in our model is referred to as object or resource in many software systems or security research papers. I’ve chosen Record because it supports a concrete example in patient records.
  3. Action - What the Principal wants to do with the Record.

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 Actions on our Records.

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 Records and ACLs.

For this reason, we’re going to attach our ACLs directly to the Records 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:

  1. References to groups of Principals rather than individual Principals
  2. References to groups or hierarchies of Records rather than individual Records

If we were to replace the reference to Principal with a reference to a new Group object containing a list of Principals (or other Groups), system administrators could move Principals in and out of Groups 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 Tables or Folders), we could add and remove references to Records 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 Principals and the Records we’re trying to protect. Roles contain references to both Users and Objects or in our case Principals and Records. They also contain the Actions the Principals are able to perform on those Records.

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 RecordIds. Consider an example where we have a common permission set and many Records. 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 AccessControls 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 Roles 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 Roles down to only provide the minimum number of permissions needed can yield a large number of permutations resulting in an explosion of Roles 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 (Principals) and resources (Records). 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 Rules as well as the Principals and Records 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 RecordIds. 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 Rules to Roles intead of a static list of PrincipalIds. If we were using a hybrid ABAC/RBAC system, we’d probably just set up our Roles to pass our tests and not bother with Rules.

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.

Related Posts