This content originally appeared on DEV Community and was authored by Ege Aytın
Welcome to our latest article on Google Zanzibar! For those who haven't heard of it yet, Zanzibar is the authorization system used by Google to handle authorization for hundreds of its services and products, including YouTube, Drive, Calendar, Cloud, and Maps. With the ability to handle complex authorization policies at a global scale, Zanzibar processes billions of access control lists and queries per second.
In this article, we will take a closer look at Zanzibar by implementing some of its fundamentals. Specifically, we will begin by exploring the Zanzibar data model and ReBAC (Relationship-Based Access Control). Next, we will create relational tuples, which are analogous to ACL (Access Control List) style authorization data in Zanzibar. We will then proceed to Zanzibar APIs and examine how Zanzibar handles modeling.
Please keep in mind that code blocks in this article are for demonstration purposes only and are not intended for production usage.
If you're looking for a fully-fledged implementation or a centralized authorization solution that uses the Zanzibar permission model, we've got you covered! We're currently building an open-source authorization service inspired by Google Zanzibar, which you can check out on our github repo.
To begin, it is important to understand the data model of Zanzibar as it differs significantly from legacy authorization structures.
Zanzibar Data Model
Despite popular access control models - such as Role Based Access Control (RBAC) and Attribute Based Access Control (ABAC) - Zanzibar relies on relationship-based access control, which takes into account the relationships between resources and entities rather than solely relying on roles or attributes.
Now, you might be wondering, what are these relationships and how does Google Zanzibar utilize them to create complex authorization policies and enforce them efficiently?
What is relational based access control (ReBAC) exactly ?
Relationship-Based Access Control (ReBAC) is an access control model that takes into account relationships between subjects and objects when determining access permissions.
ReBAC extends traditional access control models by considering social or organizational relationships, such as hierarchical or group-based relationships, in addition to the standard user-to-resource relationships. For example, ReBAC might allow a manager to access files of subordinates in their team, or permit members of a certain project team to access specific resources associated with that project.
By incorporating relationship-based factors, ReBAC can provide a more nuanced and flexible access control mechanism that better reflects real-world social structures and can help organizations to enforce security policies more effectively.
To give you a simple example, let's take a look at an endpoint responsible for updating a given document.
// Define a Document model
const Document = sequelize.define('Document', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
title: {
type: DataTypes.STRING,
allowNull: false
},
content: {
type: DataTypes.TEXT,
allowNull: false
}
});
// Update a document by ID
app.put('/documents/:id', async (req, res) => {
const id = parseInt(req.params.id);
const document = await Document.findByPk(id);
if (!document) {
return res.status(404).send('Document not found');
}
const { title, content } = req.body;
await document.update({ title, content });
res.send(document);
});
In this example, we're using Sequelize ORM to update a document in a PostgreSQL database on an express.js endpoint. As you might have noticed, we're not checking any kind of access rights at the moment. That means anybody can edit any document, which isn't ideal, right?
We can easily fix this by adding an authorization check. Let’s say that only the user who owns the document can edit it.
// Update a document by ID
app.put('/documents/:id', async (req, res) => {
const id = parseInt(req.params.id);
const document = await Document.findByPk(id);
if (!document) {
return res.status(404).send('Document not found');
}
// Check if the user is authorized to edit the document
if (document.owner !== req.user.id) {
return res.status(401).send('Unauthorized');
}
const { title, content } = req.body;
await document.update({ title, content });
res.send(document);
});
With that we have added a relational access control in its simplest form: ownership.
Not surprisingly in real world applications that probably won’t be the only access control check you should make.
If we take a step back and look at this example from an organizational perspective, we can see that there are probably more access rules to consider beyond just ownership. For instance, organizational roles such as admin or manager roles may need the ability to edit any document that belongs to their organization.
In addition, resources like documents can have parent-child relationships with various user groups and teams, each with different levels of entitlements.
Things can get pretty complicated when we combine a bunch of access control rules and make the authorization structure more fine grained. But no worries, that's where Zanzibar comes in to save the day and make our lives a whole lot easier!
As we previously mentioned, Zanzibar leverages relationships between users and resources to provide a powerful and adaptable approach to access control. By taking into account these relationships, Zanzibar is able to provide a fine-grained level of control and flexibility that can be customized to fit a wide range of use cases.
Let's look at how Zanzibar leverages relations in more depth.
Zanzibar Relation Tuples
In Zanzibar, access control policies are expressed as relations, which are essentially tables that define the relationship between principals, resources, and permissions. Each relation is defined by a schema that specifies the names and types of its columns.
The basic format for a relation tuple is:
<object>#<relation>@<user>
In this format, object represents the object or resource being accessed, relation represents the relationship between the user and the object, and user represents the user or identity that is requesting access.
For example, suppose we have a Zanzibar system that is managing access to a set of files. Here are some example relation tuples that might be used to represent access control policies:
file123#owner@alice
file123#viewer@bob
document124#maintainers@team#members
In this example, we have three relation tuples:
-
file123#owner@alice
: This tuple represents the fact that Alice is the owner of file123. -
file123#viewer@bob
: This tuple represents the fact that Bob has permission to view file123. -
document124#maintainer@team2#member
: This represents members of team 2 who are maintainers of document124.
As we understand how relation tuples are structured, let's quickly create tuples in Postgresql Database
Sample Implementation in PostgreSQL.
It’s important to note that Zanzibar built on Google Spanner DB, the authors explained that they organized the database schema using a table per object namespace approach.
However, we’re exploring the data model and not much care about the scalability and performance right now. So we will store the relation tuples in a single PostgreSQL database, deviating from the original Zanzibar paper's approach.
Here is our tuple table to represent relation tuples.
CREATE TABLE tuples (
object_namespace text NOT NULL,
object_id text NOT NULL,
object_relation text NOT NULL,
subject_namespace text,
subject_id text NOT NULL,
subject_relation text
sets
);
Lets bump sample relation tuples into that:
INSERT INTO tuples (object_namespace, object_id, object_relation, subject_namespace, subject_id, subject_relation) VALUES
('doc', '323', 'owner', 'user', '1', NULL),
('doc', '152', 'parent', 'org', '1', '...'),
('doc', '323', 'owner', 'org', '1', 'member'),
('org', '1', 'member', 'user', '3', NULL),
('org', '1', 'admin', 'user', '4', NULL),
('org', '2', 'member', 'user', '2', NULL);
Respectively this will create following relation tuples:
- user:1 is owner of document:323
- doc:152 belongs to organization:1
- members of org:1 are owners of doc:323
- user:4 is admin in org:1
- user:2 is member in org:2
Zanzibar APIs
The Zanzibar API consists of five methods; read, write, watch, check, and expand. Those methods can be used to manage access control policies and check permissions for resources.
The read, write, and watch methods are used for interacting directly with the authorization data (relation tuples), while the check and expand methods are focused specifically on authorization.
For this article, we will be implementing the check API, which is essential for enforcing authorization when using Zanzibar. We will also be using a sample dataset to test our authorization logic.
Check API
The check API enables applications to verify whether a specific subject (such as a user, group, or team members) has the necessary permissions to perform a particular action on a resource.
In our case, it would verify whether a logged-in user is authorized to edit document X.
How does the check API evaluate access decisions ?
Zanzibar stores information about resources, users, and relationships in a centralized database.
And when a user requests access to a resource, Zanzibar uses that information stored in its database to evaluate the request and determine whether access should be granted.
By doing so, Zanzibar ensures that access control decisions can be made quickly and efficiently, even for large-scale distributed systems.
Check Request
In our example, we specify that users can edit docs only if the user is an owner of the document.
In this context we can call our check API as follows, and expect to get a boolean value indicating whether the given subject (user:X) has the specified relation (owner, editor, etc.) to the given object (document:Y)
check(subject_id, object_relation, object_namespace, object_id)
We’ll use this function in our edit endpoint. But let's first build the logic behind it as a store procedure where we defined our tuples table.
CREATE OR REPLACE FUNCTION check (p_subject_id text, p_object_relation text, p_object_namespace text, p_object_id text)
RETURNS boolean
LANGUAGE plpgsql
AS $$
DECLARE
var_r record;
var_b boolean;
BEGIN
FOR var_r IN (
SELECT
object_namespace,
object_id,
object_relation,
subject_namespace,
subject_id,
subject_relation
FROM
tuples
WHERE
object_id = p_object_id
AND object_namespace = p_object_namespace
AND object_relation = p_object_relation
ORDER BY
subject_relation NULLS FIRST)
LOOP
IF var_r.subject_id = p_subject_id THEN
RETURN TRUE;
END IF;
IF var_r.subject_namespace IS NOT NULL AND var_r.subject_relation IS NOT NULL THEN
EXECUTE 'SELECT check($1, $2, $3, $4)'
USING p_subject_id, var_r.subject_relation, var_r.subject_namespace, var_r.subject_id INTO var_b;
IF var_b = TRUE THEN
RETURN TRUE;
END IF;
END IF;
END LOOP;
RETURN FALSE;
END;
$$;
Voila, we implemented Zanzibar’s check API. Here's a more detailed breakdown of what this store procedure exactly does.
Declaring Variables
I declared a record variable "var_r" and a boolean variable “var_b”.
DECLARE
var_r record;
var_b boolean;
var_r
The record variable "var_r" is used to hold the current row of the result set returned by the SQL query in the FOR loop.
The loop iterates over the rows returned by the query, one at a time, and the values of the columns in the current row are assigned to the fields of the record variable.
This allows the function to access the values of the columns in the current row using field references instead of column names.
var_b
The boolean variable "var_b" is used to store the result of the recursive call to the same function. When the function makes a recursive call to itself, it passes the new parameters and expects a boolean result.
The boolean result indicates whether the relation exists or not. The result of the recursive call is stored in the "var_b" variable and is checked later in the loop.
If the result is TRUE, the loop terminates early and the function returns TRUE. If none of the recursive calls return TRUE, the loop completes and the function returns FALSE.
Retrieve all related relation tuples
FOR var_r IN (
SELECT
object_namespace,
object_id,
object_relation,
subject_namespace,
subject_id,
subject_relation
FROM
tuples
WHERE
object_id = p_object_id
AND object_namespace = p_object_namespace
AND object_relation = p_object_relation
ORDER BY
subject_relation NULLS FIRST)
This part of the code is responsible for retrieving all rows from the tuples table where the object_namespace, object_id, and object_relation columns match the corresponding parameters passed to the function. The ORDER BY clause sorts the rows by the subject_relation column in ascending order, with null values coming first.
The loop continues until all rows in the result set have been processed, or until a RETURN statement is encountered, which causes the function to terminate early and return a value.
Recursive Search to Conclude Access Check
LOOP
IF var_r.subject_id = p_subject_id THEN
RETURN TRUE;
END IF;
IF var_r.subject_namespace IS NOT NULL AND var_r.subject_relation IS NOT NULL THEN
EXECUTE 'SELECT check($1, $2, $3, $4)'
USING p_subject_id, var_r.subject_relation, var_r.subject_namespace, var_r.subject_id INTO var_b;
IF var_b = TRUE THEN
RETURN TRUE;
END IF;
END IF;
END LOOP;
RETURN FALSE;
The function then starts a loop that retrieves rows from the "tuples" table based on the input parameters. The loop checks if the “subject_id" column of the retrieved row matches the input “p_subject_id" parameter. If it does, the function returns TRUE immediately as the relation exists.
If the "subject_id" column of the retrieved row does not match the input “p_subject_id" parameter, the function checks if the row contains a "subject_relation" and "subject_namespace". If it does, the function makes a recursive call to itself with the "subject_relation", "subject_namespace", and “subject_id" columns of the retrieved row as input parameters.
The result of the recursive call is stored in the "var_b" variable. If the value of "var_b" is TRUE, the function returns TRUE immediately as the relation exists.
If none of the retrieved rows have a matching “subject_id" column or a recursive call returns TRUE, the function returns FALSE as the relation does not exist.
That's the end of the breakdown, now let’s implement this in our ‘/documents/:id' endpoint using sequelize. query to call our check function.
// Update a document by ID
app.put('/documents/:id', async (req, res) => {
const id = parseInt(req.params.id);
const document = await Document.findByPk(id);
if (!document) {
return res.status(404).send('Document not found');
}
// Check if the user is authorized to edit the document
const result = await sequelize.query('CALL check(:userId, :objectRelation, :objectNamespace, :objectId)', {
replacements: {
userId: req.user.id,
objectRelation: 'owner',
objectNamespace: 'doc',
objectId: id,
}
})
if (result) {
return res.status(401).send('Unauthorized');
};
const {
title,
content
} = req.body;
await document.update({
title,
content
});
res.send(document)
});
So in this call, we’re checking whether user:X has an owner relation with doc:Y. If we look at the relation tuples - let’s remember them.
-
user:1
is owner ofdocument:323
-
doc:152
belongs toorganization:1
- members of
org:2
are owners ofdoc:323
-
user:4
is admin inorg:1
-
user:2
is member inorg:2
-
user:1
anduser:2
can editdocument:323
becauseuser:1
has direct ownership relation withdocument:323
anduser:2
is member oforg:2
, which we stated members of theorg:2
are owners ofdoc:323
.
What if we need to extend the requirements for editing a document. Such as, we can say that admins of an organization, which document:X belong, can edit document:X. In that case we need to make additional calls.
For example, we can check whether user:1
admin in organization:1
? And whether document:323
belongs to organization:1
? Respectively,
check(‘1’, ‘admin’, ‘organization’, ‘2’)
check(‘323’, ’parent’, ‘organization’, ‘2’)
Of course, we don't want to make a separate call for every authorization rule. Ideally, we want to be able to manage everything with a single call. To achieve this, we need to create an authorization model (or policy) and feed it into the Zanzibar engine.
This allows Zanzibar to search for the given action (such as edit, push, delete, etc.) and the relevant relationships. Then, it can check each relationship to see whether a given subject (i.e., a user or user set) is authorized to perform the action.
Modeling in Zanzibar
Zanzibar handles modeling with namespace configurations. A namespace configuration in Zanzibar is a set of rules that define how resources within a specific namespace can be accessed. Such as a collection of databases or a set of virtual machines.
A possible document modeling could be represented as
name: "doc"
relation {
name: "owner"
}
relation {
name: "editor"
userset_rewrite {
union {
child { computed_userset { relation: "owner" } }
child { tuple_to_userset {
tupleset { relation: "parent" }
computed_userset {
relation: "admin"
}
}}
}
}
}
relation {
name: "parent"
}
This namespace configuration has three relations: "owner", "editor", and "parent".
Users who are members of the "owner" relation have full control over the resources in the "doc" namespace.
The "parent" relation specifies that the organization "doc" belongs to.
The "editor" relation specifies that users who are members of the "owner" relation or members of the "admin" relation in the organization specified by the "parent" relation can edit resources in the "doc" namespace.
Modeling in Permify
Although I appreciate the approach behind namespace configurations, I must admit that it can be inconvenient to model complex cases. You may need to create multiple namespaces and configure them separately.
At Permify, we have developed an authorization language that aims to simplify the modeling process for those complex cases. Our approach allows for more flexibility and customization while still ensuring that access control decisions can be made quickly and efficiently.
The language allows to define arbitrary relations between users and objects, such as owner, editor in our example, further you can also define roles like user types such as admin, manager, member, etc.
Here is a quick demonstration of how modeling is structured in Permify.
Relationship-based policies and centralized authorization management combination solves so much for the the teams to get market fast as well as for the organizations, especially those with large datasets and multiple segmentation of hierarchies or groups/teams.
Conclusion
During our journey to build a Zanzibar inspired solution, we have discovered that ReBAC with centralized management duo is the desired approach for many use cases and most of the teams we discussed want to benefit from it. Yet, implementing such a system is not an easy decision.
Adopting a Zanzibar-like solution requires designing your data model around it, which means significant refactoring and changes in approach if you are currently using legacy authorization solutions based on roles or attributes associated with users or user sets.
At that point seeing new Zanzibar solutions to ease those frictions makes us happy and help us to see different angles around Zanzibar.
If you are interested in learning more about Zanzibar or believe that it may be beneficial to your organization, please don't hesitate to join our community on discord. We welcome the opportunity to chat and share our insights.
This content originally appeared on DEV Community and was authored by Ege Aytın
Ege Aytın | Sciencx (2023-04-06T16:26:30+00:00) Exploring Google Zanzibar: A Demonstration of Its Basics. Retrieved from https://www.scien.cx/2023/04/06/exploring-google-zanzibar-a-demonstration-of-its-basics/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.