Authorization is a topic that remains evergreen for reinvention. Cryptographic techniques change, scales of systems change, and debates between centralization vs decentralization flow back and forth like the tide drifting in and out of a beach. Biscuits are a modern approach, one that combines novel techniques with decentralization and attenuation.
Biscuits are simple. You want to access the data, you give the guard a treat. AI Generated via DALL-E 3 with prompt: Businessman handing a biscuit to a towering security guard. The security guard is standing in front of a server room.
A biscuit, as seen at https://biscuitsec.org/, is a cryptographic token which describes the authorizations of the token bearer. What that means is: the token describes what the token bearer is allowed to do. This can include the identity, but biscuits are issued post authentication and are not a form of authentication. Following this logic, having a biscuit token does not mean you are the initial entity the token was issued to, it states that you are taking actions allowed by the token, on behalf of that initial entity. This is normal for bearer tokens but biscuits provide great tools for limiting what the bearer tokens can be used for.
What makes biscuits different from a session-id or an opaque OAuth token is that the authorization information is embedded into the token, in a prolog variant called “datalog.” This means that no central authorization server needs to be queried, saving on network traffic. Because datalog is a well defined language this also means that evaluating authorization policies is straightforward, much more so than whatever a JWT may make up, or even how macaroon’s express their caveats.
Biscuits also support offline attenuation, which is a very useful property. Attenuation is the property where a token becomes further restricted by the current bearer of the token. Imagine starting with a token which can do anything. You pass this token to a service which returns information about builds. You don’t want the token to be usable for anything else, so you want to “attenuate” the token, add a restriction that it can only be used to view information about builds. In fact it can only be used to view information about the build with ID 12345. OAuth allows for restricting what a token can be used for via scopes, but those restrictions are still limited to the API level. Scopes won’t restrict arguments to an API call. Biscuits allow you to restrict the arguments that go to an API call. The “offline” portion means that no authorization server needs to be asked to issue a new token. The holder of the token can attenuate it themselves. A service passing a token off to other services can attenuate the token as it hands it off, it doesn’t even need to be the original token bearer.
Comparing Biscuits to OAuth
I’ll start off this section by noting that I really don’t like the OIDC & OAuth ecosystem. While OAuth is an open standard it’s one which I find confusingly specified, with a multitude of documents that cover a multitude of flows, using various confusingly named actors. The more complex a system is, the easier it is to unknowingly do the wrong thing or be taken advantage of by threat actors who notice you doing the wrong thing.
The other thing that annoys me with scopes in OAuth is how limited they are in what they can express. Applications can request scopes (https://oauth.net/2/scope/) which allow actions, typically at the level of everything a user can access. If you want to allow write access in GitHub to a single repo, OAuth scopes are not the correct tool and additional authorization logic needs to be added in. Since scopes require different parts of an enterprise to collaborate to define the scope, it’s meaning, and what apps will use it, apps are incentivized to just use an existing scope for their features. It begins to resemble Linux capabilities, especially CAP_SYS_ADMIN
(https://lwn.net/Articles/486306/), in allowing for a scope to do much more than the user intended.
OIDC, in my humble opinion and ranting a little more, is an example that good intentions lead one down the road to hell. Reading the standard (https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) one comes across gems like the title of 3.1: “Authentication using the Authorization Code Flow”. Yes, authN is done in terms of authZ primitives - and the authN information is contained within claims which were added to the Oauth standard. This leads to neat situations (https://gist.github.com/nicolasdao/5f428529426d2183e2f1358fb46ba642#why-pseudo-authentication-is-ok-and-not-ok) where it’s possible to mix up authZ tokens for authN tokens, which is made possible by misbehaving apps stealing authZ tokens and using then to authN elsewhere. Ugh 🤢
Biscuits are used as an authZ solution and don’t attempt to mix authN concepts into their design. It is true that most biscuits would contain information about the authorized user, but this is because any sensible auth system starts with authN to generate an authZ token.
Biscuits change the backend and scopes first approach to more of a GraphQL style system. GraphQL arose as a tool to empower front end developers to make semi-arbitrary calls to the backend without waiting for the backend team to create different REST APIs. Biscuits use Datalog to allow clients to specify policies about what a token can be used for. Going back to the prior example around write access to GitHub repos: A biscuit can add a restriction that only a specific set of GitHub repos (or even just one) can be written to. Biscuits can also add those restrictions at any point without needing to talk to a central server - no more need for central servers, limits on who can issue new tokens, the particular restrictions you want not being supported, that’s all (more or less) gone. Like Rodney Norman says “Hey, you know you can just do stuff. Like you don’t need anybody’s permission or anything.”
It is worthwhile to point out that OIDC is an accepted means of authentication across much of the internet. It would be an entirely reasonable flow for a user to authenticate via OIDC and then trade that OIDC token for a biscuit token.
Drawbacks
The above glowing endorsements shouldn’t be taken as a statement that I think Biscuits are perfect nor that every issue nor that every issue with them has been resolved.
Non-standard Datalog Usage
Biscuits were my first exposure to datalog and one thing I noticed when trying to learn more about datalog itself is that the syntax (dialect?) biscuits use is not the standard syntax. Resources like:
- https://www.learndatalogtoday.org/
- https://blogit.michelin.io/an-introduction-to-datalog/
- https://blog.pzakrzewski.com/find-legal-moves-in-brass-birmingham-with-logic-programming
were able to discuss datalog concepts and ideas, but not in a way that would readily translate over to the syntax used in biscuits. It’s not a big deal to translate, and frankly the biscuit syntax is easier on the eyes, but it does require a little extra effort to apply datalog theory to practice. Throughout the rest of this article I try to refer to the specific syntax Biscuit’s use for datalog as “Biscuit datalog”.
One resource that was useful was: https://www.clever-cloud.com/blog/engineering/2021/04/15/biscuit-tutorial/. Since the author is the same Geoffroy that authored the biscuits standard, this is hardly a surprise.
Could use more examples
When playing with biscuits myself, I tried to make checks as simple as possible in favor of generating new facts up until I had to do a check. This was to make sure that I understood what I was testing against. Even after reading the grammar and specifications the only operator I was able to find which seemed useful in building out RBAC was the .contains
operator.
Later on in this post I provide my own example of building out an RBAC system.
Facts are Not Typed
Facts are not typed in biscuit datalog and this feels like an easy way to make mistakes. If you have a fact like authz($user,$resource,$action);
you can represent everything with numerical ids, for example: authz(1,2,3);
. This makes it very easy to mix up values however.
Biscuit datalog supports namespacing of the values in facts, which as they note is not a security feature. It does however make it possible to do domain separation of values so that mixing them up does not lead to failures in security and makes debugging much easier. When namespaced the same fact would look like authz("user:1", "resource:2", "action:3");
which is much easier to read and debug over.
Reading AuthZ Code Pierces Abstraction Layers
In order to know what facts an authorizer will concern itself with, you need to read the code. This is really unfortunate since it pierces an abstraction layer of needing to read more than an API to understand how to make use of (and attenuate) biscuits.
Shopping cart microservice flow from biscuitsec.org examples
As an example: https://www.biscuitsec.org/docs/guides/microservices/ shows an example of an API talking to a payment and email service.
The problem here is that the cart API needs to know the full set of required rights for the payment service and email service. It also needs to know what the payment service requires in order to attenuate the token correctly to just have the required rights for the payment and email services.
For proper attenuation or building a biscuit a chain of services will use, this means that the authorizer code for each service which accepts biscuits needs to be read. Once again this involves a lot of piercing abstraction layers and can lead to easy breakage or excessive capabilities being granted.
It’s hard for me to figure out how to improve on this each service without publishing the authorizer facts and rules. That in turn would need agreed upon nomenclature, like “time($time)” means the datetime in RFC3339 format or something similar. If each service published that along with the services that service would call, then it would at least be possible to chain together and test out possible biscuits. Limiting the set of capabilities allowed to the least possible would still be difficult and likely involve parsing the authentication rules.
Unclear mental model for building out interactions
One place where I struggled, in part due to examples, was figuring out how to build out interactions. I ended up with an algorithm along the lines of:
- Draw out the interactions to be modeled as a graph of values.
- This should be done in ReBAC / spice-db style:
- Resources have directed edges pointing to actions.
- Resources may be grouped under logical categories
- Entities allowed to perform actions are an edge connected to the action.
- If a path from an entity to an action can be found, that action is allowed.
- Following this facts and rule can be calculated from the graph.
- This can be done by using rules to list out a path as the start and every node connected to the path.
- Relationships can be specified as the entities being operated on and the edge (relationship) connecting them.
- Then a match needs to be found between the relationship and the path.
- Attributes for authZ should have their own individual facts.
I explore this more in my example below, where I build out an authZ for a git forge.
Example
To develop my understanding of biscuits, I made a toy project involving authorization for a git forge. To make things more interesting, I challenged myself to include more than just the biscuit logic, I also use sqlite to represent how everything would be stored and retrieved.
A caveat to begin: Not everything is done in the most optimal manner, but it does work in the end. Group information is retrieved by the authorizer rather than at initial token issuance. Multiple SQL queries are used in favor of showing what each individual rule is doing.
The Code
Bottom Line Up Front, the code is located at: https://github.com/er4hn/biscuit-forge . The code uses go modules and nix flakes and can be ran via go run .
.
Git Forge AuthZ Functional Spec
The functional spec is intended to layout how authorization works for this example git forge. It makes uses of users, groups of users, repos, and groups of repos.
Flowchart which shows the how the authz system is laid out. Solid lines show relationships where the parent contains the child, either in membership or capabilities. Dotted lines show how roles can be assigned. Solid red lines map actions to roles. The mermaid that generated this is located at “Git Forge Authz Mermaid Diagram”.
This divides the components of the forge authZ system into the following:
- Resources - These are entities and objects and consists of:
- Users: These are entities which authenticate and use the forge.
- User Groups: Users can be grouped into a user group and authZ permissions can be assigned to the entire group.
- User groups can also contain other user groups. In this case the parent group will have all the permissions of the child group.
- Repos: Repos are code repositories. Each repo has a set of “repo actions” associated with it.
- The set of actions that can be done to a repo are: “Read”, “Write”, and “Membership”.
- The mapping of actions to users is determined by the “repo roles”.
- Repo Groups: Repos can be grouped and have a set of authz apply to a repo.
- Repo groups cannot have child repo groups.
- The set of actions that can be done to a repogroup are: “Read”, and “Write”
- Membership is deliberately omitted as an action for repogroups.
- The mapping of actions to users is determined by the “repo group roles”.
- Repo Actions - Actions determine what operations can be done to a repo and are grouped as:
- Read: Read from the repo
- Write: Write to the repo
- Membership: Handle adding and removing members from the repo.
- Repo Roles - Roles are where users, and user groups are mapped to roles for a particular repo. Roles determine what actions are allowed and the roles are:
- Owner: Owners can perform membership, write, and read actions.
- Writer: Writers can perform write, and read actions.
- Reader: Readers can perform read actions.
- Repo Group Roles - Repo Group roles map users and user groups to roles for a particular repogroup. Those roles are:
- Writer: Same as repo roles
- Reader: Same as repo roles.
Git Forge AuthZ Design Doc
The design doc lays out how the functional spec for this authz service will be implemented. The high level design involves storing the state in an sqlite database and materializing (pulling the values into the datalog logic) the state into the biscuit authorizer along with the logic.
Sqlite
The basic sqlite schema is as follows:
--
-- File generated with SQLiteStudio v3.4.4
--
-- Text encoding used: System
--
PRAGMA foreign_keys = off;
BEGIN TRANSACTION;
-- Table: repo_roles_enum
CREATE TABLE IF NOT EXISTS repo_roles_enum (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
rolename TEXT UNIQUE
NOT NULL
);
INSERT INTO repo_roles_enum (
id,
rolename
)
VALUES (
1,
'reader'
);
INSERT INTO repo_roles_enum (
id,
rolename
)
VALUES (
2,
'writer'
);
INSERT INTO repo_roles_enum (
id,
rolename
)
VALUES (
3,
'owner'
);
-- Table: Repo_Roles_membership_UserGroups
CREATE TABLE IF NOT EXISTS Repo_Roles_membership_UserGroups (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
repo_id INTEGER REFERENCES Repos (id) ON DELETE CASCADE
NOT NULL,
usergroup_id INTEGER NOT NULL
REFERENCES UserGroups (id) ON DELETE CASCADE,
repo_role INTEGER REFERENCES repo_roles_enum (id)
NOT NULL
);
-- Table: Repo_Roles_membership_Users
CREATE TABLE IF NOT EXISTS Repo_Roles_membership_Users (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
repo_id INTEGER REFERENCES Repos (id) ON DELETE CASCADE
NOT NULL,
user_id INTEGER NOT NULL
REFERENCES Users (id) ON DELETE CASCADE,
repo_role INTEGER REFERENCES repo_roles_enum (id)
NOT NULL
);
-- Table: RepoGroup_membership
CREATE TABLE IF NOT EXISTS RepoGroup_membership (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
repogroup_id INTEGER REFERENCES RepoGroups (id) ON DELETE CASCADE
NOT NULL,
repo_id INTEGER REFERENCES Repos (id) ON DELETE CASCADE
NOT NULL
);
-- Table: repogroup_roles_enum
CREATE TABLE IF NOT EXISTS repogroup_roles_enum (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
rolename TEXT UNIQUE
NOT NULL
);
INSERT INTO repogroup_roles_enum (
id,
rolename
)
VALUES (
1,
'reader'
);
INSERT INTO repogroup_roles_enum (
id,
rolename
)
VALUES (
2,
'writer'
);
-- Table: RepoGroup_Roles_membership_Usergroup
CREATE TABLE IF NOT EXISTS RepoGroup_Roles_membership_Usergroup (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
repogroup_id INTEGER REFERENCES RepoGroups (id) ON DELETE CASCADE
NOT NULL,
usergroup_id INTEGER NOT NULL
REFERENCES UserGroups (id) ON DELETE CASCADE,
repogroup_role INTEGER REFERENCES repogroup_roles_enum (id)
NOT NULL
);
-- Table: RepoGroup_Roles_membership_Users
CREATE TABLE IF NOT EXISTS RepoGroup_Roles_membership_Users (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
repogroup_id INTEGER REFERENCES RepoGroups (id) ON DELETE CASCADE
NOT NULL,
user_id INTEGER NOT NULL
REFERENCES Users (id) ON DELETE CASCADE,
repogroup_role INTEGER REFERENCES repogroup_roles_enum (id)
NOT NULL
);
-- Table: RepoGroups
CREATE TABLE IF NOT EXISTS RepoGroups (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
groupname TEXT UNIQUE
NOT NULL
);
-- Table: Repos
CREATE TABLE IF NOT EXISTS Repos (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
reponame TEXT UNIQUE
NOT NULL
);
-- Table: UserGroup_membership_usergroups
CREATE TABLE IF NOT EXISTS UserGroup_membership_usergroups (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
usergroup_id INTEGER REFERENCES UserGroups (id) ON DELETE CASCADE
NOT NULL,
child_usergroup_id INTEGER REFERENCES UserGroups (id) ON DELETE CASCADE
NOT NULL
);
-- Table: UserGroup_membership_users
CREATE TABLE IF NOT EXISTS UserGroup_membership_users (
id INTEGER PRIMARY KEY
UNIQUE
NOT NULL,
usergroup_id INTEGER REFERENCES UserGroups (id) ON DELETE CASCADE
NOT NULL,
user_id INTEGER REFERENCES Users (id) ON DELETE CASCADE
NOT NULL
);
-- Table: UserGroups
CREATE TABLE IF NOT EXISTS UserGroups (
id INTEGER PRIMARY KEY
NOT NULL
UNIQUE,
groupname TEXT UNIQUE
NOT NULL
);
-- Table: Users
CREATE TABLE IF NOT EXISTS Users (
id INTEGER PRIMARY KEY
NOT NULL
UNIQUE,
username TEXT UNIQUE
NOT NULL
);
COMMIT TRANSACTION;
PRAGMA foreign_keys = on;
sqlite schema for forge authz
Most of the schema is straightforward and as expected. One thing to note is that what roles are allowed to do is not listed in the schema. That is because the actions roles can do is part of the logic. The database is used to store state.
To make this usable, some example users, groups, repos, and repogroups need to be added and mapped to various roles. As my example of this:
Flowchart showing various users, repos, usergroups, and repogroups, which are mapped to roles. The mermaid used to generate this can be found at “Example Setup Mermaid Diagram”.
The sqlite to insert this example setup looks like:
--
-- File generated with SQLiteStudio v3.4.4
--
-- Text encoding used: System
--
PRAGMA foreign_keys = off;
BEGIN TRANSACTION;
INSERT INTO Repo_Roles_membership_Users (
id,
repo_id,
user_id,
repo_role
)
VALUES (
1,
3,
1,
3
);
INSERT INTO Repo_Roles_membership_Users (
id,
repo_id,
user_id,
repo_role
)
VALUES (
2,
3,
2,
1
);
INSERT INTO RepoGroup_membership (
id,
repogroup_id,
repo_id
)
VALUES (
1,
1,
2
);
INSERT INTO RepoGroup_membership (
id,
repogroup_id,
repo_id
)
VALUES (
2,
1,
3
);
INSERT INTO RepoGroup_Roles_membership_Usergroup (
id,
repogroup_id,
usergroup_id,
repogroup_role
)
VALUES (
1,
1,
1,
2
);
INSERT INTO RepoGroups (
id,
groupname
)
VALUES (
1,
'Foo'
);
INSERT INTO Repos (
id,
reponame
)
VALUES (
1,
'Alpha'
);
INSERT INTO Repos (
id,
reponame
)
VALUES (
2,
'Bravo'
);
INSERT INTO Repos (
id,
reponame
)
VALUES (
3,
'Charlie'
);
INSERT INTO UserGroup_membership_usergroups (
id,
usergroup_id,
child_usergroup_id
)
VALUES (
1,
1,
2
);
INSERT INTO UserGroup_membership_usergroups (
id,
usergroup_id,
child_usergroup_id
)
VALUES (
2,
2,
3
);
INSERT INTO UserGroup_membership_users (
id,
usergroup_id,
user_id
)
VALUES (
1,
1,
4
);
INSERT INTO UserGroup_membership_users (
id,
usergroup_id,
user_id
)
VALUES (
2,
1,
3
);
INSERT INTO UserGroup_membership_users (
id,
usergroup_id,
user_id
)
VALUES (
3,
3,
5
);
INSERT INTO UserGroups (
id,
groupname
)
VALUES (
1,
'FooOps'
);
INSERT INTO UserGroups (
id,
groupname
)
VALUES (
2,
'BarOps'
);
INSERT INTO UserGroups (
id,
groupname
)
VALUES (
3,
'BazOps'
);
INSERT INTO Users (
id,
username
)
VALUES (
1,
'Olivia'
);
INSERT INTO Users (
id,
username
)
VALUES (
2,
'Noah'
);
INSERT INTO Users (
id,
username
)
VALUES (
3,
'Emma'
);
INSERT INTO Users (
id,
username
)
VALUES (
4,
'Liam'
);
INSERT INTO Users (
id,
username
)
VALUES (
5,
'Tony'
);
COMMIT TRANSACTION;
PRAGMA foreign_keys = on;
sqlite showing configuring an example setup.
These values are fetched for use in the “Biscuit Logic” section.
Biscuit Logic
Datalog (and reading more is highly recommended) consists of “facts”, and “rules”. A fact is any sort of statement about something known and looks like fact_name($various, $variables)
where “fact_name” is the name of the fact. $various
, and $variables
define values, which can be numbers, strings, or dates. A “rule” is a function which derives new facts from existing ones. A rule might look like rule_name($various) <- fact_name($various, $variables), $variables.contains("baz");
where rule_name is the name of the rule, as well as the facts output by the rule. The part after the <-
arrow are the rules which are followed to derive the new facts. Comma’s separate AND statements in the rules.
Throughout this section I will describe biscuits using their variable syntax and then show an example. For example foo($bar, $baz);
is a biscuit fact foo
with two variables in it $bar
, and $baz
. An example of this would be foo("bar:value1", "baz:value2")
where both $bar
and $baz
have been namespaced to avoid mixing them up. Namespacing is just a string added to the front of a variable, but it proved very useful here since most of the sqlite values are numerical ids.
User Token
The biscuit token provided to be authorized is very simple. It is just the user id: user($userid)
, for example user("userid:1")
. No information about usergroups or other details are provided since those are intended to be pulled out of the sqlite database at evaluation time. Pulling these values out is more costly during each eval, but in the event memberships change after the token is issued, there is no need to request nor invalidate the existing token.
Authorizer
The authorizer is what processes the token and decides if the token, and requested operation, are authorized.
To start, the authorizer defines how repo roles map to actions:
repo_role_actions("role:owner", ["action:membership", "action:write", "action:read"]);
repo_role_actions("role:writer", ["action:write", "action:read"]);
repo_role_actions("role:reader", ["action:read"]);
repo_role_actions maps roles to actions.
The way that roles such as reader are a subset of owner are not defined in terms of each other, but simply by listing out the allowed actions each role has. This makes things way simpler.
Next, the operation the user requested and the time are defined as facts:
operation("action:read", "repo:3");
time(2024-05-05T12:39:22Z);
operation and time facts.
The operation is the action requested and the repo it is requested against. Time is not used by any later rules nor logic, but it is provided for the user to attenuate their token.
The repo is itself useful information to have and is pulled out via a rule:
repo($repoid) <-
operation($action, $repoid);
Deriving the repo fact from the operation fact.
Usergroups appear as state derived from sqlite. Since usergroups are recursive, the sql query to retrieve group membership must be as well:
WITH RECURSIVE ugs (
usergroup_id,
child_usergroup_id
)
AS (
SELECT usergroup_id,
NULL
FROM UserGroup_membership_users
WHERE user_id = $userid
UNION
SELECT UserGroup_membership_usergroups.usergroup_id,
UserGroup_membership_usergroups.child_usergroup_id
FROM UserGroup_membership_usergroups,
ugs
WHERE UserGroup_membership_usergroups.usergroup_id = ugs.usergroup_id OR
UserGroup_membership_usergroups.usergroup_id = ugs.child_usergroup_id
)
SELECT ugs.usergroup_id,
ugs.child_usergroup_id
FROM ugs;
sqlite query to retrieve usergroup information.
There is also a much simpler query to get the usergroups that the user is a member of. These bits of data get represented in the authorizer as:
usergroup($usergroup, $user);
usergroup($parent_usergroup, $child_usergroup);
Example definitions of usergroups.
which as an example that shows the usefulness of namespacing:
usergroup("usergroupid:1", "userid:4");
usergroup("usergroupid:1", "usergroupid:2");
usergroup("usergroupid:2", "usergroupid:3");
Usergroup examples.
here userid’s and usergroupid’s are able to exist in the same fact, without being mistaken for one another. This makes later logic much simpler.
repogroups appear in a similar manner:
repogroup($repogroup, $repo);
repogroup("repogroupid:1", "repo:3");
Repogroup definition and example.
Role assignments are a little more complicated and once again make use of namespacing. These consist of two nodes: The user (or usergroup), the repo (or repogroup), and the relationship between the two (i.e. the role). This is shown as:
role($user_or_usergroup, $repo_or_repogroup, $role);
role("usergroupid:1", "repogroupid:1", "role:writer");
Role definition and example.
(as an aside, roles required the most effort to retrieve, requiring 4 sqlite queries to get all the possible relationships. This was due to both the sqlite schema enforcing the functional design as well as a desire to keep the sqlite queries relatively simple.)
Next come the datalog rules. The first item to determine is how to tie the requested action to the roles that can perform it. This is done via:
req_role($role, $action) <-
operation($action, $repo),
repo_role_actions($role, $permissions), $permissions.contains($action);
Rule to determine which roles allow for the desired action.
The next rules deal with a concept I am calling “authority” as in “X has authority over Y”. This is where the membership in usergroups and repogroups is flattened out. The reason why it is flattened out is a connection needs to be found between a role
fact and a user/repo. In order to find that connection, every group a user is in, directly or indirectly, must be listed. The same goes for repos.
The repo_authority rule is simple:
repo_authority($member, $member) <-
repo($member);
repo_authority($member, $group) <-
repogroup($group, $member);
repo_authority rule.
The repo_authority first off has authority over itself. The repo also has authority over any group it is a member of.
The user_authority rule is a little more complex since it must be recursive:
user_authority($member, $member) <-
user($member);
user_authority($member, $group) <-
usergroup($group, $member),
$member.starts_with("userid:");
user_authority($member, $subgroup) <-
usergroup($group, $subgroup),
$subgroup.starts_with("usergroupid:"),
user_authority($member, $group);
user_authority rule
Recursion in datalog works similar to a recursive union query in sqlite. Once again, it’s best to read more elsewhere, but rules are derived iteratively until there are no more unique facts that can be generated by evaluating the rules.
Finally, this all simply needs to be tied together:
allow if
user($user),
operation($action, $repo),
req_role($role, $action),
user_authority($user, $userOrgroup),
repo_authority($repo, $repoOrgroup),
role($userOrGroup, $repoOrGroup, $role);
The allow rule that decides the final authorization
By making use of flattening in the authority and namespacing individual user/repos and groups, this final rule was made much simpler.
Sample Run
Running this for a sample request results in the following:
- User Token:
user("userid:4");
- Authorizer:
repo_role_actions("role:owner", ["action:membership", "action:write", "action:read"]);
repo_role_actions("role:writer", ["action:write", "action:read"]);
repo_role_actions("role:reader", ["action:read"]);
operation("action:read", "repo:3");
time(2024-05-08T23:57:55Z);
repo($repoid) <-
operation($action, $repoid);
usergroup("usergroupid:1", "userid:4");
usergroup("usergroupid:1", "usergroupid:2");
usergroup("usergroupid:2", "usergroupid:3");
repogroup("repogroupid:1", "repo:3");
role("usergroupid:1", "repogroupid:1", "role:writer");
user_authority($member, $member) <-
user($member);
user_authority($member, $group) <-
usergroup($group, $member),
$member.starts_with("userid:");
user_authority($member, $subgroup) <-
usergroup($group, $subgroup),
$subgroup.starts_with("usergroupid:"),
user_authority($member, $group);
repo_authority($member, $member) <-
repo($member);
repo_authority($member, $group) <-
repogroup($group, $member);
req_role($role, $action) <-
operation($action, $repo),
repo_role_actions($role, $permissions), $permissions.contains($action);
allow if
user($user),
operation($action, $repo),
req_role($role, $action),
user_authority($user, $userOrgroup),
repo_authority($repo, $repoOrgroup),
role($userOrGroup, $repoOrGroup, $role);
- Final Facts:
operation("action:read","repo:3");
repo("repo:3");
repo_authority("repo:3","repo:3");
repo_authority("repo:3","repogroupid:1");
repo_role_actions("role:owner",["action:membership", "action:read", "action:write"]);
repo_role_actions("role:reader",["action:read"]);
repo_role_actions("role:writer",["action:read", "action:write"]);
repogroup("repogroupid:1","repo:3");
req_role("role:owner","action:read");
req_role("role:reader","action:read");
req_role("role:writer","action:read");
role("usergroupid:1","repogroupid:1","role:writer");
time(2024-05-08T03:57:55Z);
user("userid:4");
user_authority("userid:4","usergroupid:1");
user_authority("userid:4","usergroupid:2");
user_authority("userid:4","usergroupid:3");
user_authority("userid:4","userid:4");
usergroup("usergroupid:1","usergroupid:2");
usergroup("usergroupid:1","userid:4");
usergroup("usergroupid:2","usergroupid:3");
Attenuation
The prior example was neat, and shows how this works from the database to authorizer, but it doesn’t show anything that a more traditional authz system wouldn’t be able to do.
Attenuation is where biscuits really show their value. By adding restrictions to the biscuit token, the user is able to limit what the token can be used for. As some examples consider the following restrictions that can be added to the token:
check if repo("repo:3");
check if operation($action, $repo), $action == "action:read";
check if time($date), $date <= 2100-03-30T19:00:10Z;
Example restrictions to add to a token.
Each of these limits how the token can be used in various fine grained ways. Once a token is limited in this manner the dangers of giving it to a service, or another person, are limited to what the token can do.
As an example of where this can be very useful: Consider a service which signs blobs of compiled code. Because the blob is so sensitive multiple people must consent to signing it. By using biscuits each person signing off can provide a token to the person who will perform the actual signing with the following attenuation:
check if blob("sha-256-hash:7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069");
and know that their token cannot be used for anything else than signing this particular blob. Success for biscuits and an example of how they can do much more than OIDC/OAuth!
References
- Biscuits, the web page: https://www.biscuitsec.org/
- Examples and lessons involving datalog:
- A big thanks to Thomas Ptacek for originally writing about biscuits at: https://fly.io/blog/api-tokens-a-tedious-survey/
- And a second thanks for having one Geoffroy on a podcast he hosts: Security Cryptography Whatever: https://securitycryptographywhatever.com/2022/01/29/biscuits-with-geoffroy-couprie/
- Thank you to the authors of Biscuits as well: Geoffroy Couprie, and Clément Delafargue. It’s thanks to their efforts that I can make this blog post and dream of better authZ systems.
Git Forge AuthZ Mermaid Diagram
flowchart TD
GitRepoService(Git Repo Service)
subgraph Resources
Repos
RepoGroups
Users
UserGroups[User Groups]
UserGroups --> UserGroups
UserGroups --> Users
RepoGroups --> Repos
end
subgraph RepoActions[Repo Actions]
Membership
Write
Read
end
subgraph RepoRoles[Repo Roles]
RepoOwner[Owner]
RepoWrite[Writer]
RepoRead[Reader]
RepoOwner --> RepoWrite --> RepoRead
end
subgraph RepoGroupRoles[Repo Group Roles]
RGWrite[Writer]
RGRead[Reader]
RGWrite --> RGRead
end
Membership --- RepoOwner
Write --- RepoWrite
Write --- RGWrite
Read --- RepoRead
Read --- RGRead
linkStyle 6,7,8,9,10 stroke:#c00,stroke-width:4px,color:red;
GitRepoService --- Resources
Repos --> RepoRoles
RepoGroups --> RepoGroupRoles
Users -.-> RGWrite
Users -.-> RGRead
Users -.-> RepoOwner
Users -.-> RepoWrite
Users -.-> RepoRead
UserGroups -.-> RGWrite
UserGroups -.-> RGRead
UserGroups -.-> RepoOwner
UserGroups -.-> RepoWrite
UserGroups -.-> RepoRead
Example Setup Mermaid Diagram
%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
flowchart TD
subgraph Users
Emma
Noah
Tony
Olivia
Liam
end
subgraph UserGroups
FooOps
BarOps
BazOps
end
subgraph Repos
Alpha
Bravo
Charlie
end
subgraph RepoGroups
Foo
end
subgraph FooRoles[RepoGroup Foo Roles]
FooWriter[Writer]
FooReader[Reader]
FooWriter --> FooReader
end
subgraph CharlieRoles[Repo Charlie Roles]
CharlieOwner[Owner]
CharlieWriter[Writer]
CharlieReader[Reader]
CharlieOwner --> CharlieWriter --> CharlieReader
end
Foo --- Bravo
Foo --- Charlie
FooOps --- BarOps --- BazOps
Foo --- FooRoles
Charlie --- CharlieRoles
FooOps --- Liam
FooOps --- Emma
BazOps --- Tony
FooOps -- Is a member of the role --- FooWriter
Noah --- CharlieReader
Olivia --- CharlieOwner