Google Zanzibar: Implementing the Check API and Expand API using .NET

In the previous articles we have seen how to implement a very simplified Relationship-based Access Control using a Google Zanzibar-like data model and some SQL queries. It worked good, and I quite like what we ended up with. But it doesn't let us define an effictive ACL, because at the moment we need to materialize all relations in the database.

But what about unions or intersections of permissions? What about expressing statements like: "If you are the editor of a Document, then you are also a viewer of a Document"? What about inheritance of permissions like: "If you are the viewer of a Folder, then you are also a viewer of the folders Documents"?

So in this article we will take a look at the Google Zanzibar's Namespace Configuration Language, and implement a simplified version of the Google Zanzibar Check API and Expand API.

All code can be found in a Git Repository at:

Table of contents

The Problem

The original Google Zanzibar paper has examples for Rrelation tuples ...

Example Tuple in Text Notation Semantics
doc:readme#owner@10 User 10 is an owner of doc:readme
group:eng#member@11 User 11 is a member of group:eng
doc:readme#viewer@group:eng#member Members of group:eng are viewers of doc:readme
doc:readme#parent@folder:A#... doc:readme is in folder:A

We can read them like this:

  • User :10: is owner of Document :readme:
  • User :11: is member of Group :engineering:
  • A member of Group :engineering: is a viewer of Document :readme:
  • Folder :A: is parent of Document :readme:

We have previously written a T-SQL Function [Identity].[udf_RelationTuples_Check] to implement a function for checking if a User has a Relation to a given Object and thus has permission. It can be expressed in a few lines of SQL using a Common Table Expression (CTE).

CREATE FUNCTION [Identity].[udf_RelationTuples_Check]
(
     @ObjectNamespace NVARCHAR(50)
    ,@ObjectKey INT
    ,@ObjectRelation NVARCHAR(50)
    ,@SubjectNamespace NVARCHAR(50)
    ,@SubjectKey INT
)
RETURNS BIT
AS
BEGIN

    DECLARE @IsAuthorized BIT = 0;

    WITH RelationTuples AS
    (
       SELECT
           [RelationTupleID]
          ,[ObjectNamespace]
          ,[ObjectKey]
          ,[ObjectRelation]
          ,[SubjectNamespace]
          ,[SubjectKey]
          ,[SubjectRelation]
          , 0 AS [HierarchyLevel]
        FROM
          [Identity].[RelationTuple]
        WHERE
            [ObjectNamespace] = @ObjectNamespace AND [ObjectKey] = @ObjectKey AND [ObjectRelation] = @ObjectRelation

        UNION All

        SELECT        
           r.[RelationTupleID]
          ,r.[ObjectNamespace]
          ,r.[ObjectKey]
          ,r.[ObjectRelation]
          ,r.[SubjectNamespace]
          ,r.[SubjectKey]
          ,r.[SubjectRelation]
          ,[HierarchyLevel] + 1 AS [HierarchyLevel]
      FROM 
        [Identity].[RelationTuple] r, [RelationTuples] cte
      WHERE 
        cte.[SubjectKey] = r.[ObjectKey] 
            AND cte.[SubjectNamespace] = r.[ObjectNamespace] 
            AND cte.[SubjectRelation] = r.[ObjectRelation]
    )
    SELECT @IsAuthorized =
        CASE
            WHEN EXISTS(SELECT 1 FROM [RelationTuples] WHERE [SubjectNamespace] = @SubjectNamespace AND [SubjectKey] = @SubjectKey) 
                THEN 1
            ELSE 0
        END;

    RETURN @IsAuthorized;
END

But this function is not an effective Access Control List, the Google Zanzibar Paper notes on it:

While relation tuples reflect relationships between objects and users, they do not completely define the effective ACLs. For example, some clients specify that users with editor permissions on each object should have viewer permission on the same object.

While such relationships between relations can be represented by a relation tuple per object, storing a tuple for each object in a namespace would be wasteful and make it hard to make modifications across all objects.

Instead, we let clients define object-agnostic relationships via userset rewrite rules in relation configs.

The Zanzibar Paper then goes on to define a so called "Namespace Configuration Language" and shares some Pseudo-Code for it ...

name: "doc"

relation { name: "owner" }

relation {
    name: "editor"
    userset_rewrite {
        union {
            child { _this {} }
            child { computed_userset { relation: "owner" } }
} } }
relation {
    name: "viewer"
    userset_rewrite {
        union {
            child { _this {} }
            child { computed_userset { relation: "editor" } }
            child { tuple_to_userset {
                tupleset { relation: "parent" }
                computed_userset {
                    object: $TUPLE_USERSET_OBJECT # parent folder
                    relation: "viewer"
            } } }
} } }

Let's parse it and implement the Check API and Expand API described in the original paper!

Parsing the Google Zanzibar Configuration Language

The first thing we need to do is to parse the namespace configuration language as described in the Google Zanzibar paper. We could try to hand-roll a Lexer and Parser for the language, but there's ANTLR4 we could use to generate the code.

An ANTLR4 Grammar for the Configuration Language

After spending some hours learning ANTLR4 syntax and an amateurish Grammars, I have seen, that there's already an ANTLR4 Grammar for the Google Zanzibar Configuration Language on GitHub. It was written by Kjell Holmgren. So all credit goes to him, his acl-rewrite is a great project and I've learnt tons about Google Zanzibar.

/** 
  * This Grammar was written by Kjell Holmgren (https://github.com/kholmgren):
  * 
  *     - https://github.com/kholmgren/acl-rewrite/blob/master/src/main/antlr4/io/kettil/rewrite/parser/UsersetRewrite.g4
  */
grammar UsersetRewrite;

options { caseInsensitive=true; }

@header {#pragma warning disable 3021}

namespace
    : 'name' ':' namespaceName=STRING relation* EOF
    ;

relation
    : 'relation' '{' 'name' ':' relationName=STRING usersetRewrite? '}'
    ;

usersetRewrite
    : 'userset_rewrite' '{' userset '}'
    ;

userset
    : childUserset
    | computedUserset
    | setOperationUserset
    | thisUserset
    | tupleToUserset
    ;

childUserset
    : 'child' '{' userset '}'
    ;

computedUserset
    : 'computed_userset' '{' (usersetNamespaceRef | usersetObjectRef | usersetRelationRef)+ '}'
    ;

usersetNamespaceRef
    : 'namespace' ':' ref=(STRING | TUPLE_USERSET_NAMESPACE)
    ;

usersetObjectRef
    : 'object' ':' ref=(STRING | TUPLE_USERSET_OBJECT)
    ;

usersetRelationRef
    : 'relation' ':' ref=(STRING | TUPLE_USERSET_RELATION)
    ;

thisUserset
    : '_this' '{' '}'
    ;

tupleToUserset
    : 'tuple_to_userset' '{' tupleset computedUserset '}'
    ;

tupleset
    : 'tupleset' '{' (namespaceRef | objectRef | relationRef)+ '}'
    ;

namespaceRef
    : 'namespace' ':' ref=STRING
    ;

objectRef
    : 'object' ':' ref=STRING
    ;

relationRef
    : 'relation' ':' ref=STRING
    ;

setOperationUserset
    : op=(UNION | INTERSECT | EXCLUDE) '{' userset* '}'
    ;

UNION
    : 'union'
    ;

INTERSECT
    : 'intersect'
    ;

EXCLUDE
    : 'exclude'
    ;

TUPLE_USERSET_NAMESPACE
    : '$TUPLE_USERSET_NAMESPACE'
    ;

TUPLE_USERSET_OBJECT
    : '$TUPLE_USERSET_OBJECT'
    ;

TUPLE_USERSET_RELATION
    : '$TUPLE_USERSET_RELATION'
    ;

STRING
   : '"' ~["]* '"'
   | '\'' ~[']* '\''
   ;

SINGLE_LINE_COMMENT
   : '//' .*? (NEWLINE | EOF) -> skip
   ;

MULTI_LINE_COMMENT
   : '/*' .*? '*/' -> skip
   ;

IDENTIFIER
   : IDENTIFIER_START IDENTIFIER_PART*
   ;
fragment IDENTIFIER_START
   : [\p{L}]
   | '$'
   | '_'
   ;
fragment IDENTIFIER_PART
   : IDENTIFIER_START
   | [\p{M}]
   | [\p{N}]
   | [\p{Pc}]
   | '\u200C'
   | '\u200D'
   ;
fragment NEWLINE
   : '\r\n'
   | [\r\n\u2028\u2029]
   ;

WS
   : [ \t\n\r\u00A0\uFEFF\u2003] + -> skip
   ;

Generating the C# Lexer and Parser

The Grammar is unlikely to change, so it's totally fine for us to create it manually and not embed it in a build step. From the ANTLR homepage I am downloading the antlr-4.13.1-complete.jar and put it in a tools folder. In the root folder, we then create a file makeUsersetRewriteParser.bat:

@echo off

:: Licensed under the MIT license. See LICENSE file in the project root for full license information.

:: ANTLR4 Executable
set ANTLR4_JAR=%~dp0tools\antlr-4.13.1-complete.jar  

:: Parameters for the Code Generator
set PARAM_USERSET_GRAMMAR=%~dp0\RebacExperiments\RebacExperiments.Acl\Ast\UsersetRewrite.g4
set PARAM_OUTPUT_FOLDER=%~dp0\RebacExperiments\RebacExperiments.Acl\Ast\Generated
set PARAM_NAMESPACE=RebacExperiments.Acl.Ast.Generated

:: Run the "Antlr4" Code Generator
java -jar %ANTLR4_JAR%^
    -package %PARAM_NAMESPACE%^
    -visitor^
    -no-listener^
    -Dlanguage=CSharp^
    -Werror^
    -o %PARAM_OUTPUT_FOLDER%^
    %PARAM_USERSET_GRAMMAR%

Works! But the generated code contains some warnings, that we need to suppress. So we add a #pragma directive to both the Lexer and Parser for suppressing these warnings explicitly.

grammar UsersetRewrite;

@parser::header {#pragma warning disable 3021}
@lexer::header {#pragma warning disable 3021}

namespace
    : 'name' ':' namespaceName=STRING relation* EOF
    ;

# ...

We can now see the files being created for the Grammar:

PS > .\makeUsersetRewriteParser.bat
PS > tree /f .\src\AclExperiments\Parser\
│
│   UsersetRewrite.g4
│
└───Generated
        UsersetRewrite.interp
        UsersetRewrite.tokens
        UsersetRewriteBaseVisitor.cs
        UsersetRewriteLexer.cs
        UsersetRewriteLexer.interp
        UsersetRewriteLexer.tokens
        UsersetRewriteParser.cs
        UsersetRewriteVisitor.cs

Defining the Abstract Syntax Tree

What's next is parsing the namespace configuration to an Abstract Syntax Tree (AST).

Every Experession in the namespace configuration language is a UsersetExpression:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    /// Base class for all Userset Expressions.
    /// </summary>
    public abstract record UsersetExpression
    {
    }
}

The root node is a NamespaceUsersetExpression, which has a name and its relations:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    /// The root node of the Zanzibar Configuration language. It contains the 
    /// name of the configured subject and an optional list of relations, expressed 
    /// as <see cref="RelationUsersetExpression"/>.
    /// </summary>
    public record NamespaceUsersetExpression : UsersetExpression
    {
        /// <summary>
        /// Gets or sets the Namespace being configured.
        /// </summary>
        public required string Name { get; set; }

        /// <summary>
        /// Gets or sets the Relations expressed by the Namespace configuration.
        /// </summary>
        public Dictionary<string, RelationUsersetExpression> Relations { get; set; } = new();
    }
}

A RelationUsersetExpression defines the relation name and has a UsersetRewrite associated. If the namespace configuration just contains a name, we assume to just use the direct relations, expressed by the ThisUsersetExpression:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    /// A Relation is expressed by its name and an optional rewrite, which is expressed as a 
    /// <see cref="UsersetExpression"/>. 
    /// </summary>
    public record RelationUsersetExpression : UsersetExpression
    {
        public required string Name { get; set; }

        public UsersetExpression Rewrite { get; set; } = new ThisUsersetExpression();
    }
}

In the paper we can see Relations having either no Userset rewrite assigned or having a union of userset rewrites, so you can have a union of... Direct relations stored in the database (ThisUsersetExpression), Computed Usersets (ComputedUsersetExpression) or inherited permissions (TupleToUsersetExpression).

The _this userset rewrite states to return the tupleset of all matching materialized relations:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    /// Returns all users from stored relation tuples for the <code>object#relation</code> pair, including 
    /// indirect ACLs referenced by usersets from the tuples.This is the default behavior when no rewrite
    /// rule is specified.
    /// </summary>
    public record ThisUsersetExpression : UsersetExpression
    {
    }
}

The computed_userset is expressed by using a ComputedUsersetExpression. It allows us to define inheritance of relations, such as "You are a viewer of a document, if you are the editor of a document".

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    /// Computes, for the input object, a new userset. For example, this allows the userset expression for 
    /// a viewer relation to refer to the editor userset on the same object, thus offering an ACL inheritance
    /// capability between relations.
    /// </summary>
    public record ComputedUsersetExpression : UsersetExpression
    {
        /// <summary>
        /// Gets or sets the Namespace.
        /// </summary>
        public string? Namespace { get; set; }

        /// <summary>
        /// Gets or sets the Object,
        /// </summary>
        public string? Object { get; set; }

        /// <summary>
        /// Gets or sets the Relation.
        /// </summary>
        public string? Relation { get; set; }
    }
}

To model something statements like "You are the viewer of a document, if you are the viewer of the documents parent folder", we need a tuple_to_userset expression, which is defined in the TupleToUsersetExpression expression.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    ///  Computes a tupleset (§2.4.1) from the input object, fetches relation tuples matching the tupleset, and computes 
    ///  a userset from every fetched relation tuple.This flexible primitive allows our clients to express complex
    ///  policies such as "Look up the 'parent' Folder of the Document and inherit 
    ///  its 'viewers'".
    /// </summary>
    public record TupleToUsersetExpression : UsersetExpression
    {
        /// <summary>
        /// Gets or sets the Tupleset.
        /// </summary>
        public required TuplesetExpression TuplesetExpression { get; set; }

        /// <summary>
        /// Gets or sets the Computer Userset.
        /// </summary>
        public required ComputedUsersetExpression ComputedUsersetExpression { get; set; }
    }
}

The TuplesetExpression holds the information, which set of relation tuples is going to passed to the computed userset rewrite:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    /// Each tupleset specifies keys of a set of relation tuples. The set can include a single tuple key, or 
    /// all tuples with a given object ID or userset in a namespace, optionally constrained by a relation 
    /// name.
    /// </summary>
    public record TuplesetExpression : UsersetExpression
    {
        /// <summary>
        /// Gets or sets the Namespace.
        /// </summary>
        public string? Namespace;

        /// <summary>
        /// Gets or sets the Object.
        /// </summary>
        public string? Object { get; set; }

        /// <summary>
        /// Gets or sets the Relation.
        /// </summary>
        public required string Relation { get; set; }
    }
}

Now where it gets interesting in the Google Zanzibar paper is the Set Operations. You can no only include relation tuples in your result set, but also apply set operations on them, such as union, intersection or exclude. We can model this in a SetOperationEnum enumeration:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    /// The Set Operation to apply for a <see cref="UsersetExpression"/>.
    /// </summary>
    public enum SetOperationEnum
    {
        /// <summary>
        /// Unions together the relations/permissions referenced.
        /// </summary>
        Union = 1,

        /// <summary>
        /// Intersects the set of subjects found for the relations/permissions referenced.
        /// </summary>
        Intersect = 2,

        /// <summary>
        /// Excludes the set of subjects found for the relations/permissions referenced.
        /// </summary>
        Exclude = 3,
    }
}

The SetOperationUsersetExpression now contains the operation and the list of children.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Expressions
{
    /// <summary>
    /// Userset Expressions can be expressed as a union, intersection, ... and more 
    /// set operations, so we are able to define more complex authorization rules..
    /// </summary>
    public record SetOperationUsersetExpression : UsersetExpression
    {
        /// <summary>
        /// Gets or sets the Set Operation, such as a Union.
        /// </summary>
        public SetOperationEnum Operation { get; set; }

        /// <summary>
        /// Gets or sets the Children.
        /// </summary>
        public required List<UsersetExpression> Children { get; set; }
    }
}

Parsing the Namespace Configuration to the Userset Expression Tree

We have previously generated a Lexer and a Parser using ANTLR4. We have also passed the visitor flag, so a Visitor named UsersetRewriteBaseVisitor has been created for us. This Visitor can be used to transform the ANTLR4 parse tree to our previously defined Abstract Syntax Tree.

That's a relatively simple task, because our AST mostly looks like the Grammar we have defined.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Antlr4.Runtime;
using Antlr4.Runtime.Misc;
using AclExperiments.Expressions;
using AclExperiments.Parser.Generated;
using static AclExperiments.Parser.Generated.UsersetRewriteParser;

namespace AclExperiments.Parser
{
    public class NamespaceUsersetRewriteParser
    {
        public static NamespaceUsersetExpression Parse(string text)
        {
            var charStream = CharStreams.fromString(text);

            return Parse(charStream);
        }

        private static NamespaceUsersetExpression Parse(ICharStream input)
        {
            var parser = new UsersetRewriteParser(new CommonTokenStream(new UsersetRewriteLexer(input)));

            return (NamespaceUsersetExpression)new UsersetRewriteVisitor().Visit(parser.@namespace());
        }

        private class UsersetRewriteVisitor : UsersetRewriteBaseVisitor<UsersetExpression>
        {
            public override UsersetExpression VisitNamespace([NotNull] NamespaceContext context)
            {
                return new NamespaceUsersetExpression
                {
                    Name = Unquote(context.namespaceName.Text),
                    Relations = context.relation()
                        .Select(VisitRelation)
                        .Cast<RelationUsersetExpression>()
                        .ToDictionary(x => x.Name, x => x)
                };
            }

            public override UsersetExpression VisitRelation([NotNull] RelationContext context)
            {
                return new RelationUsersetExpression
                {
                    Name = Unquote(context.relationName.Text),
                    Rewrite = context.usersetRewrite() != null ? VisitUsersetRewrite(context.usersetRewrite()) : new ChildUsersetExpression { Userset = new ThisUsersetExpression() }
                };
            }

            public override UsersetExpression VisitUsersetRewrite([NotNull] UsersetRewriteContext context)
            {
                if (context.userset() == null)
                {
                    return new ChildUsersetExpression { Userset = new ThisUsersetExpression() };
                }

                return VisitUserset(context.userset());
            }

            public override UsersetExpression VisitChildUserset([NotNull] ChildUsersetContext context)
            {
                return new ChildUsersetExpression
                {
                    Userset = VisitUserset(context.userset())
                };
            }

            public override UsersetExpression VisitComputedUserset([NotNull] ComputedUsersetContext context)
            {
                string? @namespace = null;

                if (context.usersetNamespaceRef().Length > 1)
                {
                    throw new InvalidOperationException("More than one namespace specified");
                }

                if (context.usersetNamespaceRef().Length != 0)
                {
                    var usersetNamespaceRefContext = context.usersetNamespaceRef().First();

                    switch (usersetNamespaceRefContext.@ref.Type)
                    {
                        case STRING:
                            @namespace = Unquote(usersetNamespaceRefContext.STRING().GetText());
                            break;

                        case TUPLE_USERSET_NAMESPACE:
                            @namespace = UsersetRef.TUPLE_USERSET_NAMESPACE;
                            break;
                    }
                }

                string? @object = null;

                if (context.usersetObjectRef().Length > 1)
                {
                    throw new InvalidOperationException("More than one object specified");
                }

                if (context.usersetObjectRef().Length != 0)
                {
                    var usersetObjectRefContext = context.usersetObjectRef().First();

                    switch (usersetObjectRefContext.@ref.Type)
                    {
                        case STRING:
                            @object = Unquote(usersetObjectRefContext.STRING().GetText());
                            break;

                        case TUPLE_USERSET_OBJECT:
                            @object = UsersetRef.TUPLE_USERSET_OBJECT;
                            break;
                    }
                }

                if (@namespace == null && UsersetRef.TUPLE_USERSET_OBJECT.Equals(@object))
                {
                    @namespace = UsersetRef.TUPLE_USERSET_NAMESPACE;
                }

                string relation = string.Empty;

                if (context.usersetRelationRef().Length > 1)
                {
                    throw new InvalidOperationException("More than one relation specified"); 
                }

                if (context.usersetRelationRef().Length != 0)
                {
                    var usersetRelationRefContext = context.usersetRelationRef().First();

                    switch (usersetRelationRefContext.@ref.Type)
                    {
                        case STRING:
                            relation = Unquote(usersetRelationRefContext.STRING().GetText());
                            break;

                        case TUPLE_USERSET_RELATION:
                            relation = UsersetRef.TUPLE_USERSET_RELATION;
                            break;
                    }
                }

                return new ComputedUsersetExpression
                {
                    Namespace = @namespace,
                    Object = @object,
                    Relation = relation
                };
            }

            public override UsersetExpression VisitSetOperationUserset([NotNull] SetOperationUsersetContext context)
            {
                var op = context.op.Type switch
                {
                    UNION => SetOperationEnum.Union,
                    INTERSECT => SetOperationEnum.Intersect,
                    _ => throw new ArgumentException(nameof(context.op.Type)),
                };

                return new SetOperationUsersetExpression
                {
                    Operation = op,
                    Children = context.userset()
                        .Select(x => x.Accept(this))
                        .ToList()
                };
            }

            public override UsersetExpression VisitThisUserset([NotNull] ThisUsersetContext context)
            {
                return new ThisUsersetExpression();
            }

            public override UsersetExpression VisitTupleset([NotNull] TuplesetContext context)
            {
                string? @namespace = null;

                if (context.namespaceRef().Length > 1)
                {
                    throw new InvalidOperationException("More than one namespace specified");
                }

                if (context.namespaceRef().Length != 0)
                {
                    @namespace = Unquote(context.namespaceRef().First().@ref.Text);
                }

                string? @object = null;

                if (context.objectRef().Length > 1)
                {
                    throw new InvalidOperationException("More than one object specified");
                }

                if (context.objectRef().Length != 0)
                {
                    @object = Unquote(context.objectRef().First().@ref.Text);
                }

                string relation = string.Empty;

                if (context.relationRef().Length > 1)
                {
                    throw new InvalidOperationException("More than one relation specified");
                }

                if (context.relationRef().Length != 0)
                {
                    relation = Unquote(context.relationRef().First().@ref.Text);
                }

                return new TuplesetExpression
                {
                    Namespace = @namespace,
                    Object = @object,
                    Relation = relation
                };
            }

            public override UsersetExpression VisitTupleToUserset([NotNull] TupleToUsersetContext context)
            {
                return new TupleToUsersetExpression
                {
                    TuplesetExpression = (TuplesetExpression)VisitTupleset(context.tupleset()),
                    ComputedUsersetExpression = (ComputedUsersetExpression)VisitComputedUserset(context.computedUserset())
                };
            }

            private static string Unquote(string value)
            {
                return value.Trim('"');
            }
        }
    }
}

We can now write a test, to see if it works as expected, in here we only want to see if it parses the configuration without errors.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using AclExperiments.Expressions;
using AclExperiments.Parser;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace AclExperiments.Tests.Parser
{
    [TestClass]
    public class NamespaceUsersetRewriteParserTests
    {
        /// <summary>
        /// Parses the namespace configuration described in Google Zanzibar paper:
        /// 
        ///             name: "doc"
        ///             
        ///             relation { name: "owner" }
        ///         
        ///             relation {
        ///                 name: "editor"
        ///         
        ///                 userset_rewrite {
        ///                     union {
        ///                         child { _this {} }
        ///                         child { computed_userset { relation: "owner" } }
        ///                     } } }
        ///             
        ///             relation {
        ///                 name: "viewer"
        ///                 userset_rewrite {
        ///                     union {
        ///                         child { _this {} }
        ///                         child { computed_userset { relation: "editor" } }
        ///                         child { tuple_to_userset {
        ///                             tupleset { 
        ///                                 relation: "parent"
        ///                             }
        ///                             computed_userset {
        ///                                 object: $TUPLE_USERSET_OBJECT
        ///                                 relation: "viewer"
        ///                         } } }
        ///         } } }
        /// </summary>
        [TestMethod]
        public void NamespaceUsersetRewriteParser_GoogleZanzibarExample_CheckAstBasic()
        {
            // Arrange
            var namespaceConfigText = File.ReadAllText("./Resources/doc.nsconfig");

            // Act
            var namespaceConfig = NamespaceUsersetRewriteParser.Parse(namespaceConfigText);

            // Assert
            Assert.AreEqual("doc", namespaceConfig.Name);

            Assert.AreEqual(3, namespaceConfig.Relations.Count);

            Assert.AreEqual(true, namespaceConfig.Relations.ContainsKey("owner"));
            Assert.AreEqual(true, namespaceConfig.Relations.ContainsKey("editor"));
            Assert.AreEqual(true, namespaceConfig.Relations.ContainsKey("viewer"));
        }

Database Design and Data Access

The database for the example is going to have 3 tables:

  • [Identity].[User]
    • The User is required to audit all data modifications.
  • [Identity].[NamespaceConfiguration]
    • The Namespace Configuration in the Google Zanzibar configuration language.
  • [Identity].[RelationTuple]
    • The materialized Relation Tuples to with the Object-Relation-Subject information

We are going to use System Versioning (Temporal Tables) for all tables, so we can audit changes. The [Identity].[User] holds the user information, which is referenced when auditing the changes.

CREATE TABLE [Identity].[User](
    [UserID]                INT                                         CONSTRAINT [DF_Identity_User_UserID] DEFAULT (NEXT VALUE FOR [Identity].[sq_User]) NOT NULL,
    [FullName]              NVARCHAR(50)                                NOT NULL,
    [PreferredName]         NVARCHAR(50)                                NULL,
    [IsPermittedToLogon]    BIT                                         NOT NULL,
    [LogonName]             NVARCHAR (256)                              NULL,
    [HashedPassword]        NVARCHAR (MAX)                              NULL,
    [RowVersion]            ROWVERSION                                  NULL,
    [LastEditedBy]          INT                                         NOT NULL,
    [ValidFrom]             DATETIME2 (7) GENERATED ALWAYS AS ROW START NOT NULL,
    [ValidTo]               DATETIME2 (7) GENERATED ALWAYS AS ROW END   NOT NULL,
    CONSTRAINT [PK_User] PRIMARY KEY ([UserID]),
    CONSTRAINT [FK_User_LastEditedBy_User_UserID] FOREIGN KEY ([LastEditedBy]) REFERENCES [Identity].[User] ([UserID]),
    PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [Identity].[UserHistory]));

The Namespace Configurations are going to be stored in a Table [Identity].[NamespaceConfiguration]. All Rows also have a version, because we might need them to understand past Authorization decisions.

CREATE TABLE [Identity].[NamespaceConfiguration](
    [NamespaceConfigurationID]      INT                                         CONSTRAINT [DF_Identity_NamespaceConfiguration_NamespaceConfigurationID] DEFAULT (NEXT VALUE FOR [Identity].[sq_NamespaceConfiguration]) NOT NULL,
    [Name]                          NVARCHAR(255)                               NOT NULL,
    [Content]                       NVARCHAR(MAX)                               NOT NULL,
    [Version]                       INT                                         NOT NULL,
    [RowVersion]                    ROWVERSION                                  NULL,
    [LastEditedBy]                  INT                                         NOT NULL,
    [ValidFrom]                     DATETIME2 (7) GENERATED ALWAYS AS ROW START NOT NULL,
    [ValidTo]                       DATETIME2 (7) GENERATED ALWAYS AS ROW END   NOT NULL,
    CONSTRAINT [PK_NamespaceConfiguration] PRIMARY KEY ([NamespaceConfigurationID]),
    CONSTRAINT [FK_NamespaceConfiguration_LastEditedBy_User_UserID] FOREIGN KEY ([LastEditedBy]) REFERENCES [Identity].[User] ([UserID]),
    PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [Identity].[NamespaceConfigurationHistory]));

The Relation Tuples are going to be stored in a Table [Identity].[RelationTuple].

CREATE TABLE [Identity].[RelationTuple](
    [RelationTupleID]       INT                                         CONSTRAINT [DF_Identity_RelationTuple_RelationTupleID] DEFAULT (NEXT VALUE FOR [Identity].[sq_RelationTuple]) NOT NULL,
    [Namespace]             NVARCHAR(50)                                NOT NULL,
    [Object]                NVARCHAR(50)                                NOT NULL,
    [Relation]              NVARCHAR(50)                                NOT NULL,
    [Subject]               NVARCHAR(50)                                NOT NULL,
    [RowVersion]            ROWVERSION                                  NULL,
    [LastEditedBy]          INT                                         NOT NULL,
    [ValidFrom]             DATETIME2 (7) GENERATED ALWAYS AS ROW START NOT NULL,
    [ValidTo]               DATETIME2 (7) GENERATED ALWAYS AS ROW END   NOT NULL,
    CONSTRAINT [PK_RelationTuple] PRIMARY KEY ([RelationTupleID]),
    CONSTRAINT [FK_RelationTuple_LastEditedBy_User_UserID] FOREIGN KEY ([LastEditedBy]) REFERENCES [Identity].[User] ([UserID]),
    PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [Identity].[RelationTupleHistory]));

Everything is put into a nicely structured SQL Server Database Project, which can be used to create the database and create scripts, without ever leaving Visual Studio.

AclService: A .NET Implementation for the Expand and Check API

We are creating a class named AclService, which is going to implement the Google Zanzibar Check and Expand APIs.

To do this, the AclService uses two dependencies, I didn't show in the article, the INamespaceConfigurationStore and the IRelationTupleStore. The IRelationTupleStore provides methods to query for materialized relation tuples, the INamespaceConfigurationStore is used to query for available namespace configurations.

The ACL Domain Model

Now let's start with the domain model we are going to work with.

Objects in Google Zanzibar always have a Namespace and an Id.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Models
{
    /// <summary>
    /// The Object of an Object to Subject Relation.
    /// </summary>
    public record AclObject
    {
        /// <summary>
        /// Gets or sets the Namespace.
        /// </summary>
        public required string Namespace { get; set; }

        /// <summary>
        /// Gets or sets the Id.
        /// </summary>
        public required string Id { get; set; }
    }
}

For Subjects we need to differentiate between a UserId and a Userset. In a later article or incarnation, we might not only authorize users, so I have called these a SubjectId and a SubjectSet. Both have a common base class AclSubject.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Models
{
    /// <summary>
    /// Base class for Subjects, which is either a <see cref="AclSubjectId"/> or a <see cref="AclSubjectSet"/>.
    /// </summary>
    public abstract record AclSubject
    {
        /// <summary>
        /// Formats the given <see cref="AclSubject"/> as a <see cref="string"/>.
        /// </summary>
        /// <returns>Textual Representation of the <see cref="AclSubject"/></returns>
        public abstract string FormatString();
    }
}

A SubjectId refers to the specific user and only contains the User ID.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Models
{
    /// <summary>
    /// A Subject ID.
    /// </summary>
    public record AclSubjectId : AclSubject
    {
        /// <summary>
        /// Gets or sets the ID.
        /// </summary>
        public required string Id { get; set; }

        public static AclSubjectId FromString(string s)
        {
            return new AclSubjectId { Id = s };
        }

        public override string FormatString()
        {
            return Id;
        }
    }
}

A Userset is defined by a Namespace, Object and Relation, in the format <Namespace>:<Object>#<Relation>, to express something like group members having viewer access to a document, we would write something like: doc:1#viewer@group:2#member.

Something interesting to notice is, how the textual representation of the Tuple in the Google Zanzibar paper models the relationship between the document and the parent folder as as a SubjectSet: doc:doc_1#parent@folder:folder_1#... using the special relation ....

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Models
{
    /// <summary>
    /// A Subject Set.
    /// </summary>
    public record AclSubjectSet : AclSubject
    {
        /// <summary>
        /// Gets or sets the Namespace.
        /// </summary>
        public required string Namespace { get; set; }

        /// <summary>
        /// Gets or sets the Object.
        /// </summary>
        public required string Object { get; set; }

        /// <summary>
        /// Gets or sets the Relation.
        /// </summary>
        public required string Relation { get; set; }

        /// <summary>
        /// Formats the <see cref="AclSubjectSet"/> as a <see cref="string"/> in the Google Zanzibar notation.
        /// </summary>
        /// <returns>The textual SubjectSet representation</returns>
        public override string FormatString()
        {
            return string.Format("{0}:{1}#{2}", Namespace, Object, Relation);
        }

        /// <summary>
        /// Parses a given <see cref="string"/> in Google Zanzibar notation to an <see cref="AclSubjectSet"/>.
        /// </summary>
        /// <param name="s">Textual representation of a Subject Set in Google Zanzibar notation</param>
        /// <returns>The <see cref="AclSubject"/> for the given text</returns>
        /// <exception cref="InvalidOperationException">Thrown, if the input string is not a valid SubjectSet</exception>
        public static AclSubjectSet FromString(string s)
        {
            var parts = s.Split("#");

            if (parts.Length != 2)
            {
                throw new InvalidOperationException("Invalid SubjectSet String");
            }

            var innerParts = parts[0].Split(":");

            if (innerParts.Length != 2)
            {
                throw new InvalidOperationException("Invalid SubjectSet String");
            }

            return new AclSubjectSet
            {
                Namespace = innerParts[0],
                Object = innerParts[1],
                Relation = parts[1]
            };
        }
    }
}

The Object and the Subject are connected via a Relation. We model this as a AclRelation object:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace AclExperiments.Models
{
    /// <summary>
    /// A Relation between an Object and a Subject (or SubjectSet).
    /// </summary>
    public record AclRelation
    {
        /// <summary>
        /// Gets or sets the Object.
        /// </summary>
        public required AclObject Object { get; set; }

        /// <summary>
        /// Gets or sets the Relation.
        /// </summary>
        public required string Relation { get; set; }

        /// <summary>
        /// Gets or sets the Subject.
        /// </summary>
        public required AclSubject Subject { get; set; }
    }
}

Finally we need a was to convert between a given Google Zanzibar subject notation (user_1, folder:folder_1#...) and the AclSubject (AclSubjectId or a AclSubject). This is put into a static class AclSubjects.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using AclExperiments.Models;

namespace AclExperiments.Utils
{
    /// <summary>
    /// Utility methods for working with an <see cref="AclSubject"/>.
    /// </summary>
    public static class AclSubjects
    {
        /// <summary>
        /// Converts a given string to a <see cref="AclSubject"/>, which is either a <see cref="AclSubjectId"/> or a <see cref="AclSubjectSet"/>.
        /// </summary>
        /// <param name="s">Subject String in Google Zanzibar Notation</param>
        /// <returns>The <see cref="AclSubject"/></returns>
        public static AclSubject SubjectFromString(string s)
        {
            if (s.Contains('#'))
            {
                return AclSubjectSet.FromString(s);
            }

            return AclSubjectId.FromString(s);
        }

        /// <summary>
        /// Converts a given <see cref="AclSubject"/> to the textual Google Zanzibar representation.
        /// </summary>
        /// <param name="s">Subject, which is either a SubjectId or SubjectSet</param>
        /// <returns>Google Zanzibar Notation for the ACL Relation</returns>
        /// <exception cref="InvalidOperationException">Thrown, if the <see cref="AclSubject"/> couldn't be formatted as a string</exception>
        public static string SubjectToString(AclSubject s)
        {
            switch (s)
            {
                case AclSubjectId subjectId:
                    return subjectId.FormatString();
                case AclSubjectSet subjectSet:
                    return subjectSet.FormatString();
                default:
                    throw new InvalidOperationException($"Cannot format Subject Type '{s.GetType().Name}'");
            }
        }
    }
}

Check API

The Check API is used to check if the user has a given permission, which means: Check if a Subject (user) has a Relation to a given Object. We can implement the basic algorithm in a few lines of code.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// ...

namespace AclExperiments
{
    /// <summary>
    /// The <see cref="AclService"/> implements the Google Zanzibar algorithms, such as Expand, Check and ListObjects.
    /// </summary>
    public class AclService
    {
        private readonly ILogger _logger;
        private readonly IRelationTupleStore _relationTupleStore;
        private readonly INamespaceConfigurationStore _namespaceConfigurationStore;

        public AclService(ILogger<AclService> logger, IRelationTupleStore relationTupleStore, INamespaceConfigurationStore namespaceConfigurationStore)
        {
            _logger = logger;
            _relationTupleStore = relationTupleStore;
            _namespaceConfigurationStore = namespaceConfigurationStore;
        }

        #region Check API

        public async Task<bool> CheckAsync(string @namespace, string @object, string relation, string subject, CancellationToken cancellationToken)
        {
            // Get the latest Namespace Configuration from the Store:
            var namespaceConfiguration = await _namespaceConfigurationStore
                .GetLatestNamespaceConfigurationAsync(@namespace, cancellationToken)
                .ConfigureAwait(false);

            // Get the Rewrite for the Relation from the Namespace Configuration:
            var rewrite = GetUsersetRewrite(namespaceConfiguration, relation);

            // Check Rewrite Rules for the Relation:
            return await this
                .CheckUsersetRewriteAsync(rewrite, @namespace, @object, relation, subject, cancellationToken)
                .ConfigureAwait(false);
        }

        /// <summary>
        /// Returns the <see cref="UsersetExpression"/> for a given Namespace Configuration and Relation.
        /// </summary>
        /// <param name="namespaceUsersetExpression">Namespace Configuration</param>
        /// <param name="relation">Relation to Check</param>
        /// <returns>The <see cref="UsersetExpression"/> for the given relation</returns>
        /// <exception cref="InvalidOperationException">Thrown, if the Relation isn't configured in the Namespace Configuration</exception>
        private static UsersetExpression GetUsersetRewrite(NamespaceUsersetExpression namespaceUsersetExpression, string relation)
        {
            if (!namespaceUsersetExpression.Relations.TryGetValue(relation, out var relationUsersetExpression))
            {
                throw new InvalidOperationException($"Namespace '{namespaceUsersetExpression.Name}' has no Relation '{relation}'");
            }

            return relationUsersetExpression.Rewrite;
        }

        /// <summary>
        /// Checks a Userset Rewrite.
        /// </summary>
        /// <param name="rewrite">Rewrite Rule for the Relation</param>
        /// <param name="namespace">Object Namespace</param>
        /// <param name="object">Object ID</param>
        /// <param name="relation">Relation name</param>
        /// <param name="subject">Subject Name</param>
        /// <param name="cancellationToken"></param>
        /// <returns></returns>
        /// <exception cref="InvalidOperationException"></exception>
        public async Task<bool> CheckUsersetRewriteAsync(UsersetExpression rewrite, string @namespace, string @object, string relation, string subject, CancellationToken cancellationToken)
        {
            switch (rewrite)
            {
                case ThisUsersetExpression thisUsersetExpression:
                    return await this
                        .CheckThisAsync(thisUsersetExpression, @namespace, @object, relation, subject, cancellationToken)
                        .ConfigureAwait(false);
                case ChildUsersetExpression childUsersetExpression:
                    return await this
                        .CheckUsersetRewriteAsync(childUsersetExpression.Userset, @namespace, @object, relation, subject, cancellationToken)
                        .ConfigureAwait(false);
                case ComputedUsersetExpression computedUsersetExpression:
                    return await
                        CheckComputedUsersetAsync(computedUsersetExpression, @namespace, @object, subject, cancellationToken)
                        .ConfigureAwait(false);
                case TupleToUsersetExpression tupleToUsersetExpression:
                    return await
                        CheckTupleToUsersetAsync(tupleToUsersetExpression, @namespace, @object, relation, subject, cancellationToken)
                        .ConfigureAwait(false);
                case SetOperationUsersetExpression setOperationExpression:
                    return await
                        CheckSetOperationExpression(setOperationExpression, @namespace, @object, relation, subject, cancellationToken)
                        .ConfigureAwait(false);
                default:
                    throw new InvalidOperationException($"Unable to execute check for Expression '{rewrite.GetType().Name}'");
            }
        }

        private async Task<bool> CheckSetOperationExpression(SetOperationUsersetExpression setOperationExpression, string @namespace, string @object, string relation, string user, CancellationToken cancellationToken)
        {
            switch (setOperationExpression.Operation)
            {
                case SetOperationEnum.Intersect:
                    {
                        foreach (var child in setOperationExpression.Children)
                        {
                            var permitted = await this
                                .CheckUsersetRewriteAsync(child, @namespace, @object, relation, user, cancellationToken)
                                .ConfigureAwait(false);

                            if (!permitted)
                            {
                                return false;
                            }
                        }

                        return true;
                    }
                case SetOperationEnum.Union:
                    {
                        foreach (var child in setOperationExpression.Children)
                        {
                            var permitted = await this
                                .CheckUsersetRewriteAsync(child, @namespace, @object, relation, user, cancellationToken)
                                .ConfigureAwait(false);

                            if (permitted)
                            {
                                return true;
                            }
                        }

                        return false;
                    }
                default:
                    throw new NotImplementedException($"No Implementation for Set Operator '{setOperationExpression.Operation}'");
            }
        }

        private async Task<bool> CheckThisAsync(ThisUsersetExpression thisUsersetExpression, string @namespace, string @object, string relation, string user, CancellationToken cancellationToken)
        {
            var aclObject = new AclObject
            {
                Namespace = @namespace,
                Id = @object,
            };

            var aclSubject = AclSubjects.SubjectFromString(user);

            var count = await _relationTupleStore
                .GetRelationTuplesRowCountAsync(aclObject, relation, aclSubject, cancellationToken)
                .ConfigureAwait(false);

            if (count > 0)
            {
                return true;
            }

            var subjestSets = await _relationTupleStore
                .GetSubjectSetsAsync(aclObject, relation, cancellationToken)
                .ConfigureAwait(false);

            foreach (var subjectSet in subjestSets)
            {
                var permitted = await this
                    .CheckAsync(subjectSet.Namespace, subjectSet.Object, subjectSet.Relation, user, cancellationToken)
                    .ConfigureAwait(false);

                if (permitted)
                {
                    return true;
                }
            }

            return false;
        }

        private async Task<bool> CheckComputedUsersetAsync(ComputedUsersetExpression computedUsersetExpression, string @namespace, string @object, string user, CancellationToken cancellationToken)
        {
            if (computedUsersetExpression.Relation == null)
            {
                throw new InvalidOperationException("A Computed Userset requires a relation");
            }

            return await this
                .CheckAsync(@namespace, @object, computedUsersetExpression.Relation, user, cancellationToken)
                .ConfigureAwait(false);
        }

        private async Task<bool> CheckTupleToUsersetAsync(TupleToUsersetExpression tupleToUsersetExpression, string @namespace, string @object, string relation, string user, CancellationToken cancellationToken)
        {
            {
                var aclObject = new AclObject
                {
                    Namespace = @namespace,
                    Id = @object
                };

                var subjectSets = await _relationTupleStore
                    .GetSubjectSetsAsync(aclObject, tupleToUsersetExpression.TuplesetExpression.Relation, cancellationToken)
                    .ConfigureAwait(false);

                if (subjectSets.Count == 0)
                {
                    return false;
                }

                foreach (var subject in subjectSets)
                {
                    relation = subject.Relation;

                    if (relation == "...")
                    {
                        relation = tupleToUsersetExpression.ComputedUsersetExpression.Relation!;

                        var permitted = await this
                            .CheckAsync(subject.Namespace, subject.Object, relation, user, cancellationToken)
                            .ConfigureAwait(false);

                        if (permitted)
                        {
                            return true;
                        }
                    }
                }

                return false;
            }
        }


        #endregion Check API

    }
}

We can now test, if the Check API works as expected. The test case is described extensively.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// ...

namespace AclExperiments.Tests
{
    [TestClass]
    public class AclServiceTests : IntegrationTestBase
    {
        private AclService _aclService = null!;

        private INamespaceConfigurationStore _namespaceConfigurationStore = null!;
        private IRelationTupleStore _relationTupleStore = null!;

        protected override Task OnSetupBeforeCleanupAsync()
        {
            _aclService = _services.GetRequiredService<AclService>();
            _relationTupleStore = _services.GetRequiredService<IRelationTupleStore>();
            _namespaceConfigurationStore = _services.GetRequiredService<INamespaceConfigurationStore>();

            return Task.CompletedTask;
        }

        public override void RegisterServices(IServiceCollection services)
        {
            services.AddSingleton<ISqlConnectionFactory>((sp) =>
            {
                var connectionString = _configuration.GetConnectionString("ApplicationDatabase");

                if (connectionString == null)
                {
                    throw new InvalidOperationException($"No Connection String named 'ApplicationDatabase' found in appsettings.json");
                }

                return new SqlServerConnectionFactory(connectionString);
            });

            services.AddSingleton<AclService>();
            services.AddSingleton<INamespaceConfigurationStore, SqlNamespaceConfigurationStore>();
            services.AddSingleton<IRelationTupleStore, SqlRelationTupleStore>();
        }

        #region Check API

        ///// <summary>
        /// In this test we have one document "doc_1", and two users "user_1" and "user_2". "user_1" 
        /// has a "viewer" permission on "doc_1", because he has a direct relationto it. "user_2" has 
        /// a viewer permission on a folder "folder_1". 
        /// 
        /// The folder "folder_1" is a "parent" of the document, and thus "user_2" inherits the folders 
        /// permission through the computed userset.
        /// 
        /// Namespace |  Object       |   Relation    |   Subject             |
        /// ----------|---------------|---------------|-----------------------|
        /// doc       |   doc_1       |   viewer      |   user_1              |
        /// doc       |   doc_1       |   parent      |   folder:folder_1#... |
        /// folder    |   folder_1    |   viewer      |   user_2              |
        /// </summary>
        [TestMethod]
        public async Task CheckAsync_CheckUserPermissions()
        {
            // Arrange
            await _namespaceConfigurationStore.AddNamespaceConfigurationAsync("doc", 1, File.ReadAllText("Resources/doc.nsconfig"), 1, default);
            await _namespaceConfigurationStore.AddNamespaceConfigurationAsync("folder", 1, File.ReadAllText("Resources/folder.nsconfig"), 1, default);

            var aclRelations = new[]
            {
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "doc",
                            Id = "doc_1"
                        },
                        Relation = "owner",
                        Subject = new AclSubjectId
                        {
                            Id = "user_1"
                        }
                    },
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "doc",
                            Id = "doc_1"
                        },
                        Relation = "parent",
                        Subject = new AclSubjectId
                        {
                            Id = "folder:folder_1#..."
                        }
                    },
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "folder",
                            Id = "folder_1"
                        },
                        Relation = "viewer",
                        Subject = new AclSubjectId
                        {
                            Id = "user_2"
                        }
                    },
                };

            await _relationTupleStore.AddRelationTuplesAsync(aclRelations, 1, default);

            // Act
            var user_1_is_permitted = await _aclService.CheckAsync("doc", "doc_1", "viewer", "user_1", default);
            var user_2_is_permitted = await _aclService.CheckAsync("doc", "doc_1", "viewer", "user_2", default);
            var user_3_is_permitted = await _aclService.CheckAsync("doc", "doc_1", "viewer", "user_3", default);

            // Assert
            Assert.AreEqual(true, user_1_is_permitted);
            Assert.AreEqual(true, user_2_is_permitted);
            Assert.AreEqual(false, user_3_is_permitted);
        }

        #endregion Check API

        // ...

    }
}

Expand API

While the Check API only dealth with the Question "Are we permitted to access the object?", the Expand API is used to determine the full ACL for a user. This is especially useful for debugging, to understand why a user has been granted or been denied permission to an object.

It starts by defining a SubjectTree, which is going to hold all Subjects determined by the Expand API.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using AclExperiments.Expressions;

namespace AclExperiments.Models
{
    /// <summary>
    /// The expanded Subject Tree.
    /// </summary>
    public record SubjectTree
    {
        /// <summary>
        /// Gets or sets the Userset Expression for this.
        /// </summary>
        public required UsersetExpression Expression { get; set; }

        /// <summary>
        /// Gets or sets the determined Subjects.
        /// </summary>
        public HashSet<AclSubject> Result { get; set; } = [];

        /// <summary>
        /// Gets or sets the Children Trees.
        /// </summary>
        public List<SubjectTree> Children { get; set; } = new();
    }
}

And now we can finally implement the Expand API to traverse the Expression Tree and evaluate the Rewrite Rules recursively. I initially tried to use a Visitor pattern, but my brain just couldn't digest a double dispatch.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// ...

namespace AclExperiments
{
    /// <summary>
    /// The <see cref="AclService"/> implements the Google Zanzibar algorithms, such as Expand, Check and ListObjects.
    /// </summary>
    public class AclService
    {
        private readonly ILogger _logger;
        private readonly IRelationTupleStore _relationTupleStore;
        private readonly INamespaceConfigurationStore _namespaceConfigurationStore;

        public AclService(ILogger<AclService> logger, IRelationTupleStore relationTupleStore, INamespaceConfigurationStore namespaceConfigurationStore)
        {
            _logger = logger;
            _relationTupleStore = relationTupleStore;
            _namespaceConfigurationStore = namespaceConfigurationStore;
        }

        // ...

        #region Expand API

        public async Task<SubjectTree> ExpandAsync(string @namespace, string @object, string relation, int depth, CancellationToken cancellationToken)
        {
            var namespaceConfiguration = await _namespaceConfigurationStore
                .GetLatestNamespaceConfigurationAsync(@namespace, cancellationToken)
                .ConfigureAwait(false);

            var usersetRewriteForRelation = GetUsersetRewrite(namespaceConfiguration, relation);

            var t = await this
                 .ExpandRewriteAsync(usersetRewriteForRelation, @namespace, @object, relation, depth, cancellationToken)
                 .ConfigureAwait(false);

            return new SubjectTree
            {
                Expression = namespaceConfiguration,
                Children = [t],
                Result = t.Result
            };
        }

        public async Task<SubjectTree> ExpandRewriteAsync(UsersetExpression rewrite, string @namespace, string @object, string relation, int depth, CancellationToken cancellationToken)
        {
            switch (rewrite)
            {
                case ThisUsersetExpression thisUsersetExpression:
                    return await this
                        .ExpandThisAsync(thisUsersetExpression, @namespace, @object, relation, depth, cancellationToken)
                        .ConfigureAwait(false);
                case ComputedUsersetExpression computedUsersetExpression:
                    return await this
                        .ExpandComputedUserSetAsync(computedUsersetExpression, @namespace, @object, relation, depth, cancellationToken)
                        .ConfigureAwait(false);
                case TupleToUsersetExpression tupleToUsersetExpression:
                    return await this
                        .ExpandTupleToUsersetAsync(tupleToUsersetExpression, @namespace, @object, relation, depth, cancellationToken)
                        .ConfigureAwait(false);
                case ChildUsersetExpression childUsersetExpression:
                    return await this
                        .ExpandRewriteAsync(childUsersetExpression.Userset, @namespace, @object, relation, depth, cancellationToken)
                        .ConfigureAwait(false);
                case SetOperationUsersetExpression setOperationExpression:
                    return await this
                        .ExpandSetOperationAsync(setOperationExpression, @namespace, @object, relation, depth, cancellationToken)
                        .ConfigureAwait(false);
                default:
                    throw new InvalidOperationException($"Unable to execute check for Expression '{rewrite.GetType().Name}'");
            }
        }

        public async Task<SubjectTree> ExpandSetOperationAsync(SetOperationUsersetExpression setOperationUsersetExpression, string @namespace, string @object, string relation, int depth, CancellationToken cancellationToken)
        {
            List<SubjectTree> children = [];

            // TODO This could be done in Parallel
            foreach (var child in setOperationUsersetExpression.Children)
            {
                var t = await this
                    .ExpandRewriteAsync(child, @namespace, @object, relation, depth, cancellationToken)
                    .ConfigureAwait(false);

                children.Add(t);
            }

            HashSet<AclSubject>? subjects = null;

            foreach (var child in children)
            {
                if (subjects == null)
                {
                    subjects = new HashSet<AclSubject>(child.Result);
                }
                else
                {
                    switch (setOperationUsersetExpression.Operation)
                    {
                        case SetOperationEnum.Union:
                            subjects.UnionWith(child.Result);
                            break;
                        case SetOperationEnum.Intersect:
                            subjects.IntersectWith(child.Result);
                            if (subjects.Count == 0)
                                goto eval;
                            break;
                        case SetOperationEnum.Exclude:
                            subjects.ExceptWith(child.Result);
                            if (subjects.Count == 0)
                                goto eval;
                            break;
                        default:
                            throw new InvalidOperationException();
                    }
                }
            }

        eval:

            return new SubjectTree
            {
                Expression = setOperationUsersetExpression,
                Result = subjects ?? [],
                Children = children
            };
        }

        public async Task<SubjectTree> ExpandThisAsync(ThisUsersetExpression expression, string @namespace, string @object, string relation, int depth, CancellationToken cancellationToken)
        {
            var query = new RelationTupleQuery
            {
                Namespace = @namespace,
                Object = @object,
                Relation = relation
            };

            var tuples = await _relationTupleStore
                .GetRelationTuplesAsync(query, cancellationToken)
                .ConfigureAwait(false);

            var children = new List<SubjectTree>();
            var result = new HashSet<AclSubject>();

            foreach (var tuple in tuples)
            {
                if (tuple.Subject is AclSubjectSet subjectSet)
                {
                    var rr = subjectSet.Relation;

                    if (rr == "...")
                    {
                        rr = relation;
                    }

                    var t = await this
                        .ExpandAsync(subjectSet.Namespace, subjectSet.Object, rr, depth - 1, cancellationToken)
                        .ConfigureAwait(false);

                    children.Add(t);
                }
                else
                {
                    var t = new SubjectTree
                    {
                        Expression = expression,
                        Result = [tuple.Subject]
                    };

                    children.Add(t);

                    result.Add(tuple.Subject);
                }
            }

            return new SubjectTree
            {
                Expression = expression,
                Result = result,
                Children = children
            };
        }

        public async Task<SubjectTree> ExpandComputedUserSetAsync(ComputedUsersetExpression computedUsersetExpression, string @namespace, string @object, string relation, int depth, CancellationToken cancellationToken)
        {
            if (computedUsersetExpression.Relation == null)
            {
                throw new InvalidOperationException("A Computed Userset requires a relation");
            }

            var subTree = await this
                .ExpandAsync(@namespace, @object, computedUsersetExpression.Relation, depth - 1, cancellationToken)
                .ConfigureAwait(false);

            return new SubjectTree
            {
                Expression = computedUsersetExpression,
                Children = [subTree],
                Result = subTree.Result
            };
        }

        public async Task<SubjectTree> ExpandTupleToUsersetAsync(TupleToUsersetExpression tupleToUsersetExpression, string @namespace, string @object, string relation, int depth, CancellationToken cancellationToken)
        {
            var rr = tupleToUsersetExpression.TuplesetExpression.Relation;

            if (rr == "...")
            {
                rr = relation;
            }

            var query = new RelationTupleQuery
            {
                Namespace = @namespace,
                Object = @object,
                Relation = rr
            };

            var tuples = await _relationTupleStore
                .GetRelationTuplesAsync(query, cancellationToken)
                .ConfigureAwait(false);

            var children = new List<SubjectTree>();

            var subjects = new HashSet<AclSubject>();

            foreach (var tuple in tuples)
            {
                if (tuple.Subject is AclSubjectSet subjectSet)
                {
                    rr = subjectSet.Relation;

                    if (rr == "...")
                    {
                        rr = relation;
                    }

                    var t = await this
                        .ExpandAsync(subjectSet.Namespace, subjectSet.Object, rr, depth - 1, cancellationToken)
                        .ConfigureAwait(false);

                    children.Add(new SubjectTree
                    {
                        Expression = new ComputedUsersetExpression
                        {
                            Namespace = @namespace,
                            Object = @object,
                            Relation = rr
                        },
                        Children = [t],
                        Result = t.Result
                    });

                    subjects.UnionWith(t.Result);
                }
                else
                {
                    var t = new SubjectTree
                    {
                        Expression = new ComputedUsersetExpression
                        {
                            Namespace = @namespace,
                            Object = @object,
                            Relation = rr,
                        },
                        Result = [tuple.Subject]
                    };

                    children.Add(t);
                    subjects.UnionWith(t.Result);
                }
            }

            return new SubjectTree
            {
                Expression = tupleToUsersetExpression,
                Children = children,
                Result = subjects
            };
        }

        #endregion Expand API
    }
}

We can use the Expand API to get the full... This is useful, if we need.

Finally let's write some Integration tests. The comments in the test are exhaustive, so we don't need to repeat them here. Enjoy! And feel free to add more complex tests and probably uncover cases I didn't think of yet.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// ...

namespace AclExperiments.Tests
{
    [TestClass]
    public class AclServiceTests : IntegrationTestBase
    {
        private AclService _aclService = null!;

        private INamespaceConfigurationStore _namespaceConfigurationStore = null!;
        private IRelationTupleStore _relationTupleStore = null!;

        protected override Task OnSetupBeforeCleanupAsync()
        {
            _aclService = _services.GetRequiredService<AclService>();
            _relationTupleStore = _services.GetRequiredService<IRelationTupleStore>();
            _namespaceConfigurationStore = _services.GetRequiredService<INamespaceConfigurationStore>();

            return Task.CompletedTask;
        }

        public override void RegisterServices(IServiceCollection services)
        {
            services.AddSingleton<ISqlConnectionFactory>((sp) =>
            {
                var connectionString = _configuration.GetConnectionString("ApplicationDatabase");

                if (connectionString == null)
                {
                    throw new InvalidOperationException($"No Connection String named 'ApplicationDatabase' found in appsettings.json");
                }

                return new SqlServerConnectionFactory(connectionString);
            });

            services.AddSingleton<AclService>();
            services.AddSingleton<INamespaceConfigurationStore, SqlNamespaceConfigurationStore>();
            services.AddSingleton<IRelationTupleStore, SqlRelationTupleStore>();
        }

        #region Check API

        ///// <summary>
        /// In this test we have one document "doc_1", and two users "user_1" and "user_2". "user_1" 
        /// has a "viewer" permission on "doc_1", because he has a direct relationto it. "user_2" has 
        /// a viewer permission on a folder "folder_1". 
        /// 
        /// The folder "folder_1" is a "parent" of the document, and thus "user_2" inherits the folders 
        /// permission through the computed userset.
        /// 
        /// Namespace |  Object       |   Relation    |   Subject             |
        /// ----------|---------------|---------------|-----------------------|
        /// doc       |   doc_1       |   viewer      |   user_1              |
        /// doc       |   doc_1       |   parent      |   folder:folder_1#... |
        /// folder    |   folder_1    |   viewer      |   user_2              |
        /// </summary>
        [TestMethod]
        public async Task CheckAsync_CheckUserPermissions()
        {
            // Arrange
            await _namespaceConfigurationStore.AddNamespaceConfigurationAsync("doc", 1, File.ReadAllText("Resources/doc.nsconfig"), 1, default);
            await _namespaceConfigurationStore.AddNamespaceConfigurationAsync("folder", 1, File.ReadAllText("Resources/folder.nsconfig"), 1, default);

            var aclRelations = new[]
            {
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "doc",
                            Id = "doc_1"
                        },
                        Relation = "owner",
                        Subject = new AclSubjectId
                        {
                            Id = "user_1"
                        }
                    },
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "doc",
                            Id = "doc_1"
                        },
                        Relation = "parent",
                        Subject = new AclSubjectId
                        {
                            Id = "folder:folder_1#..."
                        }
                    },
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "folder",
                            Id = "folder_1"
                        },
                        Relation = "viewer",
                        Subject = new AclSubjectId
                        {
                            Id = "user_2"
                        }
                    },
                };

            await _relationTupleStore.AddRelationTuplesAsync(aclRelations, 1, default);

            // Act
            var user_1_is_permitted = await _aclService.CheckAsync("doc", "doc_1", "viewer", "user_1", default);
            var user_2_is_permitted = await _aclService.CheckAsync("doc", "doc_1", "viewer", "user_2", default);
            var user_3_is_permitted = await _aclService.CheckAsync("doc", "doc_1", "viewer", "user_3", default);

            // Assert
            Assert.AreEqual(true, user_1_is_permitted);
            Assert.AreEqual(true, user_2_is_permitted);
            Assert.AreEqual(false, user_3_is_permitted);
        }

        #endregion Check API

        #region Expand API

        ///// <summary>
        /// In this test we have one document "doc_1", and two users "user_1" and "user_2". "user_1" 
        /// has a "viewer" permission on "doc_1", because he has a direct relationto it. "user_2" has 
        /// a viewer permission on a folder "folder_1". 
        /// 
        /// The folder "folder_1" is a "parent" of the document, and thus "user_2" inherits the folders 
        /// permission through the computed userset.
        /// 
        /// Namespace |  Object       |   Relation    |   Subject             |
        /// ----------|---------------|---------------|-----------------------|
        /// doc       |   doc_1       |   viewer      |   user_1              |
        /// doc       |   doc_1       |   parent      |   folder:folder_1#... |
        /// folder    |   folder_1    |   viewer      |   user_2              |
        /// </summary>
        [TestMethod]
        public async Task Expand_ExpandUsersetRewrites()
        {
            // Arrange
            await _namespaceConfigurationStore.AddNamespaceConfigurationAsync("doc", 1, File.ReadAllText("Resources/doc.nsconfig"), 1, default);
            await _namespaceConfigurationStore.AddNamespaceConfigurationAsync("folder", 1, File.ReadAllText("Resources/folder.nsconfig"), 1, default);

            var aclRelations = new[]
            {
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "doc",
                            Id = "doc_1"
                        },
                        Relation = "owner",
                        Subject = new AclSubjectId
                        {
                            Id = "user_1"
                        }
                    },
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "doc",
                            Id = "doc_1"
                        },
                        Relation = "parent",
                        Subject = new AclSubjectId
                        {
                            Id = "folder:folder_1#..."
                        }
                    },
                    new AclRelation
                    {
                        Object = new AclObject
                        {
                            Namespace = "folder",
                            Id = "folder_1"
                        },
                        Relation = "viewer",
                        Subject = new AclSubjectId
                        {
                            Id = "user_2"
                        }
                    },
                };

            await _relationTupleStore.AddRelationTuplesAsync(aclRelations, 1, default);

            // Act
            var subjectTree = await _aclService.ExpandAsync("doc", "doc_1", "viewer", 100, default);

            // Assert
            Assert.AreEqual(2, subjectTree.Result.Count);

            var sortedSubjectTreeResults = subjectTree.Result
                .Cast<AclSubjectId>()
                .OrderBy(x => x.Id)
                .ToList();

            Assert.AreEqual("user_1", sortedSubjectTreeResults[0].Id);
            Assert.AreEqual("user_2", sortedSubjectTreeResults[1].Id);
        }

        #endregion Expand API
    }
}

Conclusion

And that's it!

So let's see what we have developed in this article.

We can parse the Google Zanzibar Namespace Configuration language and implemented a basic version of the Check API and Expand API. We've learnt about ANTLR4 and have seen how to go from idea to implementation.

There's a lot more we could do... we could parallelize set operations to speed up the code significantly. We could cache Namespace Configurations so we don't waste cycles parsing them all over. We could cache relation tuples, to waste expensive database resources for querying the same data all over.

But it's an open source project. Let's work on it together! 👍

Yes, at the end this article was mostly just pasting code. It's because I wanted to finally get something out and didn't want to waste weeks on trying to find the "perfect abstraction". There's a lot more interesting code in the Git repository, enjoy!