zanzigo

package module
v0.0.0-...-1cdd71b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 12, 2024 License: Apache-2.0 Imports: 7 Imported by: 0

README

Go Reference Go Report Card

zanzigo

The zanzigo-library provides building blocks for creating your own Zanzibar-esque authorization service. If you are unfamiliar with Google's Zanzibar, check out zanzibar.academy by Auth0.

This respository also includes a server-implementation using gRPC/ConnectRPC.

Install

go get -u github.com/trevex/zanzigo

Getting started

First you will need an authorization-model, which defines the ruleset of your relation-based access control. The structure of ObjectMap is inspired by warrant.

model, err := zanzigo.NewModel(zanzigo.ObjectMap{
    "user": zanzigo.RelationMap{},
	"group": zanzigo.RelationMap{
		"member": zanzigo.Rule{},
	},
	"folder": zanzigo.RelationMap{
		"owner": zanzigo.Rule{},
		"editor": zanzigo.Rule{
			InheritIf: "owner",
		},
		"viewer": zanzigo.Rule{
			InheritIf: "editor",
		},
	},
	"doc": zanzigo.RelationMap{
		"parent": zanzigo.Rule{},
		"owner": zanzigo.Rule{
			InheritIf:    "owner",
			OfType:       "folder",
			WithRelation: "parent",
		},
		"editor": zanzigo.AnyOf(
			zanzigo.Rule{InheritIf: "owner"},
			zanzigo.Rule{
				InheritIf:    "editor",
				OfType:       "folder",
				WithRelation: "parent",
			},
		),
		"viewer": zanzigo.AnyOf(
			zanzigo.Rule{InheritIf: "editor"},
			zanzigo.Rule{
				InheritIf:    "viewer",
				OfType:       "folder",
				WithRelation: "parent",
			},
		),
	},
})

Next, you will need a storage-implementation, check out the Storage-section of this document for details. For simplicity, let's use Postgres and assume databaseURL is defined:

if err := postgres.RunMigrations(databaseURL); err != nil {
    // ...
}

storage, err := postgres.NewPostgresStorage(databaseURL)
if err != nil {
    // ...
}

To traverse the authorization-model and check a permission, you need a resolver:

resolver, err := zanzigo.NewResolver(model, storage, 16)
if err != nil {
    // ...
}

// Alternatively construct zanzigo.Tuple directly instead of using the string-format from the paper.
result, err := resolver.Check(context.Background(), zanzigo.TupleString("doc:mydoc#viewer@user:myuser"))

That is it!

For more thorough examples, check out the examples/-folder in the repository. Details regarding the storage- and resolver-implementation can be found below or in the generated documentation.

Storage

Postgres

Make sure the database migrations ran before creating the storage-backend:

if err := postgres.RunMigrations(databaseURL); err != nil {
    log.Fatalf("Could not migrate db: %s", err)
}

The Postgres implementation comes in two flavors. One is using queries:

storage, err := postgres.NewPostgresStorage(databaseURL)

The queries are prepared and executed at the same time using UNION ALL, so no parallelism of the resolver is required. The database will traverse all checks of a certain depth at the same time for us.

The second flavor is using stored Postgres-functions:

storage, err := postgres.NewPostgresStorage(databaseURL, postgres.UseFunctions())

This storage-implementation prepares Postgres-functions, which will traverse the authorization-model. This means only a single query is issues calling a particular function and directly return the result of the check.

Both flavors have advantages and disadvantages, but are compatible, so swapping is possible at any time.

SQLite3

Alternatively SQLite3 can be used as follow:

dbfile := "./sqlite.db" # URL parameters from mattn/go-sqlite3 can be used
if err := sqlite3.RunMigrations(dbfile); err != nil {
    log.Fatalf("Could not migrate db: %s", err)
}
storage, err := sqlite3.NewSQLiteStorage(dbfile)
Which storage implementation to use?

This really depends on which underlying database will fulfill your needs, so familiarize yourself with their trade-offs using the upstream documentation.

You might also want to consider the following on day 2:

  1. You can use Litestream or LiteFS to scale beyond a single replica, e.g. multiple read-replicas of SQLite3.
  2. Both function and query-based flavors of the Postgres implementation should work with Neon, while only query-based approach is expected to be compatible with CockroachDB.

Benchmark

A benchmark was undertaken on Google Cloud. All the code and the raw results can be found in ./bench.

The code also includes some micro-benchmarks, that are run locally, which result in the following results on my laptop:

goos: linux
goarch: amd64
pkg: github.com/trevex/zanzigo/storage/postgres
cpu: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz
BenchmarkPostgres
BenchmarkPostgres/queries
BenchmarkPostgres/queries/indirect_nested_4
BenchmarkPostgres/queries/indirect_nested_4-8         	    5533	    190032 ns/op	   20151 B/op	     112 allocs/op
BenchmarkPostgres/queries/direct
BenchmarkPostgres/queries/direct-8                    	   19274	     61679 ns/op	    5096 B/op	      40 allocs/op
BenchmarkPostgres/functions
BenchmarkPostgres/functions/indirect_nested_4
BenchmarkPostgres/functions/indirect_nested_4-8       	    9423	    127550 ns/op	     802 B/op	      13 allocs/op
BenchmarkPostgres/functions/direct
BenchmarkPostgres/functions/direct-8                  	   19890	     60806 ns/op	     801 B/op	      13 allocs/op
PASS
goos: linux
goarch: amd64
pkg: github.com/trevex/zanzigo/storage/sqlite3
cpu: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz
BenchmarkSQLite3
BenchmarkSQLite3/queries
BenchmarkSQLite3/queries/indirect_nested_4
BenchmarkSQLite3/queries/indirect_nested_4-8         	   22962	     52081 ns/op	   13294 B/op	      70 allocs/op
BenchmarkSQLite3/queries/direct
BenchmarkSQLite3/queries/direct-8                    	   65703	     18410 ns/op	    3077 B/op	      26 allocs/op
PASS

Development

Persistent Postgres

During development it might make sense to persist data created by tests. You can specify a different database to use, by setting TEST_POSTGRES_DATABASE_URL environment variable.

For example start a postgres database in docker and run tests against it as follows:

docker run --name postgres -e POSTGRES_USER=zanzigo -e POSTGRES_PASSWORD=zanzigo -e POSTGRES_DB=zanzigo -e listen_addresses='*' --net=host -d postgres:15.4
TEST_POSTGRES_DATABASE_URL="postgres://zanzigo:zanzigo@127.0.0.1:5432/zanzigo?sslmode=disable" go test -v ./...

If you want to inspect the database it might be helpful to run pgAdmin4:

docker run -d --name pgadmin  -e PGADMIN_DEFAULT_EMAIL='test@test.local' -e PGADMIN_DEFAULT_PASSWORD=secret -e PGADMIN_CONFIG_SERVER_MODE='False' -e PGADMIN_LISTEN_PORT=8080 --net=host dpage/pgadmin4

Documentation

Overview

The zanzigo-package provides building blocks for creating your own Zanzibar-esque authorization service.

Your start by defining an authorization model:

model, err := zanzigo.NewModel(zanzigo.ObjectMap{
	"user": zanzigo.RelationMap{},
	"group": zanzigo.RelationMap{
		"member": zanzigo.Rule{},
	},
	"folder": zanzigo.RelationMap{
		"owner": zanzigo.Rule{},
		"editor": zanzigo.Rule{
			InheritIf: "owner",
		},
		"viewer": zanzigo.Rule{
			InheritIf: "editor",
		},
	},
	"doc": zanzigo.RelationMap{
		"parent": zanzigo.Rule{},
		"owner": zanzigo.Rule{
			InheritIf:    "owner",
			OfType:       "folder",
			WithRelation: "parent",
		},
		"editor": zanzigo.AnyOf(
			zanzigo.Rule{InheritIf: "owner"},
			zanzigo.Rule{
				InheritIf:    "editor",
				OfType:       "folder",
				WithRelation: "parent",
			},
		),
		"viewer": zanzigo.AnyOf(
			zanzigo.Rule{InheritIf: "editor"},
			zanzigo.Rule{
				InheritIf:    "viewer",
				OfType:       "folder",
				WithRelation: "parent",
			},
		),
	},
})

With a storage-implementation available, tuples can be inserted (check whitepaper for notation or altenatively construct Tuple directly):

// We add user 'myuser' to the group 'mygroup'
_ = storage.Write(ctx, zanzigo.TupleString("group:mygroup#member@user:myuser"))
// The document 'mydoc' is in folder 'myfolder'
_ = storage.Write(ctx, zanzigo.TupleString("doc:mydoc#parent@folder:myfolder"))
// Members of group 'mygroup' are viewers of folder 'myfolder'
_ = storage.Write(ctx, zanzigo.TupleString("folder:myfolder#viewer@group:mygroup#member"))

Using a Resolver permissions can be checked by traversing the tuples using the inferred rules of the authorization-model:

resolver, _ := zanzigo.NewResolver(model, storage, 16)
// Based on the indirect permission through the group's permissions on the folder,
// the following would return 'true':
result, _ := resolver.Check(context.Background(), zanzigo.TupleString("doc:mydoc#viewer@user:myuser"))

For more examples, check the repository. You may find additional information in the README.

Index

Constants

This section is empty.

Variables

View Source
var (
	// TODO: doc
	ErrTypeUnknown = errors.New("Unknown type used in tuple")
	// TODO: doc
	ErrRelationUnknown = errors.New("Unknown relation used in tuple")
)
View Source
var (
	CursorStart = uuid.Must(uuid.FromString("ffffffff-ffff-ffff-ffff-ffffffffffff"))
)
View Source
var EmptyTuple = Tuple{}
View Source
var (
	// Returned by Storage-implementation for example if a given Read did not return a result.
	ErrNotFound = errors.New("not found")
)

Functions

This section is empty.

Types

type Check

type Check struct {
	Tuple    Tuple
	Userdata Userdata
	Ruleset  []InferredRule
}

A request to check a given Tuple with the inferred ruleset and the prepared Userdata.

type InferredRule

type InferredRule struct {
	Kind                  Kind
	Object                string
	Subject               string
	Relations             []string
	WithRelationToSubject []string
}

An InferredRule is the result of [Rule]s being prepared when a model is instiantiated via NewModel. It is a flattened and preprocessed form of rules that is directly used to interact with Storage-implementations. It merges relations, splits them if multiple [Kind]s apply and it is important to note, that they are also sorted by [InferredRule.Kind] in [Model.InferredRules].

type InferredRuleMap

type InferredRuleMap map[string]map[string][]InferredRule

A map of objects to map of relations to sorted rulesets of InferredRule.

type Kind

type Kind int

[InferredRule]s are precomputed for a given Model based on the ObjectMap and specified [Rule]s. Several types of rules exist, which require different traversals of the authorization model.

const (
	// Should never be used, but is used as a default value to make sure [Kind] is always specified.
	KindUnknown Kind = iota
	// A direct relationship between object and subject exists, user has direct access to object.
	KindDirect
	// A direct relationship between object and usersets exists, e.g. user is part of a group with access to the desired resource.
	KindDirectUserset
	// An indirect relationship between object and subject exists through another nested object, e.g. user has access to a folder containing a document.
	KindIndirect
)

type MarkedTuple

type MarkedTuple struct {
	Tuple
	CheckIndex int
	RuleIndex  int
}

A tuple with additional information which Check and which InferredRule from the Check resulted in this tuple. This is used by the Resolver to connect resulting tuples to the original instructions to deduct subsequent actions.

type Model

type Model struct {
	InferredRules InferredRuleMap
	// contains filtered or unexported fields
}

A Model is the authorization model created from an ObjectMap. During creation the model-definition provided by an ObjectMap is computed into a lower-lever ruleset of [InferredRule]s.

func NewModel

func NewModel(objects ObjectMap) (*Model, error)

NewModel checks the ObjectMap for correctness and will infer the rules and prepare them for check-resolution.

func (*Model) IsValid

func (m *Model) IsValid(t Tuple) bool

TODO: add input validation to examples and document properly

func (*Model) RulesetFor

func (m *Model) RulesetFor(object, relation string) []InferredRule

Rules are sorted direct first, indirect last. Returns the Rulset for a particular object-type and relation. If the object-type or relation does not exist, nil will be returned.

type ObjectMap

type ObjectMap map[string]RelationMap

The ObjectMap is the primary input of a model and is required to create a model and compute the inferred rules. The key is expected to be the object-type.

The structure is inspired by warrant.

type Pagination

type Pagination struct {
	Limit  int
	Cursor uuid.UUID // We use UUIDv7, so we can directly use it as cursor as it is sequential
}

type RelationMap

type RelationMap map[string]Rule

RelationMap maps relationship-names to rules.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

A Resolver uses a Model and Storage-implementation to execute relationship checks.

func NewResolver

func NewResolver(model *Model, storage Storage, maxDepth int) (*Resolver, error)

NewResolver creates a new resolver for the particular Model using the designated Storage-implementation. The main purpose of a Resolver is to traverse the ReBAC-policies and check whether a Tuple is authorized or not. During creation the inferred rules of the Model are used to precompute storage-specific Userdata that can be used to speed up checks (when calling Storage.QueryChecks internally). When Check is called the Userdata is passed on to the Storage-implementation as part of the Check.

maxDepth limits the depth of the traversal of the authorization-model during checks.

func (*Resolver) Check

func (r *Resolver) Check(ctx context.Context, t Tuple) (bool, error)

Checks whether the relationship stated by Tuple t is true.

func (*Resolver) RulesetFor

func (r *Resolver) RulesetFor(object, relation string) []InferredRule

Returns an inferred ruleset for the given object-type and relation.

type Rule

type Rule struct {
	// If InheritIf is set the relation is inheritable from the specified relation,
	// e.g. `viewer` relationship inherited if subject is `editor`.
	InheritIf string `json:"inheritIf"`
	// If OfType is set, the relation specified by InheritIf needs to exist between the subject and the specified object-type.
	// This requires WithRelation to be set as there needs to be a WithRelation between object and an instance of OfType.
	OfType string `json:"ofType,omitempty"`
	// WithRelation defines, which relation needs to exist between OfType and the object to inherit the relationship status.
	WithRelation string `json:"withRelation,omitempty"`
	// Rules should not be set directly, but are public to make serializing rules easier.
	// The purpose of Rules is to allow combining rules. This should be done with functions such as [AnyOf] to properly mark the rule.
	Rules []Rule `json:"rules,omitempty"`
}

A Rule is associated with a relationship of an authorization Model and defines when the requirement of the relationship is met. Without any fields specified the Rule will still be met by direct relations between an object and a subject.

func AnyOf

func AnyOf(rules ...Rule) Rule

AnyOf combines multiple rules into one rule, which when applied to a relation will result in a relation when any of the specified rules applies.

type Storage

type Storage interface {
	// Creates the [Tuple] t or errors, if creations fails.
	Write(ctx context.Context, t Tuple) error
	// Reads the specified [Tuple]. As all fields need to be known to read it, the UUID is returned.
	// If the tuple was not found, [ErrNotFound] is returned.
	Read(ctx context.Context, t Tuple) (uuid.UUID, error)

	List(ctx context.Context, f Tuple, p Pagination) ([]Tuple, uuid.UUID, error)

	// PrepareRuleset takes an object-type and relation with the inferred ruleset and prepares
	// the storage-implementation for subsequent checks by optionally returning [Userdata].
	PrepareRuleset(object, relation string, ruleset []InferredRule) (Userdata, error)
	// QueryChecks will retrieve matching tuples for all the checks if they exist.
	// The tuples are marked with the CheckIndex and RuleIndex to be able to identify precisely, the associated ruleset.
	// Returned marked tuples are sorted by RuleIndex as rulesets always begin with direct-relationships.
	// This allows returning as soon as possible by minimizing the rules to be checked for matches.
	QueryChecks(ctx context.Context, checks []Check) ([]MarkedTuple, error)

	Close() error
}

Storage provides simple CRUD operations for persistence as well as more complex methods required to permission checks as performant as possible.

type Tuple

type Tuple struct {
	/// ⟨object⟩ ::= ⟨namespace⟩‘:’⟨object id⟩
	ObjectType string `json:"object_type"`
	ObjectID   string `json:"object_id"`
	/// ⟨relation⟩
	ObjectRelation string `json:"relation"`
	/// ⟨user⟩ ::= ⟨namespace⟩‘:’⟨user id⟩ | ⟨userset⟩
	SubjectType string `json:"user_type"`
	SubjectID   string `json:"user_id"`
	/// ⟨userset⟩ ::= ⟨object⟩‘#’⟨relation⟩
	SubjectRelation string `json:"user_relation"`
}

⟨tuple⟩ ::= ⟨object⟩‘#’⟨relation⟩‘@’⟨user⟩

func TupleString

func TupleString(s string) Tuple

Parses a string in Zanzibar-format and returns the resulting tuple. If the string is malformed, EmptyTuple will be returned.

Examples for input are: 'doc:mydoc#viewer@user:myuser' or 'doc:mydoc#editor@group:mygroup#member'

type Userdata

type Userdata any

Marker interface for Userdata returned by a storage-implementation. Primary purpose is to prepare for a given inferred ruleset with Storage.PrepareRuleset and supply the Userdata to subsequent Storage.QueryChecks involving the associated ruleset.

type UserdataMap

type UserdataMap map[string]map[string]Userdata

A map of object-types to relations to Userdata.

Directories

Path Synopsis
api
cmd
examples

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL