Learnings from Building a Simple Authorization System (ABAC)

November 23, 2023 · 5 min read
Enes Cakir
Software Engineer

Ubicloud is an open and portable cloud. When we started working on it, we looked at how Identity and Access Management (IAM) systems were implemented in the public cloud. We were surprised by the big bifurcation. Hyperscalers such as AWS, Azure, and GCP, had powerful authorization models. Other cloud and hosting providers only had authorization at the most basic level.

So, we set out to deliver something as powerful as the authorization systems seen on the hyperscalers. In particular, we came up with the following requirements:

  • Correct - We can look at the authorization system and reason that it works correctly. Ideally, we can prove to ourselves that it works as expected.
  • Flexible - You can define complex access policies. These policies can then provide granular access control between roles, attributes, and resources.
  • Simple - Ideally, a developer can own and support the entirety of the system part-time. We primarily need users to be able to express complex relationships; and we’re happy to trade-off nice to have features.

So, I spent a week understanding our options for authorization. I decided to simplify our solution space and only provide attribute-based access control (ABAC). AWS, Azure, and GCP were already going in that direction.

With that understanding, I could build a simple authorization system example on PostgreSQL in a day. I could then implement our initial ABAC system in 130 lines of code. This blog shares that example, our design and implementation, and our approach’s pros and cons.

ABAC example

The following diagram describes a simple example, where the users on the left have associated “tags” (classic roles) and the resources on the right also have “tags”.

ABAC is a flexible way of implementing access control policies between users and resources. (This is a rather simplified view and we’ll come back to it later.) Since a user can hold different roles with an organization, our ABAC system allows creating different “tags” for the user.

In this example, the resource can be a VM, simple storage bucket, or source file that the user wants to access. The action is what the user is trying to do with the resource. Example actions include VM view, VM create, VM delete, etc.

Access policies are then represented with the triplet <user, action, resource>. In addition to this triplet, we introduce the notion of tags. We can associate each user or resource with one or more tags. These four concepts give us an enormous amount of flexibility with our authorization model.

Let’s translate this example into something more generic using PostgreSQL.


-- Create our data model (tables) first
-- A tag_namespace table to avoid naming collisions
CREATE TABLE tag_namespace (
    "id" serial NOT NULL PRIMARY KEY,
    "name" text NOT NULL
);

-- A item table to represent users or resources
CREATE TABLE item (
    "id" serial NOT NULL PRIMARY KEY,
    "name" text NOT NULL
);

-- A tags table to represent a user or resource’s attributes
CREATE TABLE tag (
    "id" serial NOT NULL PRIMARY KEY,
    "tag_namespace_id" serial NOT NULL,
    "name" text NOT NULL
);

-- An applied tags table that is an intermediary / pivot table
-- to establish a many to many relationship between items and tags
CREATE TABLE applied_tag (
    "tag_id" serial NOT NULL,
    "tagged_id" serial NOT NULL,
    PRIMARY KEY ("tag_id","tagged_id")
);

-- An access policy table that represents the relationship between [user, action, resource]
CREATE TABLE access_policy (
    "id" serial NOT NULL PRIMARY KEY,
    "tag_namespace_id" serial NOT NULL,
    "body" jsonb NOT NULL
);


-- Create tag namespace
INSERT INTO tag_namespace ("id", "name") VALUES (1, 'BackendApp');
-- Create users
INSERT INTO item ("id", "name") VALUES (2, 'Enes'), (3, 'Daniel');
-- Create resources
INSERT INTO item ("id", "name") VALUES (4, 'CI/CD Pipeline'), (5, 'Database'), (6, 'WebServer');

-- Create "engineers", "devops", "dev", "prod" tags to apply to resources
INSERT INTO tag ("id", "tag_namespace_id", "name") VALUES (7, 1, 'engineers'), (8, 1, 'devops'), (9, 1, 'dev'), (10, 1, 'prod');

-- Apply "engineers" tag to "Enes", "devops" tag to "Daniel"
INSERT INTO applied_tag ("tag_id", "tagged_id") VALUES (7, 2),  (8, 3);
-- Apply "dev" tag to "CI/CD Pipeline" and "prod" tag to "Database" and "WebServer"
INSERT INTO applied_tag ("tag_id", "tagged_id") VALUES (9, 4),  (10, 5),  (10, 6);

-- Allow "engineers" and "devops" to view "dev" resources 
-- and "devops" to view "prod" resources
INSERT INTO access_policy ("id", "tag_namespace_id", "body") 
VALUES (11, 1, 
  '{' '"acls" : ['
      '{'
        '"users": ["engineers", "devops"],'
        '"actions": ["view"],'
        '"resources": ["dev"]'
      '},'
      '{' 
        '"users": ["devops"],'
        '"actions": ["view"],'
        '"resources": ["prod"]'
      '}'
    ']' '}'
);

-- Check what "Enes" can view, it should be "CI/CD Pipeline" (4)
SELECT resource_applied_tags.tagged_id, users, actions, resources
FROM item AS “user”
JOIN applied_tag AS user_applied_tags ON “user”.id = user_applied_tags.tagged_id
    JOIN tag AS user_tags ON user_applied_tags.tag_id = user_tags.id
    JOIN access_policy AS acl ON user_tags.tag_namespace_id = acl.tag_namespace_id
    JOIN jsonb_to_recordset(acl.body->'acls') as items(users JSONB, actions JSONB, resources JSONB) ON TRUE
    JOIN tag AS resource_tags ON user_tags.tag_namespace_id = resource_tags.tag_namespace_id
    JOIN applied_tag AS resource_applied_tags ON resource_tags.id = resource_applied_tags.tag_id AND resources ? resource_tags."name"
WHERE “user”.id = 2 --Enes
AND users ? user_tags."name"
AND actions ?| array['view'];

-- Check what "Daniel" can view, it should be "CI/CD Pipeline" (4), "Database" (5), "WebServer" (6)
SELECT resource_applied_tags.tagged_id, users, actions, resources
FROM item AS “user”
JOIN applied_tag AS user_applied_tags ON “user”.id = user_applied_tags.tagged_id
    JOIN tag AS user_tags ON user_applied_tags.tag_id = user_tags.id
    JOIN access_policy AS acl ON user_tags.tag_namespace_id = acl.tag_namespace_id
    JOIN jsonb_to_recordset(acl.body->'acls') as items(users JSONB, actions JSONB, resources JSONB) ON TRUE
    JOIN tag AS resource_tags ON user_tags.tag_namespace_id = resource_tags.tag_namespace_id
    JOIN applied_tag AS resource_applied_tags ON resource_tags.id = resource_applied_tags.tag_id AND resources ? resource_tags."name"
WHERE “user”.id = 3 --Daniel
AND users ? user_tags."name"
AND actions ?| array['view'];
   

Design and Implementation

Our ABAC design is almost entirely based on the above example. We represent our data model in 5 PostgreSQL tables. We then use one query to check if a user has permissions to access a resource.

There are only three things to be mindful of. First, ABAC is way more flexible than what we showed it to be. In the above example, we used “tags” to assign roles to a user and used those roles to grant permissions to resources. In ABAC, you can also define those permissions over attributes. In addition to roles, attributes can include things like a user’s location, client device type, or authentication method. Tailscale has a great blog post that describes various access control policies in more detail.

Second, ABAC uses slightly different terms than the example above. In ABAC terminology, the subject is the user requesting access to a resource to perform an action. The resource is the object (such as VM, simple storage bucket, or source file) that the subject wants to access. The action is what the user is trying to do with the resource. So, the triplet <user, action, resource> above is more generally represented as <subject, action, object> in our design.

Third, for clarity, all we need for authorization is the following SQL query. This query checks if a path exists from a subject (a user identified with one or more tags) to the object (a resource identified with one or more tags).


SELECT object_applied_tags.tagged_id, object_applied_tags.tagged_table, subjects, actions, objects
FROM accounts AS subject
   JOIN applied_tag AS subject_applied_tags ON subject.id = subject_applied_tags.tagged_id
   JOIN access_tag AS subject_access_tags ON subject_applied_tags.access_tag_id = subject_access_tags.id
   JOIN access_policy AS acl ON subject_access_tags.project_id = acl.project_id
   JOIN jsonb_to_recordset(acl.body->'acls') as items(subjects JSONB, actions JSONB, objects JSONB) ON TRUE
   JOIN access_tag AS object_access_tags ON subject_access_tags.project_id = object_access_tags.project_id
   JOIN applied_tag AS object_applied_tags ON object_access_tags.id = object_applied_tags.access_tag_id AND objects ? object_access_tags."name"
WHERE subject.id = :subject_id
   AND actions ?| array[:actions]
   AND subjects ? subject_access_tags."name"
   

Pros & Cons

As with all software, our design comes with tradeoffs. The nice thing about this ABAC design is that it meets our original requirements.

  • Correct - The design has an existence proof. When you run the query, if it returns a tuple, the subject can access the object. If it doesn’t, the subject isn’t authorized. Further, you can see all access paths from the subject to the object by looking at the tuples the above query returns.
  • Flexible - We can express all attribute-based access control policies with this one query, for user-defined tags.
  • Simple - We get an ABAC implementation in 10 lines of SQL. In fact, the entire file that implements our authorization policy is 130 lines of code.

When building our design, we also looked at Google’s influential paper on authorization systems, Zanzibar. Compared to Zanzibar, our design has the following drawbacks.

  • Scale - We run a SQL query for each authorization action. This solution will scale to the extent that a single PostgreSQL database can handle reads and writes. When Ubicloud reaches that scale, we’ll need to optimize our implementation or switch to a distributed solution.
  • Global availability - Zanzibar uses a globally distributed Spanner cluster; and builds on it with additional protocols, serving clusters, and offline data processing systems. We use PostgreSQL. Without additional modifications, this gives us high availability in one region.

Conclusion

ABAC enables a flexible way to authorize users. With it, organizations and users can express complex access policies between users and resources. Further, by using attributes (tags), you can extend these policies and create dynamic relationships. As importantly, ABAC is simple enough to understand without much effort. These properties enable us to create a powerful authorization system, where the data model lives in a few database tables and the core logic in one SQL query.

We also expect our ABAC design to evolve over time. Our implementation is open on GitHub; and if you have any questions or feedback, we’d love to hear from you. Please start a conversation on GitHub discussions or reach out to us at [email protected].