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.

Businessman handing biscuit to security guard

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:

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.

Micropayments API

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 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

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:

%%{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

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

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