authz

package module
v0.0.0-...-373af4f Latest Latest
Warning

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

Go to latest
Published: Sep 23, 2026 License: Apache-2.0 Imports: 5 Imported by: 3

README

authz

A leaner SpiceDB you link into your Go program.

Zanzibar semantics, SpiceDB's API shape and schema language, one Postgres, no second service. Grants live in your own database, so they can commit in the same transaction as the rows they protect and your row-level security can read them.

Status: pre-release. Every suite, SpiceDB's own corpus included, runs in CI on the in-memory store, on Postgres and against a real SpiceDB. Nothing is tagged yet: the postgres module pins the core by commit until something external needs a tag.

Why this exists

SpiceDB is a service. Running it means a second process, a second source of truth for grants, and a dual write every time your application changes membership. That is fine at Google's scale and painful in a single-binary application on one Postgres, where the natural home for a grant is a table the application already owns.

This repository keeps the parts of SpiceDB that matter to an embedder:

  • the relation model, usersets included (team:core#member), wildcards (user:*), and permissions built from union, intersection, exclusion and arrows;
  • the schema DSL, as a subset the parser rejects loudly when you leave it;
  • the API surface: relationship read, write and delete with preconditions, check, bulk check, expand, lookup resources, lookup subjects.

It leaves out what only a distributed service needs: caveats, consistency tokens, the Watch service, dispatch clusters, schema storage. One database gives one snapshot per read and one transaction per write, and that is the whole consistency story. The schema is code, kept by the program that embeds the engine.

Modules

Module Path Depends on Holds
authz github.com/matick-io/authz pgx and goose the Datastore contract and tuple types (root), schema with its DSL parser in schema/dsl, engine, the datastores in datastore/memory and datastore/postgres, the latter with its index and migrations; under internal, the index core (materialize), the conformance suite and the Postgres test helper
tests github.com/matick-io/authz/tests the library, plus the SpiceDB client the validation files, SpiceDB's corpus, the differential and conformance suites, the benchmarks

A datastore is the engine's only dependency and carries its own index, so an embedder imports the root, schema or schema/dsl, engine and one datastore package. go.work lets the tests module use the library in place.

Planned, not yet present: an api module offering the SpiceDB v1 gRPC surface over the core, and a cmd module building a standalone server from api and postgres. An embedder never needs either.

Embedding

sch, err := dsl.Parse(schemaText)                        // or schema.Build, in Go
if err := postgres.Migrate(ctx, pool); err != nil { ... } // or your deploy step
ds, err := postgres.New(pool, postgres.WithIndex(sch))   // the datastore with its index
svc, err := engine.New(ds, sch)                          // finds the index on the datastore
if err := svc.ValidateStored(ctx); err != nil { ... }    // the stored grants still fit the schema

ok, err := svc.CheckPermission(ctx,
    authz.ObjectRef{Type: "project", ID: "p1"}, "view",
    authz.SubjectRef{Object: authz.ObjectRef{Type: "user", ID: "alice"}})

The schema is code: dsl.Parse reads the SpiceDB DSL and schema.Build builds the same value without a parser. An engine is built for one schema; changing it means building a new engine, after ValidateStored says the grants already stored still fit. authz.ParseRelationship reads the SpiceDB tuple form, type:id#relation@type:id[#relation], and Relationship.String writes it. For tests, memory.New() is a datastore with the same contract and no database, and memory.WithIndex(sch) gives it an index.

docs/getting-started.md is the walkthrough: migrations from your deploy step, the checks at boot, a grant written in the transaction of the row it protects, and the view an application keeps over the permission sets so its SQL never names an authz table.

The API, next to SpiceDB's

SpiceDB v1 Here Notes
WriteSchema, ReadSchema absent the schema is code; ValidateStored checks the store against it
ReadRelationships ReadRelationships filter plus a limit; no cursor
WriteRelationships WriteRelationships create, touch, delete; preconditions; one transaction
WriteRelationshipsIn the same, inside a transaction the caller owns
ImportRelationships many creates in one pass, for loading a dataset
DeleteRelationships DeleteRelationships by filter, with preconditions
CheckPermission CheckPermission returns a bool; no caveats, so no conditional result
CheckBulkPermissions CheckBulkPermissions one snapshot, per-item errors; requests that differ only in the resource id share one walk
ExpandPermissionTree ExpandPermissionTree
LookupResources LookupResources sorted ids, caller-chosen limit, never truncated silently
LookupSubjects LookupSubjects exact wildcard algebra, with excluded ids
Watch absent the change log, Reader.Changes, is the same stream as a table
caveats, zed tokens absent

An unknown type, permission or subject type is an error, never a silent denial. A resolution that runs past the depth limit is an error, never a truncated answer. A cycle in the data, such as a folder whose parent is its own descendant, resolves as in Zanzibar: a path that returns to an open question contributes nothing, and the memo keeps no answer that rested on one.

Schema language

The SpiceDB DSL, restricted to:

definition <type> {
    relation <name>: <type> | <type>#<relation> | <type>:*
    permission <name> = <expr>
}

where an expression is a relation or permission on the same type, an arrow relation->name, or a combination with +, & and -. Mixing operators in one expression requires parentheses, as in SpiceDB. Arrows start from a relation and cannot walk a relation that allows a wildcard. nil is the empty set. Type names may carry a prefix/, as in SpiceDB. Comments use // and /* */.

Datastores and indexes

A datastore implements authz.Datastore: a View at one read snapshot and a Transact that commits only on success, over a Reader and Writer the engine calls, plus a change log. Every write records what came into being and what went, under a revision drawn in commit order, so an index or a mirror can follow it without missing a step.

The engine is correct on a bare datastore. Two optional indexes make it fast, after Zanzibar's Leopard and AuthZed's Materialize:

  • a NestingIndex answers questions about nested usersets in one call however deep the nesting, so only arrows count against the depth limit;
  • a PermissionIndex precomputes whole permissions, for the rewrites a set can represent: unions, references and arrows, with no wildcard, intersection or exclusion on the path.

A datastore that keeps an index returns it from Index (authz.Indexed), and engine.New reads it; nothing else is wired. The internal materialize package holds what every implementation shares: the derivation of closure and set rows from relationships, the comparison of a maintained index with a fresh derivation, and the follower that feeds an index from the changelog. The Postgres datastore keeps both in two tables beside the relationships, maintained in SQL inside the write's own transaction (WithIndex), or with some lag through Follow (WithAsyncIndex). Writes that touch the index serialise on an advisory lock; plain grants never take it. Verify compares the tables with a recomputation, Reindex rebuilds them, and a budget refuses a single write that would add more closure rows than allowed. The memory index is a materialize.Snapshot rebuilt on change: exact by construction, and the reference the Postgres index is held to.

Every datastore in this repository passes the conformance suite in internal/datastoretest.

Validation files

tests/samples/*.yaml hold scenarios as data, in SpiceDB's validation-file format: a schema, its relationships, assertions, and the subjects each object expands to. tests/corpus/spicedb/ is SpiceDB's own integration corpus, copied with its licence and notice; a file needing caveats, expiration or intersection arrows is skipped with the reason.

Every file runs on every kind: memory, memory with its index, Postgres bare, with the closure, with the permission sets, and SpiceDB itself when one is reachable. After the assertions and the validation block, a consistency pass checks every (resource, relation, subject) pair the file gives rise to, requires both lookups to return exactly the pairs that checked true, and compares the matrices across kinds. Add a scenario there rather than as Go code.

Beside the files: a differential test that writes random relationships to every kind and asks random questions, holding the indexed kinds to the walk and the walk to SpiceDB; conformance tables for what a write refuses and which schemas each side accepts; and scenario benchmarks with SpiceDB as the peer.

Testing

go test ./... ./tests/...

runs everything that needs no database. The Postgres suites read AUTHZ_TEST_DATABASE_URL, drop and recreate the authz schema and its migration table in that database, and skip when the variable is unset; point it at a scratch database:

AUTHZ_TEST_DATABASE_URL='postgres://user:pass@localhost:5432/authz_test?sslmode=disable' \
  go test ./... ./tests/...

AUTHZ_SPICEDB=label=host:port, comma separated for several instances, and AUTHZ_SPICEDB_KEY add SpiceDB as a kind, which is how CI runs. AUTHZ_DIFF_ROUNDS scales the differential test. The benchmarks are go test ./tests -run '^$' -bench BenchmarkScenarios; tests/cmd/benchreport lays their output out per scenario, and CI publishes the comparison to the gh-pages branch.

Roadmap

  • The api module, wire-compatible with SpiceDB v1 where the feature exists.
  • Version tags once something external pins one.

License

Apache-2.0; see LICENSE.

Documentation

Overview

Package authz is a lean relationship-based access control engine meant to be linked into a Go program. Its API mirrors SpiceDB's v1 PermissionsService: ReadRelationships, WriteRelationships and DeleteRelationships with preconditions, CheckPermission and CheckBulkPermissions, ExpandPermissionTree, LookupResources and LookupSubjects.

A Service is built from a datastore and a schema value (package schema). The schema is code: build it with schema.Build, or parse the SpiceDB DSL with package schema/dsl. ValidateStored checks at boot that the relationships already in the datastore still fit the schema in code.

AI: what is deliberately left out, and why. Caveats, zed tokens and the Watch service are absent: with one database behind the engine, every read runs at one snapshot and every write in one transaction, so there is no separate consistency vocabulary to expose. Schema storage is absent because an embedded engine's schema lives in the program that embeds it; a server built on this package persists schema text itself.

The package has no dependencies. It knows object types only as the strings the schema declares.

Index

Constants

View Source
const MaxObjectIDLength = 1024

MaxObjectIDLength is the longest object id accepted, as in SpiceDB.

View Source
const WildcardID = "*"

WildcardID is the subject id that stands for every object of its type.

Variables

View Source
var (
	ErrInvalidArgument    = errors.New("authz: invalid argument")
	ErrRelationshipExists = errors.New("authz: relationship already exists")
	ErrPreconditionFailed = errors.New("authz: precondition failed")
	ErrMaxDepthExceeded   = errors.New("authz: maximum resolution depth exceeded")
	ErrSchemaMismatch     = errors.New("authz: stored relationships do not conform to the schema")
)

Errors the API returns. Wrap-compatible: test with errors.Is.

Functions

func ValidName

func ValidName(s string) bool

ValidName reports whether s is a legal relation or permission name: lowercase, digits and underscores, starting with a letter, 1-64 chars.

func ValidObjectID

func ValidObjectID(s string) bool

ValidObjectID reports whether s is a legal object id (the SpiceDB charset).

func ValidTypeName

func ValidTypeName(s string) bool

ValidTypeName reports whether s is a legal object type name: a name as ValidName has it, optionally under namespaces separated by '/', as in SpiceDB (acme/user).

Types

type BulkCreator

type BulkCreator interface {
	CreateAll(ctx context.Context, rels []Relationship) error
}

BulkCreator is a fast path a datastore may offer for loading many relationships at once (engine.Service.ImportRelationships). CreateAll creates every relationship in one transaction, records one Change, runs the datastore's hooks once, and fails with ErrRelationshipExists if any is already present.

type Change

type Change struct {
	Revision Revision
	Updates  []RelationshipUpdate
}

Change is what one committed transaction did to the relationships: every relationship that came into being (OperationTouch) or went (OperationDelete), in write order. Writes that changed nothing, such as a touch of a present relationship, do not appear.

AI: this is SpiceDB's Watch stream as a value type. Indexes (the materialize module) and applications that mirror grants into their own tables consume it; a datastore must record it in the same transaction as the write so a consumer never sees a change before its relationships.

type CheckPermissionRequest

type CheckPermissionRequest struct {
	Resource   ObjectRef
	Permission string
	Subject    SubjectRef
}

CheckPermissionRequest is one item of a CheckBulkPermissions call.

func (CheckPermissionRequest) Validate

func (r CheckPermissionRequest) Validate() error

Validate checks the request is well formed; a wildcard cannot be checked.

type CheckPermissionResult

type CheckPermissionResult struct {
	Request       CheckPermissionRequest
	HasPermission bool
	Err           error
}

CheckPermissionResult answers one CheckPermissionRequest. Err is set when that item could not be evaluated; the other items are unaffected.

type Datastore

type Datastore interface {
	View(ctx context.Context, fn func(Reader) error) error
	Transact(ctx context.Context, fn func(Writer) error) error
}

Datastore is what the engine runs on. View runs fn at one read snapshot; Transact runs fn in one transaction and commits only if fn returns nil.

A datastore stores tuples and their change log; it knows nothing about nesting. An index that accelerates nested usersets is a NestingIndex fed by the change log, either inside the write transaction through a hook the datastore package offers, or afterwards by following Changes.

type Index

type Index interface {
	NestingIndex
	PermissionIndex
}

Index is what a datastore keeps beside its tuples to answer nesting and, when configured, whole permissions in one call: a NestingIndex that is also a PermissionIndex. A datastore that keeps one implements Indexed, and engine.New reads it; nothing else has to be wired.

type Indexed

type Indexed interface {
	Datastore
	Index() Index
}

Indexed is a Datastore that keeps an Index of its own. Index returns nil when none is configured, and the engine then walks nesting and evaluates every permission from the tuples.

type LookupSubjectsResult

type LookupSubjectsResult struct {
	SubjectIDs         []string
	Wildcard           bool
	ExcludedSubjectIDs []string
}

LookupSubjectsResult is the set of subjects of one type that hold a permission. When Wildcard is true every subject of the type holds it except ExcludedSubjectIDs, and SubjectIDs is empty.

type NestingIndex

type NestingIndex interface {
	// NestedRelationships is Relationships over the usersets q names with its
	// resource selector (ResourceType, ResourceIDs and Relation, all required)
	// and every userset nested within them. Subject fields and Limit apply as
	// in Relationships.
	NestedRelationships(ctx context.Context, r Reader, q RelationshipQuery) ([]Relationship, error)
	// NestedResourceIDs returns, sorted, the ids of every resourceType#relation
	// userset that holds any of the named subjects directly or through
	// nesting. A WildcardID among subjectIDs matches wildcard grants.
	NestedResourceIDs(ctx context.Context, r Reader, subjectType string, subjectIDs []string, subjectRelation, resourceType, relation string) ([]string, error)
	// NestedResourceIDsAmong is NestedResourceIDs restricted to the resource
	// ids in among: which of them hold any of the named subjects. It is how a
	// bulk check asks one question about many resources at once.
	NestedResourceIDsAmong(ctx context.Context, r Reader, subjectType string, subjectIDs []string, subjectRelation, resourceType, relation string, among []string) ([]string, error)
}

NestingIndex answers questions about nested usersets in one call however deep the nesting, so the engine never walks nesting when the datastore keeps one (Indexed). Each method receives the Reader of the resolution in progress so an index kept in the same database can answer at the same snapshot.

Without an index the engine walks nesting hop by hop and each hop counts against the depth limit; with one, only arrows do.

type ObjectRef

type ObjectRef struct {
	Type string
	ID   string
}

ObjectRef names one object: a type from the schema and an id.

func (ObjectRef) String

func (o ObjectRef) String() string

String renders the SpiceDB form, type:id.

func (ObjectRef) Validate

func (o ObjectRef) Validate() error

Validate checks the type and id are well formed.

type PermissionIndex

type PermissionIndex interface {
	// Materialized reports whether the index answers for resourceType#permission.
	Materialized(resourceType, permission string) bool
	// HasPermission reports whether subject holds the materialised permission
	// on resource.
	HasPermission(ctx context.Context, r Reader, resource ObjectRef, permission string, subject SubjectRef) (bool, error)
	// ResourcesWithPermission returns, sorted, the ids of every resourceType on
	// which subject holds the materialised permission.
	ResourcesWithPermission(ctx context.Context, r Reader, resourceType, permission string, subject SubjectRef) ([]string, error)
	// ResourcesWithPermissionAmong is ResourcesWithPermission restricted to
	// the resource ids in among, for a bulk check: which of them the subject
	// holds the permission on, sorted.
	ResourcesWithPermissionAmong(ctx context.Context, r Reader, resourceType, permission string, subject SubjectRef, among []string) ([]string, error)
	// SubjectsWithPermission returns, sorted, the ids of every subject of the
	// given type and relation that holds the materialised permission on
	// resource.
	SubjectsWithPermission(ctx context.Context, r Reader, resource ObjectRef, permission, subjectType, subjectRelation string) ([]string, error)
}

PermissionIndex precomputes whole permissions: for each resource and each materialised permission, the usersets whose members hold it. It is Materialize's permission sets. The engine consults it for a permission the index reports as Materialized and evaluates the rewrite for every other, so an index may cover any subset of the schema. As with NestingIndex, each method receives the Reader of the resolution in progress.

AI: an index can only materialise a permission whose rewrite is unions, references and arrows over relations that allow neither wildcards nor usersets of a permission; intersections and exclusions have no set form. Those are Materialize's constraints too, and the index refuses anything else at construction rather than answering it wrongly.

type PermissionTree

type PermissionTree struct {
	Resource  ObjectRef
	Relation  string
	Operation TreeOperation
	Subjects  []SubjectRef      // TreeLeaf only
	Children  []*PermissionTree // set operations only; Exclusion has exactly two
}

PermissionTree is what ExpandPermissionTree returns: the rewrite of one permission on one resource, one level of usersets deep, as SpiceDB expands it.

type Precondition

type Precondition struct {
	Operation PreconditionOperation
	Filter    RelationshipFilter
}

Precondition is checked inside the write transaction before any update is applied; a failure aborts the whole write with ErrPreconditionFailed.

func (Precondition) Validate

func (p Precondition) Validate() error

Validate checks the precondition names an operation and a well-formed filter.

type PreconditionOperation

type PreconditionOperation int

PreconditionOperation says what a Precondition requires of its filter.

const (
	// PreconditionMustMatch requires at least one relationship to match.
	PreconditionMustMatch PreconditionOperation = iota + 1
	// PreconditionMustNotMatch requires no relationship to match.
	PreconditionMustNotMatch
)

type Reader

type Reader interface {
	Relationships(ctx context.Context, q RelationshipQuery) ([]Relationship, error)
	RelationshipKinds(ctx context.Context) ([]RelationshipKind, error)
	// Changes returns committed changes after the given revision in revision
	// order, at most limit of them (0 for no limit). A caller has caught up
	// when it gets back fewer than it asked for.
	Changes(ctx context.Context, after Revision, limit int) ([]Change, error)
}

Reader is the read half of a datastore, always observed at one snapshot.

type Relationship

type Relationship struct {
	Resource ObjectRef
	Relation string
	Subject  SubjectRef
}

Relationship is one tuple: Subject holds Relation on Resource.

func ParseRelationship

func ParseRelationship(s string) (Relationship, error)

ParseRelationship reads the SpiceDB tuple form Relationship.String produces.

func (Relationship) IsNesting

func (r Relationship) IsNesting() bool

IsNesting reports whether the relationship nests one userset within another: its subject is a userset. Indexes care about exactly these.

func (Relationship) String

func (r Relationship) String() string

String renders the SpiceDB tuple form, type:id#relation@type:id[#relation].

func (Relationship) Validate

func (r Relationship) Validate() error

Validate checks every part of the relationship is well formed. It does not consult a schema; the engine does that.

type RelationshipFilter

type RelationshipFilter struct {
	ResourceType string
	ResourceID   string
	Relation     string
	Subject      *SubjectFilter
}

RelationshipFilter selects relationships. ResourceType is required at the API; every other field narrows the selection when set.

func (RelationshipFilter) Query

func (f RelationshipFilter) Query(limit int) RelationshipQuery

Query renders the filter as the datastore-level selection.

func (RelationshipFilter) Validate

func (f RelationshipFilter) Validate() error

Validate checks the filter is well formed; ResourceType is required.

type RelationshipKind

type RelationshipKind struct {
	ResourceType    string
	Relation        string
	SubjectType     string
	SubjectRelation string
}

RelationshipKind is one distinct (resource type, relation, subject type, subject relation) combination present in the datastore.

type RelationshipQuery

type RelationshipQuery struct {
	ResourceType    string
	ResourceIDs     []string
	Relation        string
	SubjectType     string
	SubjectIDs      []string
	SubjectRelation *string
	Limit           int
}

RelationshipQuery is the datastore-level selection. Unlike RelationshipFilter it takes id sets, so a resolver can ask about many objects in one round trip, and no field is required.

type RelationshipUpdate

type RelationshipUpdate struct {
	Operation    UpdateOperation
	Relationship Relationship
}

RelationshipUpdate is one element of a WriteRelationships call, and one element of a Change, where Operation is OperationTouch for a relationship that came into being and OperationDelete for one that went.

type Revision

type Revision uint64

Revision numbers committed write transactions in commit order. Zero is before the first.

type SubjectFilter

type SubjectFilter struct {
	Type     string
	ID       string
	Relation *string
}

SubjectFilter narrows a RelationshipFilter by subject. A nil Relation matches any subject relation; a pointer to "" matches only plain object subjects.

type SubjectRef

type SubjectRef struct {
	Object   ObjectRef
	Relation string
}

SubjectRef is an object, or a userset (every subject holding Relation on the object) when Relation is set.

func (SubjectRef) String

func (s SubjectRef) String() string

String renders the SpiceDB form, type:id or type:id#relation.

func (SubjectRef) Validate

func (s SubjectRef) Validate() error

Validate checks the subject is well formed; a wildcard may carry no relation.

type TreeOperation

type TreeOperation int

TreeOperation labels a PermissionTree node.

const (
	// TreeLeaf holds the subjects a relation directly names.
	TreeLeaf TreeOperation = iota + 1
	TreeUnion
	TreeIntersection
	TreeExclusion
)

type UpdateOperation

type UpdateOperation int

UpdateOperation is what a RelationshipUpdate does to its relationship.

const (
	// OperationCreate writes the relationship and fails with
	// ErrRelationshipExists when it is already present.
	OperationCreate UpdateOperation = iota + 1
	// OperationTouch writes the relationship if absent and succeeds either way.
	OperationTouch
	// OperationDelete removes the relationship and succeeds either way.
	OperationDelete
)

type Writer

type Writer interface {
	Reader
	// Create fails with ErrRelationshipExists when the relationship is present.
	Create(ctx context.Context, r Relationship) error
	Touch(ctx context.Context, r Relationship) error
	Delete(ctx context.Context, r Relationship) error
	DeleteMatching(ctx context.Context, q RelationshipQuery) (int64, error)
}

Writer is a Reader inside one transaction.

Directories

Path Synopsis
datastore
memory
Package memory is an in-process authz.Datastore for tests.
Package memory is an in-process authz.Datastore for tests.
postgres
Package postgres is the authz.Datastore on Postgres, via pgx.
Package postgres is the authz.Datastore on Postgres, via pgx.
internal
datastoretest
Package datastoretest is the conformance suite every authz.Datastore must pass.
Package datastoretest is the conformance suite every authz.Datastore must pass.
materialize
Package materialize is the generic core of the indexes that precompute what the engine would otherwise walk: the closure of userset nesting, after Zanzibar's Leopard, and permission sets, after AuthZed's Materialize.
Package materialize is the generic core of the indexes that precompute what the engine would otherwise walk: the closure of userset nesting, after Zanzibar's Leopard, and permission sets, after AuthZed's Materialize.
pgtest
Package pgtest serialises test packages that share one scratch database.
Package pgtest serialises test packages that share one scratch database.
postgres module
Package schema is the model the engine runs on: object types, the relations they store, and the permissions computed from them.
Package schema is the model the engine runs on: object types, the relations they store, and the permissions computed from them.
dsl
Package dsl parses the SpiceDB schema DSL into a schema.Schema.
Package dsl parses the SpiceDB schema DSL into a schema.Schema.

Jump to

Keyboard shortcuts

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