diff --git a/CHANGELOG.md b/CHANGELOG.md index b3d76ab233..8a86ee2b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Cache: switch to [otter](https://maypok86.github.io/otter/) as the primary cache implementation (https://github.com/authzed/spicedb/pull/3112) +- Embedded: add `pkg/embedded`, an in-process library for running permission checks against a datastore via the dispatch engine, without standing up a gRPC server (https://github.com/authzed/spicedb/pull/3166) +- Caveats: compiled caveats (and their CEL environments) are now cached per schema version rather than rebuilt on every check, reducing check cost for schemas with many caveats (https://github.com/authzed/spicedb/pull/3166) +- Datastore: schema-derived artifacts (e.g. compiled caveats, type systems) are now cached on the stored schema and share its lifetime, so they are rebuilt only when the schema changes (https://github.com/authzed/spicedb/pull/3166) ### Fixed - The watching schema cache (`--enable-experimental-watchable-schema-cache`) no longer enters permanent fallback on transient watch errors. A new supervisor restarts the watch cycle with bounded exponential backoff and only treats caller-driven cancellation or unsupported-watch as terminal (https://github.com/authzed/spicedb/pull/3134) diff --git a/internal/caveats/run.go b/internal/caveats/run.go index fbb5a3a189..28bfc509c8 100644 --- a/internal/caveats/run.go +++ b/internal/caveats/run.go @@ -11,6 +11,7 @@ import ( "github.com/authzed/spicedb/internal/telemetry/otelconv" "github.com/authzed/spicedb/pkg/caveats" caveattypes "github.com/authzed/spicedb/pkg/caveats/types" + "github.com/authzed/spicedb/pkg/datalayer" "github.com/authzed/spicedb/pkg/datastore" "github.com/authzed/spicedb/pkg/genutil/mapz" core "github.com/authzed/spicedb/pkg/proto/core/v1" @@ -51,11 +52,24 @@ func RunSingleCaveatExpression( return runner.RunCaveatExpression(ctx, expr, context, reader, debugOption) } +// cachedSchemaProvider is satisfied (structurally) by SchemaReaders backed by a unified +// stored schema. It exposes the shared, per-schema-version CachedSchema, which hosts +// schema-derived caches such as the compiled-caveat cache. +type cachedSchemaProvider interface { + CachedSchema() *datalayer.CachedSchema +} + // CaveatRunner is a helper for running caveats, providing a cache for deserialized caveats. type CaveatRunner struct { caveatTypeSet *caveattypes.TypeSet caveatDefs map[string]*core.CaveatDefinition deserializedCaveats map[string]*caveats.CompiledCaveat + + // schemaCache, when non-nil, is a compiled-caveat cache tied to the stored schema + // (and thus shared across checks and invalidated on schema change). It is discovered + // from the reader on first use. When nil, deserializedCaveats provides per-runner + // caching only (the legacy behavior). + schemaCache *CompiledCaveatCache } // NewCaveatRunner creates a new CaveatRunner. @@ -91,6 +105,17 @@ func (cr *CaveatRunner) PopulateCaveatDefinitionsForExpr(ctx context.Context, ex ctx, span := tracer.Start(ctx, "PopulateCaveatDefinitions") defer span.End() + // If the reader is backed by a unified stored schema, use the compiled-caveat cache + // tied to that schema version so deserialization (which rebuilds the CEL environment) + // is paid once per schema rather than once per check. + if cr.schemaCache == nil { + if provider, ok := reader.(cachedSchemaProvider); ok { + if cached := provider.CachedSchema(); cached != nil { + cr.schemaCache = CompiledCaveatCacheFor(cached) + } + } + } + // Collect all referenced caveat definitions in the expression. caveatNames := mapz.NewSet[string]() collectCaveatNames(expr, caveatNames) @@ -138,12 +163,23 @@ func (cr *CaveatRunner) get(caveatDefName string) (*core.CaveatDefinition, *cave return caveat, deserialized, nil } - parameterTypes, err := caveattypes.DecodeParameterTypes(cr.caveatTypeSet, caveat.ParameterTypes) - if err != nil { - return nil, nil, err + compile := func() (*caveats.CompiledCaveat, error) { + parameterTypes, err := caveattypes.DecodeParameterTypes(cr.caveatTypeSet, caveat.ParameterTypes) + if err != nil { + return nil, err + } + return caveats.DeserializeCaveatWithTypeSet(cr.caveatTypeSet, caveat.SerializedExpression, parameterTypes) } - justDeserialized, err := caveats.DeserializeCaveatWithTypeSet(cr.caveatTypeSet, caveat.SerializedExpression, parameterTypes) + // Prefer the schema-tied cache (shared across checks) when available; fall back to + // per-runner compilation otherwise. + var justDeserialized *caveats.CompiledCaveat + var err error + if cr.schemaCache != nil { + justDeserialized, err = cr.schemaCache.GetOrCompile(caveatDefName, compile) + } else { + justDeserialized, err = compile() + } if err != nil { return caveat, nil, err } diff --git a/internal/caveats/schemacache.go b/internal/caveats/schemacache.go new file mode 100644 index 0000000000..bfb962ecec --- /dev/null +++ b/internal/caveats/schemacache.go @@ -0,0 +1,49 @@ +package caveats + +import ( + "sync" + + "github.com/authzed/spicedb/pkg/caveats" + "github.com/authzed/spicedb/pkg/datalayer" + "github.com/authzed/spicedb/pkg/spiceerrors" +) + +// compiledCaveatCacheKey identifies the schema-derived cache of compiled (deserialized) +// caveats. The cache is tied to a single stored-schema version (via datalayer.CachedSchema) +// and is discarded when the schema changes. +var compiledCaveatCacheKey = datalayer.NewDerivedCacheKey("caveats.compiled") + +func init() { + if err := datalayer.RegisterDerivedCache(compiledCaveatCacheKey, func() any { return &CompiledCaveatCache{} }); err != nil { + spiceerrors.MustPanicf("failed to register compiled caveat cache: %v", err) + } +} + +// CompiledCaveatCache caches deserialized caveats (which embed a built CEL environment) by +// caveat name, for a single schema version. Deserializing a caveat rebuilds its CEL +// environment, which is expensive; caching it on the (shared) stored schema avoids paying +// that cost on every check. +type CompiledCaveatCache struct { + m sync.Map // map[string]*caveats.CompiledCaveat +} + +// GetOrCompile returns the cached compiled caveat for name, or invokes compile and caches +// the result. compile is only called on a miss; concurrent misses may call compile more +// than once but only one result is retained. +func (c *CompiledCaveatCache) GetOrCompile(name string, compile func() (*caveats.CompiledCaveat, error)) (*caveats.CompiledCaveat, error) { + if v, ok := c.m.Load(name); ok { + return v.(*caveats.CompiledCaveat), nil + } + compiled, err := compile() + if err != nil { + return nil, err + } + actual, _ := c.m.LoadOrStore(name, compiled) + return actual.(*caveats.CompiledCaveat), nil +} + +// CompiledCaveatCacheFor returns the compiled-caveat cache tied to the given cached schema, +// building it lazily on first access. +func CompiledCaveatCacheFor(s *datalayer.CachedSchema) *CompiledCaveatCache { + return datalayer.GetDerivedCache[*CompiledCaveatCache](s, compiledCaveatCacheKey) +} diff --git a/internal/caveats/schemacache_test.go b/internal/caveats/schemacache_test.go new file mode 100644 index 0000000000..de2ab4c6f8 --- /dev/null +++ b/internal/caveats/schemacache_test.go @@ -0,0 +1,46 @@ +package caveats + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/authzed/spicedb/pkg/caveats" + caveattypes "github.com/authzed/spicedb/pkg/caveats/types" +) + +func TestCompiledCaveatCacheGetOrCompile(t *testing.T) { + env := caveats.NewEnvironmentWithTypeSet(caveattypes.Default.TypeSet) + compiled, err := caveats.CompileCaveatWithName(env, "1 == 1", "test") + require.NoError(t, err) + + c := &CompiledCaveatCache{} + calls := 0 + compile := func() (*caveats.CompiledCaveat, error) { + calls++ + return compiled, nil + } + + // First access compiles; subsequent accesses return the cached instance without recompiling. + got1, err := c.GetOrCompile("a", compile) + require.NoError(t, err) + require.Same(t, compiled, got1) + + got2, err := c.GetOrCompile("a", compile) + require.NoError(t, err) + require.Same(t, compiled, got2) + require.Equal(t, 1, calls, "compile should be invoked once per name") + + // Distinct names compile independently. + _, err = c.GetOrCompile("b", compile) + require.NoError(t, err) + require.Equal(t, 2, calls) + + // Errors propagate and are not cached. + boom := errors.New("boom") + _, err = c.GetOrCompile("c", func() (*caveats.CompiledCaveat, error) { + return nil, boom + }) + require.ErrorIs(t, err, boom) +} diff --git a/internal/services/v1/permissions_test.go b/internal/services/v1/permissions_test.go index acf2aa9ce1..13b460cdfd 100644 --- a/internal/services/v1/permissions_test.go +++ b/internal/services/v1/permissions_test.go @@ -337,7 +337,7 @@ func TestCheckPermissionSchemaLoadedOnce(t *testing.T) { StreamingAPITimeout: 30 * time.Second, DataLayerOpts: []datalayer.DataLayerOption{ datalayer.WithSchemaMode(datalayer.SchemaModeReadNewWriteBoth), - datalayer.WithSchemaCache(&simpleSchemaCache{items: make(map[datalayer.SchemaCacheKey]*datastore.ReadOnlyStoredSchema)}), + datalayer.WithSchemaCache(&simpleSchemaCache{items: make(map[datalayer.SchemaCacheKey]*datalayer.CachedSchema)}), }, }, func(t testing.TB, ds datastore.Datastore) (datastore.Datastore, datastore.Revision) { @@ -431,7 +431,7 @@ func TestCheckPermissionSchemaLoadedOnceMinLatency(t *testing.T) { StreamingAPITimeout: 30 * time.Second, DataLayerOpts: []datalayer.DataLayerOption{ datalayer.WithSchemaMode(datalayer.SchemaModeReadNewWriteBoth), - datalayer.WithSchemaCache(&simpleSchemaCache{items: make(map[datalayer.SchemaCacheKey]*datastore.ReadOnlyStoredSchema)}), + datalayer.WithSchemaCache(&simpleSchemaCache{items: make(map[datalayer.SchemaCacheKey]*datalayer.CachedSchema)}), }, }, func(t testing.TB, ds datastore.Datastore) (datastore.Datastore, datastore.Revision) { @@ -581,17 +581,17 @@ func (r *countingReader) ReadStoredSchema(ctx context.Context) (*datastore.ReadO // simpleSchemaCache is a minimal SchemaCache for testing. type simpleSchemaCache struct { mu sync.Mutex - items map[datalayer.SchemaCacheKey]*datastore.ReadOnlyStoredSchema // GUARDED_BY(mu) + items map[datalayer.SchemaCacheKey]*datalayer.CachedSchema // GUARDED_BY(mu) } -func (c *simpleSchemaCache) Get(key datalayer.SchemaCacheKey) (*datastore.ReadOnlyStoredSchema, bool) { +func (c *simpleSchemaCache) Get(key datalayer.SchemaCacheKey) (*datalayer.CachedSchema, bool) { c.mu.Lock() defer c.mu.Unlock() v, ok := c.items[key] return v, ok } -func (c *simpleSchemaCache) Set(key datalayer.SchemaCacheKey, entry *datastore.ReadOnlyStoredSchema, _ int64) bool { +func (c *simpleSchemaCache) Set(key datalayer.SchemaCacheKey, entry *datalayer.CachedSchema, _ int64) bool { c.mu.Lock() defer c.mu.Unlock() c.items[key] = entry diff --git a/pkg/cmd/datastore/datastore.go b/pkg/cmd/datastore/datastore.go index 9c3c3c251c..4990454c34 100644 --- a/pkg/cmd/datastore/datastore.go +++ b/pkg/cmd/datastore/datastore.go @@ -134,6 +134,9 @@ type Config struct { BootstrapOverwrite bool `debugmap:"visible"` BootstrapTimeout time.Duration `debugmap:"visible"` CaveatTypeSet *caveattypes.TypeSet `debugmap:"hidden"` + // BootstrapSchemaMode controls the schema storage mode used when writing bootstrap + // data. The zero value (SchemaModeReadLegacyWriteLegacy) preserves prior behavior. + BootstrapSchemaMode datalayer.SchemaMode `debugmap:"visible"` // Hedging RequestHedgingEnabled bool `debugmap:"visible"` @@ -482,7 +485,7 @@ func NewDatastore(ctx context.Context, options ...ConfigOption) (datastore.Datas } if len(bootstrapContents) > 0 { - bootstrapDL := datalayer.NewDataLayer(ds) + bootstrapDL := datalayer.NewDataLayer(ds, datalayer.WithSchemaMode(opts.BootstrapSchemaMode)) _, _, err = validationfile.PopulateFromFilesContents(ctx, bootstrapDL, opts.CaveatTypeSet, bootstrapContents) if err != nil { return nil, fmt.Errorf("failed to load bootstrap data: %w", err) diff --git a/pkg/cmd/datastore/zz_generated.options.go b/pkg/cmd/datastore/zz_generated.options.go index 9d814ebf72..07db957a75 100644 --- a/pkg/cmd/datastore/zz_generated.options.go +++ b/pkg/cmd/datastore/zz_generated.options.go @@ -4,6 +4,7 @@ package datastore import ( "fmt" types "github.com/authzed/spicedb/pkg/caveats/types" + datalayer "github.com/authzed/spicedb/pkg/datalayer" defaults "github.com/creasty/defaults" "time" ) @@ -55,6 +56,7 @@ func (c *Config) ToOption() ConfigOption { to.BootstrapOverwrite = c.BootstrapOverwrite to.BootstrapTimeout = c.BootstrapTimeout to.CaveatTypeSet = c.CaveatTypeSet + to.BootstrapSchemaMode = c.BootstrapSchemaMode to.RequestHedgingEnabled = c.RequestHedgingEnabled to.RequestHedgingInitialSlowValue = c.RequestHedgingInitialSlowValue to.RequestHedgingMaxRequests = c.RequestHedgingMaxRequests @@ -188,6 +190,13 @@ func (c *Config) DebugMap() map[string]any { } else { debugMap["BootstrapTimeout"] = c.BootstrapTimeout } + if dm, ok := any(&c.BootstrapSchemaMode).(interface { + DebugMap() map[string]any + }); ok { + debugMap["BootstrapSchemaMode"] = dm.DebugMap() + } else { + debugMap["BootstrapSchemaMode"] = c.BootstrapSchemaMode + } debugMap["RequestHedgingEnabled"] = c.RequestHedgingEnabled if dm, ok := any(&c.RequestHedgingInitialSlowValue).(interface { DebugMap() map[string]any @@ -536,6 +545,13 @@ func WithCaveatTypeSet(caveatTypeSet *types.TypeSet) ConfigOption { } } +// WithBootstrapSchemaMode returns an option that can set BootstrapSchemaMode on a Config +func WithBootstrapSchemaMode(bootstrapSchemaMode datalayer.SchemaMode) ConfigOption { + return func(c *Config) { + c.BootstrapSchemaMode = bootstrapSchemaMode + } +} + // WithRequestHedgingEnabled returns an option that can set RequestHedgingEnabled on a Config func WithRequestHedgingEnabled(requestHedgingEnabled bool) ConfigOption { return func(c *Config) { diff --git a/pkg/cmd/server/server.go b/pkg/cmd/server/server.go index 5d545f09dc..ff560da3f3 100644 --- a/pkg/cmd/server/server.go +++ b/pkg/cmd/server/server.go @@ -228,7 +228,7 @@ func (c *Config) complete(ctx context.Context) (*completedServerConfig, error) { cachingMode = schemacaching.WatchIfSupported } - storedSchemaCache, err := CompleteCache[datalayer.SchemaCacheKey, *datastore.ReadOnlyStoredSchema](cacheRegisterer, &c.StoredSchemaCacheConfig) + storedSchemaCache, err := CompleteCache[datalayer.SchemaCacheKey, *datalayer.CachedSchema](cacheRegisterer, &c.StoredSchemaCacheConfig) if err != nil { return nil, fmt.Errorf("failed to create stored schema cache: %w", err) } diff --git a/pkg/datalayer/datalayer_test.go b/pkg/datalayer/datalayer_test.go index d7ac76e49e..2410af7559 100644 --- a/pkg/datalayer/datalayer_test.go +++ b/pkg/datalayer/datalayer_test.go @@ -709,12 +709,12 @@ func TestStoredSchemaReaderAdapterEmptySchema(t *testing.T) { // Test the storedSchemaReaderAdapter when schema is empty (no definitions). adapter := &storedSchemaReaderAdapter{ - storedSchema: datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ + cached: NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ Version: 1, VersionOneof: &core.StoredSchema_V1{ V1: &core.StoredSchema_V1StoredSchema{}, }, - }), + })), lastWrittenRevision: datastore.NoRevision, } @@ -765,9 +765,9 @@ func TestStoredSchemaReaderAdapterV1Nil(t *testing.T) { // Test the v1() fallback path when VersionOneof is nil. adapter := &storedSchemaReaderAdapter{ - storedSchema: datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ + cached: NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ Version: 1, - }), + })), lastWrittenRevision: datastore.NoRevision, } @@ -882,21 +882,21 @@ func TestWriteSchemaDeletesRemovedDefinitions(t *testing.T) { // testSchemaCache is a simple in-memory cache satisfying SchemaCache for tests. type testSchemaCache struct { mu sync.Mutex - items map[SchemaCacheKey]*datastore.ReadOnlyStoredSchema // GUARDED_BY(mu) + items map[SchemaCacheKey]*CachedSchema // GUARDED_BY(mu) } func newTestSchemaCache() *testSchemaCache { - return &testSchemaCache{items: make(map[SchemaCacheKey]*datastore.ReadOnlyStoredSchema)} + return &testSchemaCache{items: make(map[SchemaCacheKey]*CachedSchema)} } -func (c *testSchemaCache) Get(key SchemaCacheKey) (*datastore.ReadOnlyStoredSchema, bool) { +func (c *testSchemaCache) Get(key SchemaCacheKey) (*CachedSchema, bool) { c.mu.Lock() defer c.mu.Unlock() v, ok := c.items[key] return v, ok } -func (c *testSchemaCache) Set(key SchemaCacheKey, entry *datastore.ReadOnlyStoredSchema, _ int64) bool { +func (c *testSchemaCache) Set(key SchemaCacheKey, entry *CachedSchema, _ int64) bool { c.mu.Lock() defer c.mu.Unlock() c.items[key] = entry diff --git a/pkg/datalayer/derivedcache.go b/pkg/datalayer/derivedcache.go new file mode 100644 index 0000000000..58885bee59 --- /dev/null +++ b/pkg/datalayer/derivedcache.go @@ -0,0 +1,77 @@ +package datalayer + +import ( + "fmt" + "sync" + + "github.com/authzed/spicedb/pkg/datastore" + "github.com/authzed/spicedb/pkg/spiceerrors" +) + +// CachedSchema bundles a read-only stored schema with lazily-built, schema-derived caches +// (for example compiled caveats, type systems, or reachability). Instances are produced and +// shared by the datalayer's stored-schema cache, so the derived caches live exactly as long +// as the schema version they belong to and are discarded, together, when the schema changes. +type CachedSchema struct { + schema *datastore.ReadOnlyStoredSchema + derived sync.Map // map[DerivedCacheKey]any +} + +// NewCachedSchema wraps a stored schema. Returns nil if schema is nil. +func NewCachedSchema(schema *datastore.ReadOnlyStoredSchema) *CachedSchema { + if schema == nil { + return nil + } + return &CachedSchema{schema: schema} +} + +// Schema returns the underlying read-only stored schema. +func (c *CachedSchema) Schema() *datastore.ReadOnlyStoredSchema { return c.schema } + +// DerivedCacheKey identifies a kind of schema-derived cache (e.g. compiled caveats). Create +// one per kind, typically as a package-level var, via NewDerivedCacheKey. +type DerivedCacheKey struct{ name string } + +// NewDerivedCacheKey returns a DerivedCacheKey with the given (debug) name. +func NewDerivedCacheKey(name string) DerivedCacheKey { return DerivedCacheKey{name: name} } + +// Name returns the human-readable name of the key. +func (k DerivedCacheKey) Name() string { return k.name } + +// derivedCacheFactories maps a DerivedCacheKey to a factory that builds an empty cache +// instance. Registered once at init time via RegisterDerivedCache. +var derivedCacheFactories sync.Map // map[DerivedCacheKey]func() any + +// RegisterDerivedCache registers a factory used to lazily build a schema-derived cache of the +// given kind. The factory returns a fresh, empty cache and is invoked at most once per +// CachedSchema instance (i.e. once per schema version). Intended to be called from an init() +// function. It returns an error if a factory is already registered for the key. +func RegisterDerivedCache(key DerivedCacheKey, factory func() any) error { + if _, loaded := derivedCacheFactories.LoadOrStore(key, factory); loaded { + return fmt.Errorf("derived schema cache already registered for key %q", key.name) + } + return nil +} + +// derivedCache returns the derived cache registered under key for this schema, building it +// once (lazily) on first access. It panics if no factory is registered for key, which +// indicates a programming error (a cache kind accessed without being registered at init). +func (c *CachedSchema) derivedCache(key DerivedCacheKey) any { + if v, ok := c.derived.Load(key); ok { + return v + } + factory, ok := derivedCacheFactories.Load(key) + if !ok { + spiceerrors.MustPanicf("no derived schema cache registered for key %q", key.name) + return nil + } + built := factory.(func() any)() + actual, _ := c.derived.LoadOrStore(key, built) + return actual +} + +// GetDerivedCache returns the schema-derived cache of type T registered under key for the +// given cached schema, building it lazily on first access. +func GetDerivedCache[T any](c *CachedSchema, key DerivedCacheKey) T { + return c.derivedCache(key).(T) +} diff --git a/pkg/datalayer/derivedcache_test.go b/pkg/datalayer/derivedcache_test.go new file mode 100644 index 0000000000..da0878ed47 --- /dev/null +++ b/pkg/datalayer/derivedcache_test.go @@ -0,0 +1,63 @@ +package datalayer_test + +import ( + "fmt" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/authzed/spicedb/pkg/datalayer" + "github.com/authzed/spicedb/pkg/datastore" + core "github.com/authzed/spicedb/pkg/proto/core/v1" +) + +// keyCounter gives each test a fresh registry key so the tests are safe under `go test -count=N` +// (registration is process-global and panics on duplicate keys by design). +var keyCounter atomic.Int64 + +func uniqueDerivedCacheKey(prefix string) datalayer.DerivedCacheKey { + return datalayer.NewDerivedCacheKey(fmt.Sprintf("%s.%d", prefix, keyCounter.Add(1))) +} + +type testCache struct{ id int } + +func newCachedSchema() *datalayer.CachedSchema { + return datalayer.NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{})) +} + +func TestDerivedCacheLazyAndShared(t *testing.T) { + key := uniqueDerivedCacheKey("lazy") + built := 0 + require.NoError(t, datalayer.RegisterDerivedCache(key, func() any { + built++ + return &testCache{id: built} + })) + + cs := newCachedSchema() + + // Built lazily on first access, and the same instance is returned thereafter. + c1 := datalayer.GetDerivedCache[*testCache](cs, key) + c2 := datalayer.GetDerivedCache[*testCache](cs, key) + require.Same(t, c1, c2) + require.Equal(t, 1, built, "factory should be invoked exactly once per schema instance") + + // A different cached-schema instance gets its own cache (per-schema-version isolation). + other := newCachedSchema() + c3 := datalayer.GetDerivedCache[*testCache](other, key) + require.NotSame(t, c1, c3) + require.Equal(t, 2, built) +} + +func TestDerivedCacheUnregisteredKeyPanics(t *testing.T) { + cs := newCachedSchema() + require.Panics(t, func() { + _ = datalayer.GetDerivedCache[*testCache](cs, uniqueDerivedCacheKey("unregistered")) + }) +} + +func TestDerivedCacheDuplicateRegistrationErrors(t *testing.T) { + key := uniqueDerivedCacheKey("dup") + require.NoError(t, datalayer.RegisterDerivedCache(key, func() any { return &testCache{} })) + require.Error(t, datalayer.RegisterDerivedCache(key, func() any { return &testCache{} })) +} diff --git a/pkg/datalayer/hashcache.go b/pkg/datalayer/hashcache.go index 39970d43f6..c5990ca052 100644 --- a/pkg/datalayer/hashcache.go +++ b/pkg/datalayer/hashcache.go @@ -32,17 +32,17 @@ type SchemaCacheKey string func (k SchemaCacheKey) KeyString() string { return string(k) } // SchemaCache defines the interface for the backing cache used by schemaHashCache. -// This is satisfied by cache.Cache[SchemaCacheKey, *datastore.ReadOnlyStoredSchema]. +// This is satisfied by cache.Cache[SchemaCacheKey, *CachedSchema]. type SchemaCache interface { - Get(key SchemaCacheKey) (*datastore.ReadOnlyStoredSchema, bool) - Set(key SchemaCacheKey, entry *datastore.ReadOnlyStoredSchema, cost int64) bool + Get(key SchemaCacheKey) (*CachedSchema, bool) + Set(key SchemaCacheKey, entry *CachedSchema, cost int64) bool Wait() } // latestSchemaEntry holds the most recent schema entry for fast-path lookups. type latestSchemaEntry struct { hash SchemaHash - schema *datastore.ReadOnlyStoredSchema + schema *CachedSchema } // schemaHashCache is a thread-safe cache for schemas indexed by hash. @@ -54,7 +54,7 @@ type latestSchemaEntry struct { type schemaHashCache struct { cache SchemaCache latest atomic.Pointer[latestSchemaEntry] // Fast path for latest schema - singleflight singleflight.Group[string, *datastore.ReadOnlyStoredSchema] + singleflight singleflight.Group[string, *CachedSchema] } // newSchemaHashCache creates a new hash-based schema cache wrapping the given cache. @@ -71,7 +71,7 @@ var _ storedSchemaCache = (*schemaHashCache)(nil) // Slow path: Check the cache. // Returns (nil, nil) if the hash is a bypass sentinel (NoSchemaHashInTransaction or NoSchemaHashForTesting). // Returns error if hash is empty string (indicates a bug where schema hash wasn't properly provided). -func (c *schemaHashCache) get(schemaHash SchemaHash) (*datastore.ReadOnlyStoredSchema, error) { +func (c *schemaHashCache) get(schemaHash SchemaHash) (*CachedSchema, error) { // Check for bypass sentinels - these intentionally skip the cache if schemaHash.IsBypassSentinel() { return nil, nil @@ -100,7 +100,7 @@ func (c *schemaHashCache) get(schemaHash SchemaHash) (*datastore.ReadOnlyStoredS // Adds to the cache and updates the atomic latest entry. // No-ops if hash is a bypass sentinel (NoSchemaHashInTransaction or NoSchemaHashForTesting). // Returns error if hash is empty string (indicates a bug where it wasn't properly provided). -func (c *schemaHashCache) Set(schemaHash SchemaHash, schema *datastore.ReadOnlyStoredSchema) error { +func (c *schemaHashCache) Set(schemaHash SchemaHash, schema *CachedSchema) error { if schemaHash.IsBypassSentinel() { return nil } @@ -125,8 +125,8 @@ func (c *schemaHashCache) GetOrLoad( ctx context.Context, rev datastore.Revision, schemaHash SchemaHash, - loader func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error), -) (*datastore.ReadOnlyStoredSchema, error) { + loader func(ctx context.Context) (*CachedSchema, error), +) (*CachedSchema, error) { // Check for bypass sentinels - load directly without caching if schemaHash.IsBypassSentinel() { schema, err := loader(ctx) @@ -163,7 +163,7 @@ func (c *schemaHashCache) GetOrLoad( sfCtx, cancel := context.WithTimeout(ctx, singleflightTimeout) defer cancel() - result, _, err := c.singleflight.Do(sfCtx, string(schemaHash), func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error) { + result, _, err := c.singleflight.Do(sfCtx, string(schemaHash), func(ctx context.Context) (*CachedSchema, error) { // Check cache again in case another goroutine loaded it schema, err := c.get(schemaHash) if err != nil { diff --git a/pkg/datalayer/hashcache_test.go b/pkg/datalayer/hashcache_test.go index c511c71534..38f44ac397 100644 --- a/pkg/datalayer/hashcache_test.go +++ b/pkg/datalayer/hashcache_test.go @@ -14,15 +14,15 @@ import ( core "github.com/authzed/spicedb/pkg/proto/core/v1" ) -func makeTestSchema(text string) *datastore.ReadOnlyStoredSchema { - return datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ +func makeTestSchema(text string) *CachedSchema { + return NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ Version: 1, VersionOneof: &core.StoredSchema_V1{ V1: &core.StoredSchema_V1StoredSchema{ SchemaText: text, }, }, - }) + })) } func TestSchemaHashCache_BasicGetSet(t *testing.T) { @@ -43,7 +43,7 @@ func TestSchemaHashCache_BasicGetSet(t *testing.T) { retrieved, err = shc.get(SchemaHash("hash1")) require.NoError(t, err) require.NotNil(t, retrieved) - require.Equal(t, schema.Get().GetV1().SchemaText, retrieved.Get().GetV1().SchemaText) + require.Equal(t, schema.Schema().Get().GetV1().SchemaText, retrieved.Schema().Get().GetV1().SchemaText) } func TestSchemaHashCache_EmptyHash(t *testing.T) { @@ -62,7 +62,7 @@ func TestSchemaHashCache_GetOrLoad(t *testing.T) { shc := newSchemaHashCache(newTestSchemaCache()) loadCalls := 0 - loader := func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error) { + loader := func(ctx context.Context) (*CachedSchema, error) { loadCalls++ return makeTestSchema("loaded definition"), nil } @@ -71,7 +71,7 @@ func TestSchemaHashCache_GetOrLoad(t *testing.T) { schema, err := shc.GetOrLoad(t.Context(), datastore.NoRevision, SchemaHash("hash1"), loader) require.NoError(t, err) require.NotNil(t, schema) - require.Equal(t, "loaded definition", schema.Get().GetV1().SchemaText) + require.Equal(t, "loaded definition", schema.Schema().Get().GetV1().SchemaText) require.Equal(t, 1, loadCalls) // Second call should hit cache @@ -85,7 +85,7 @@ func TestSchemaHashCache_GetOrLoadEmptyHash(t *testing.T) { shc := newSchemaHashCache(newTestSchemaCache()) require.Panics(t, func() { - _, _ = shc.GetOrLoad(t.Context(), datastore.NoRevision, SchemaHash(""), func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error) { + _, _ = shc.GetOrLoad(t.Context(), datastore.NoRevision, SchemaHash(""), func(ctx context.Context) (*CachedSchema, error) { return makeTestSchema("loaded definition"), nil }) }, "empty hash should panic") @@ -98,7 +98,7 @@ func TestSchemaHashCache_Singleflight(t *testing.T) { loadStarted := make(chan struct{}) loadContinue := make(chan struct{}) - loader := func(_ context.Context) (*datastore.ReadOnlyStoredSchema, error) { + loader := func(_ context.Context) (*CachedSchema, error) { loadCalls++ close(loadStarted) <-loadContinue @@ -142,7 +142,7 @@ func TestSchemaHashCache_LoadError(t *testing.T) { shc := newSchemaHashCache(newTestSchemaCache()) expectedErr := fmt.Errorf("load failed") - schema, err := shc.GetOrLoad(t.Context(), datastore.NoRevision, SchemaHash("hash1"), func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error) { + schema, err := shc.GetOrLoad(t.Context(), datastore.NoRevision, SchemaHash("hash1"), func(ctx context.Context) (*CachedSchema, error) { return nil, expectedErr }) require.Error(t, err) @@ -187,7 +187,7 @@ func TestSchemaHashCache_SentinelBypass(t *testing.T) { // GetOrLoad should always call loader loadCalled := false - result, err := shc.GetOrLoad(t.Context(), datastore.NoRevision, sentinel, func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error) { + result, err := shc.GetOrLoad(t.Context(), datastore.NoRevision, sentinel, func(ctx context.Context) (*CachedSchema, error) { loadCalled = true return schema, nil }) @@ -209,7 +209,7 @@ func TestSchemaHashCache_SingleflightTimeoutFallback(t *testing.T) { var loadCount atomic.Int32 - slowLoader := func(_ context.Context) (*datastore.ReadOnlyStoredSchema, error) { + slowLoader := func(_ context.Context) (*CachedSchema, error) { count := loadCount.Add(1) if count == 1 { close(leaderStarted) @@ -251,13 +251,13 @@ func TestNoopSchemaCache(t *testing.T) { // GetOrLoad always calls loader loadCalled := false - schema, err := noop.GetOrLoad(t.Context(), datastore.NoRevision, SchemaHash("hash"), func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error) { + schema, err := noop.GetOrLoad(t.Context(), datastore.NoRevision, SchemaHash("hash"), func(ctx context.Context) (*CachedSchema, error) { loadCalled = true return makeTestSchema("loaded"), nil }) require.NoError(t, err) require.True(t, loadCalled) - require.Equal(t, "loaded", schema.Get().GetV1().SchemaText) + require.Equal(t, "loaded", schema.Schema().Get().GetV1().SchemaText) } func TestSchemaHashCache_SlowPathCacheHit(t *testing.T) { @@ -279,5 +279,5 @@ func TestSchemaHashCache_SlowPathCacheHit(t *testing.T) { retrieved, err := shc.get(SchemaHash("hash1")) require.NoError(t, err) require.NotNil(t, retrieved) - require.Equal(t, "definition v1 {}", retrieved.Get().GetV1().SchemaText) + require.Equal(t, "definition v1 {}", retrieved.Schema().Get().GetV1().SchemaText) } diff --git a/pkg/datalayer/impl.go b/pkg/datalayer/impl.go index b717d7df5f..61a2582cb1 100644 --- a/pkg/datalayer/impl.go +++ b/pkg/datalayer/impl.go @@ -16,20 +16,20 @@ import ( // storedSchemaCache caches stored schemas by hash. type storedSchemaCache interface { GetOrLoad(ctx context.Context, rev datastore.Revision, schemaHash SchemaHash, - loader func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error)) (*datastore.ReadOnlyStoredSchema, error) - Set(schemaHash SchemaHash, schema *datastore.ReadOnlyStoredSchema) error + loader func(ctx context.Context) (*CachedSchema, error)) (*CachedSchema, error) + Set(schemaHash SchemaHash, schema *CachedSchema) error } // noopSchemaCache is a storedSchemaCache that always delegates to the loader. type noopSchemaCache struct{} func (noopSchemaCache) GetOrLoad(ctx context.Context, _ datastore.Revision, _ SchemaHash, - loader func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error), -) (*datastore.ReadOnlyStoredSchema, error) { + loader func(ctx context.Context) (*CachedSchema, error), +) (*CachedSchema, error) { return loader(ctx) } -func (noopSchemaCache) Set(_ SchemaHash, _ *datastore.ReadOnlyStoredSchema) error { +func (noopSchemaCache) Set(_ SchemaHash, _ *CachedSchema) error { return nil } diff --git a/pkg/datalayer/schema_adapter.go b/pkg/datalayer/schema_adapter.go index fbcda3acaa..356567c0ee 100644 --- a/pkg/datalayer/schema_adapter.go +++ b/pkg/datalayer/schema_adapter.go @@ -324,7 +324,7 @@ func writeSchemaViaLegacy(ctx context.Context, legacyWriter datastore.LegacySche // storedSchemaReaderAdapter implements SchemaReader by reading from the unified // StoredSchema proto via ReadStoredSchema on the underlying datastore reader. type storedSchemaReaderAdapter struct { - storedSchema *datastore.ReadOnlyStoredSchema + cached *CachedSchema lastWrittenRevision datastore.Revision } @@ -343,29 +343,46 @@ func newStoredSchemaReaderAdapter(ctx context.Context, reader storedSchemaReader defer span.End() span.SetAttributes(attribute.String(otelconv.AttrSchemaHash, string(schemaHash))) - storedSchema, err := cache.GetOrLoad(ctx, lastWrittenRevision, schemaHash, func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error) { - return reader.ReadStoredSchema(ctx) + cached, err := cache.GetOrLoad(ctx, lastWrittenRevision, schemaHash, func(ctx context.Context) (*CachedSchema, error) { + stored, err := reader.ReadStoredSchema(ctx) + if err != nil { + return nil, err + } + return NewCachedSchema(stored), nil }) if err != nil { if errors.Is(err, datastore.ErrSchemaNotFound) { // No unified schema yet; return an adapter with no definitions return &storedSchemaReaderAdapter{ - storedSchema: datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ + cached: NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ Version: 1, VersionOneof: &core.StoredSchema_V1{ V1: &core.StoredSchema_V1StoredSchema{}, }, - }), + })), lastWrittenRevision: lastWrittenRevision, }, nil } return nil, fmt.Errorf("failed to read stored schema: %w", err) } - return &storedSchemaReaderAdapter{storedSchema: storedSchema, lastWrittenRevision: lastWrittenRevision}, nil + return &storedSchemaReaderAdapter{cached: cached, lastWrittenRevision: lastWrittenRevision}, nil +} + +// StoredSchema returns the underlying read-only stored schema backing this reader. +func (s *storedSchemaReaderAdapter) StoredSchema() *datastore.ReadOnlyStoredSchema { + return s.cached.Schema() +} + +// CachedSchema returns the shared, per-schema-version cached schema, which hosts +// schema-derived caches (see CachedSchema). It is shared across readers for a single schema +// version. Consumers may type-assert a SchemaReader to the (structural) interface +// { CachedSchema() *CachedSchema } to access it. +func (s *storedSchemaReaderAdapter) CachedSchema() *CachedSchema { + return s.cached } func (s *storedSchemaReaderAdapter) v1() *core.StoredSchema_V1StoredSchema { - if v1 := s.storedSchema.Get().GetV1(); v1 != nil { + if v1 := s.cached.Schema().Get().GetV1(); v1 != nil { return v1 } return &core.StoredSchema_V1StoredSchema{} @@ -546,7 +563,7 @@ func WriteSchemaViaStoredSchema(ctx context.Context, rwt datastore.ReadWriteTran // Update cache after successful write if v1 := storedSchema.GetV1(); v1 != nil && v1.SchemaHash != "" { - if err := cache.Set(SchemaHash(v1.SchemaHash), datastore.NewReadOnlyStoredSchema(storedSchema)); err != nil { + if err := cache.Set(SchemaHash(v1.SchemaHash), NewCachedSchema(datastore.NewReadOnlyStoredSchema(storedSchema))); err != nil { return "", err } } diff --git a/pkg/datalayer/schema_adapter_test.go b/pkg/datalayer/schema_adapter_test.go index ddc8d91dcc..f305ccf201 100644 --- a/pkg/datalayer/schema_adapter_test.go +++ b/pkg/datalayer/schema_adapter_test.go @@ -425,7 +425,7 @@ func TestLegacyAdapter_LookupCaveatDefinitionsByNames_Error(t *testing.T) { func newStoredAdapter(nsDefs map[string]*core.NamespaceDefinition, cavDefs map[string]*core.CaveatDefinition, schemaText string) *storedSchemaReaderAdapter { return &storedSchemaReaderAdapter{ - storedSchema: datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ + cached: NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{ Version: 1, VersionOneof: &core.StoredSchema_V1{ V1: &core.StoredSchema_V1StoredSchema{ @@ -434,7 +434,7 @@ func newStoredAdapter(nsDefs map[string]*core.NamespaceDefinition, cavDefs map[s CaveatDefinitions: cavDefs, }, }, - }), + })), lastWrittenRevision: testRevision, } } @@ -628,7 +628,7 @@ func TestStoredAdapter_V1NilFallback(t *testing.T) { t.Parallel() // When VersionOneof is nil, v1() should return an empty struct without panicking. adapter := &storedSchemaReaderAdapter{ - storedSchema: datastore.NewReadOnlyStoredSchema(&core.StoredSchema{Version: 1}), + cached: NewCachedSchema(datastore.NewReadOnlyStoredSchema(&core.StoredSchema{Version: 1})), lastWrittenRevision: testRevision, } @@ -873,12 +873,12 @@ func TestSchemaReaderFromLegacy_ReturnsSchemaReader(t *testing.T) { type fakeSchemaCache struct { loaded int setCount int - cachedValue *datastore.ReadOnlyStoredSchema + cachedValue *CachedSchema } func (m *fakeSchemaCache) GetOrLoad(ctx context.Context, _ datastore.Revision, _ SchemaHash, - loader func(ctx context.Context) (*datastore.ReadOnlyStoredSchema, error), -) (*datastore.ReadOnlyStoredSchema, error) { + loader func(ctx context.Context) (*CachedSchema, error), +) (*CachedSchema, error) { m.loaded++ if m.cachedValue != nil { return m.cachedValue, nil @@ -886,7 +886,7 @@ func (m *fakeSchemaCache) GetOrLoad(ctx context.Context, _ datastore.Revision, _ return loader(ctx) } -func (m *fakeSchemaCache) Set(_ SchemaHash, schema *datastore.ReadOnlyStoredSchema) error { +func (m *fakeSchemaCache) Set(_ SchemaHash, schema *CachedSchema) error { m.setCount++ m.cachedValue = schema return nil @@ -906,7 +906,7 @@ func TestNewStoredSchemaReaderAdapter_UsesCache(t *testing.T) { }, }) - cache := &fakeSchemaCache{cachedValue: schema} + cache := &fakeSchemaCache{cachedValue: NewCachedSchema(schema)} reader := &fakeStoredSchemaReader{err: errors.New("should not be called")} adapter, err := newStoredSchemaReaderAdapter(t.Context(), reader, "hash1", testRevision, cache) diff --git a/pkg/embedded/README.md b/pkg/embedded/README.md new file mode 100644 index 0000000000..fee1e8ac62 --- /dev/null +++ b/pkg/embedded/README.md @@ -0,0 +1,177 @@ +# embedded + +`embedded` runs SpiceDB's permission engine **in-process, without a gRPC server**. It is +intended for callers that embed SpiceDB as a library and want to issue permission checks +directly against a datastore — paying neither network nor gRPC-serialization cost, and +passing caveat context as native Go values rather than `structpb`. + +It is a thin, focused wrapper over SpiceDB's dispatch engine (`computed.ComputeCheck` + a +local dispatcher), exposing a single operation: `Check`. + +## When to use it + +- You already have (or can construct) a `datastore.Datastore` in your process and want fast, + allocation-light permission checks against it. +- You want to avoid the overhead of standing up an embedded gRPC server + in-process client + (bufconn), the full server middleware chain, and the `structpb`/base64 caveat-context + round-trip. + +## When **not** to use it + +- You need the full SpiceDB v1 API surface (schema writes, relationship writes, bulk + operations, watch, lookup, reflection, etc.). This package only does `CheckPermission`. +- You need remote access or multiple processes sharing one logical SpiceDB. Run a real + SpiceDB server instead. + +Checks are always **fully consistent** (evaluated at the datastore head revision). + +## Quick start + +```go +package main + +import ( + "context" + "fmt" + "log" + + caveattypes "github.com/authzed/spicedb/pkg/caveats/types" + dscfg "github.com/authzed/spicedb/pkg/cmd/datastore" + "github.com/authzed/spicedb/pkg/datalayer" + "github.com/authzed/spicedb/pkg/embedded" +) + +const bootstrap = ` +schema: |- + definition user {} + + caveat is_tuesday(day string) { + day == "tuesday" + } + + definition document { + relation viewer: user + relation caveated_viewer: user with is_tuesday + + permission view = viewer + permission caveated_view = caveated_viewer + } +relationships: |- + document:readme#viewer@user:alice + + document:readme#caveated_viewer@user:bob[is_tuesday] +` + +func main() { + ctx := context.Background() + + // Any datastore works. Here we use an in-memory datastore populated from a bootstrap + // document and storing schema in the unified ("single store") format. + ds, err := dscfg.NewDatastore(ctx, + dscfg.DefaultDatastoreConfig().ToOption(), + dscfg.SetBootstrapFileContents(map[string][]byte{"bootstrap.yaml": []byte(bootstrap)}), + dscfg.WithCaveatTypeSet(caveattypes.Default.TypeSet), + dscfg.WithBootstrapSchemaMode(datalayer.SchemaModeReadNewWriteNew), + ) + if err != nil { + log.Fatal(err) + } + + perms, err := embedded.NewPermissions(embedded.Config{ + Datastore: ds, + // Read schema from the unified store, and cache it across checks so schema-derived + // caches (e.g. compiled caveats) persist and are not rebuilt per check. + SchemaMode: datalayer.SchemaModeReadNewWriteNew, + SchemaCacheMaxCostBytes: 16 << 20, // 16 MiB + }) + if err != nil { + log.Fatal(err) + } + defer perms.Close() + + // Plain check. + res, err := perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "readme", Permission: "view", + SubjectType: "user", SubjectID: "alice", + }) + if err != nil { + log.Fatal(err) + } + fmt.Println("alice can view:", res.HasPermission) // true + + // Caveated check — caveat context is passed as native Go values. + res, err = perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "readme", Permission: "caveated_view", + SubjectType: "user", SubjectID: "bob", + CaveatContext: map[string]any{"day": "tuesday"}, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println("bob can view on tuesday:", res.HasPermission) // true + + // Without the required context, the result is conditional rather than allowed/denied. + res, _ = perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "readme", Permission: "caveated_view", + SubjectType: "user", SubjectID: "bob", + }) + fmt.Println("conditional:", res.IsConditional, "missing:", res.MissingContext) + // conditional: true missing: [day] +} +``` + +You are not limited to bootstrap documents — pass any `datastore.Datastore` you have +populated however you like (the relationships/schema must already be written). + +## Configuration + +`embedded.Config`: + +| Field | Required | Default | Notes | +|---|---|---|---| +| `Datastore` | **yes** | — | The datastore to check against. The caller owns its lifecycle (`Close` does not close it). | +| `CaveatTypeSet` | no | `caveattypes.Default` | Must match the type set the schema/caveats were written with. | +| `SchemaMode` | no | legacy (per-definition) | Use `datalayer.SchemaModeReadNewWriteNew` (or `*Both`) to read the unified schema. Must match how the datastore's schema was written. | +| `SchemaCacheMaxCostBytes` | no | `0` (disabled) | When `> 0`, caches the unified stored schema across checks. This is what lets schema-derived caches (compiled caveats, etc.) persist; strongly recommended whenever `SchemaMode` reads from the unified schema. | +| `DispatchConcurrencyLimit` | no | `10` | Max concurrent sub-dispatches per check. | +| `DispatchChunkSize` | no | `100` | Datastore query / dispatch chunk size. | +| `MaxDepth` | no | `50` | Maximum dispatch recursion depth. | + +## The `Check` API + +```go +type CheckRequest struct { + ResourceType string + ResourceID string + Permission string + SubjectType string + SubjectID string + SubjectRelation string // optional; defaults to the "..." (ellipsis) relation + CaveatContext map[string]any // native Go values; no structpb / base64 +} + +type CheckResult struct { + HasPermission bool // definitively a member of the permission + IsConditional bool // membership depends on a caveat that lacked required context + MissingContext []string // the caveat context fields that were required but not provided +} +``` + +- `HasPermission == true` → allowed. +- `HasPermission == false && IsConditional == false` → denied. +- `IsConditional == true` → a caveat could not be fully evaluated; supply the values named in + `MissingContext` and check again. + +## Caveat context + +Because checks run in-process, caveat context is supplied directly as `map[string]any` and +consumed by the caveat engine without conversion. For a caveat parameter typed `bytes`, the +value must still be a base64-encoded string (the caveat type system decodes it); all other +types accept their natural Go representation. + +## Lifecycle + +Call `Close` when finished to release the dispatcher. `Close` does **not** close the +datastore you passed in — you own that. + +A `Permissions` value is safe for concurrent use. diff --git a/pkg/embedded/permissions.go b/pkg/embedded/permissions.go new file mode 100644 index 0000000000..903ced3b2a --- /dev/null +++ b/pkg/embedded/permissions.go @@ -0,0 +1,185 @@ +// Package embedded runs SpiceDB's permission engine in-process, without standing up a +// gRPC server. It is intended for callers that embed SpiceDB as a library and want to +// issue permission checks directly against a datastore, paying neither network nor +// gRPC-serialization cost, and passing caveat context as native Go values. +package embedded + +import ( + "context" + "errors" + "fmt" + + "github.com/authzed/spicedb/internal/dispatch" + "github.com/authzed/spicedb/internal/dispatch/graph" + "github.com/authzed/spicedb/internal/graph/computed" + "github.com/authzed/spicedb/pkg/cache" + caveattypes "github.com/authzed/spicedb/pkg/caveats/types" + "github.com/authzed/spicedb/pkg/datalayer" + "github.com/authzed/spicedb/pkg/datastore" + dispatchv1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1" + "github.com/authzed/spicedb/pkg/tuple" +) + +const ( + defaultConcurrencyLimit = 10 + defaultDispatchChunkSize = 100 + defaultMaxDepth = 50 +) + +// Config configures a Permissions checker. +type Config struct { + // Datastore is the datastore to check against. Required. The caller owns its lifecycle. + Datastore datastore.Datastore + + // CaveatTypeSet is the caveat type set used to compile and evaluate caveats. + // Defaults to caveattypes.Default. + CaveatTypeSet *caveattypes.TypeSet + + // SchemaMode controls how schema is read. The zero value reads legacy per-definition + // schema. Use datalayer.SchemaModeReadNewWriteNew (or *Both) for the unified schema. + SchemaMode datalayer.SchemaMode + + // SchemaCacheMaxCostBytes, when > 0, enables an in-memory stored-schema cache of the + // given size in bytes. Caching the stored schema across checks is what allows + // schema-derived caches (e.g. compiled caveats) to persist; strongly recommended when + // SchemaMode reads from the unified schema. + SchemaCacheMaxCostBytes int64 + + // DispatchConcurrencyLimit, DispatchChunkSize, and MaxDepth use sane defaults if zero. + DispatchConcurrencyLimit uint16 + DispatchChunkSize uint16 + MaxDepth uint32 +} + +// Permissions issues in-process permission checks against a datastore. +type Permissions struct { + dl datalayer.DataLayer + dispatcher dispatch.Dispatcher + cts *caveattypes.TypeSet + chunkSize uint16 + maxDepth uint32 +} + +// NewPermissions builds an in-process permissions checker from the given config. +func NewPermissions(cfg Config) (*Permissions, error) { + if cfg.Datastore == nil { + return nil, errors.New("embedded: Datastore is required") + } + + cts := cfg.CaveatTypeSet + if cts == nil { + cts = caveattypes.Default.TypeSet + } + concurrency := cfg.DispatchConcurrencyLimit + if concurrency == 0 { + concurrency = defaultConcurrencyLimit + } + chunkSize := cfg.DispatchChunkSize + if chunkSize == 0 { + chunkSize = defaultDispatchChunkSize + } + maxDepth := cfg.MaxDepth + if maxDepth == 0 { + maxDepth = defaultMaxDepth + } + + dlOpts := []datalayer.DataLayerOption{datalayer.WithSchemaMode(cfg.SchemaMode)} + if cfg.SchemaCacheMaxCostBytes > 0 { + schemaCache, err := cache.NewStandardCache[datalayer.SchemaCacheKey, *datalayer.CachedSchema](&cache.Config{ + MaxCost: cfg.SchemaCacheMaxCostBytes, + }) + if err != nil { + return nil, fmt.Errorf("embedded: failed to create stored schema cache: %w", err) + } + dlOpts = append(dlOpts, datalayer.WithSchemaCache(schemaCache)) + } + dl := datalayer.NewDataLayer(cfg.Datastore, dlOpts...) + + dispatcher, err := graph.NewLocalOnlyDispatcher(graph.DispatcherParameters{ + ConcurrencyLimits: graph.SharedConcurrencyLimits(concurrency), + DispatchChunkSize: chunkSize, + TypeSet: cts, + }) + if err != nil { + return nil, fmt.Errorf("embedded: failed to create dispatcher: %w", err) + } + + return &Permissions{ + dl: dl, + dispatcher: dispatcher, + cts: cts, + chunkSize: chunkSize, + maxDepth: maxDepth, + }, nil +} + +// CheckRequest is a single, fully-consistent permission check. The caveat context is passed +// as native Go values (no structpb / base64 round-trip). +type CheckRequest struct { + ResourceType string + ResourceID string + Permission string + SubjectType string + SubjectID string + SubjectRelation string // optional; defaults to the ellipsis ("...") relation + CaveatContext map[string]any +} + +// CheckResult is the outcome of a check. +type CheckResult struct { + // HasPermission is true when the subject is definitively a member of the permission. + HasPermission bool + // IsConditional is true when membership depends on a caveat that could not be fully + // evaluated because required context was missing (see MissingContext). + IsConditional bool + // MissingContext lists the caveat context fields that were required but not provided. + MissingContext []string +} + +// Check runs a single, fully-consistent permission check in-process. +func (p *Permissions) Check(ctx context.Context, req CheckRequest) (CheckResult, error) { + ctx = datalayer.ContextWithDataLayer(ctx, p.dl) + + // The datalayer head revision yields the schema-mode-appropriate schema hash (a real + // hash under the unified schema, or a legacy sentinel otherwise). + revision, schemaHash, err := p.dl.HeadRevision(ctx) + if err != nil { + return CheckResult{}, err + } + + subjectRel := req.SubjectRelation + if subjectRel == "" { + subjectRel = tuple.Ellipsis + } + + cr, _, err := computed.ComputeCheck(ctx, p.dispatcher, p.cts, + computed.CheckParameters{ + ResourceType: tuple.RR(req.ResourceType, req.Permission), + Subject: tuple.ONR(req.SubjectType, req.SubjectID, subjectRel), + CaveatContext: req.CaveatContext, + AtRevision: revision, + MaximumDepth: p.maxDepth, + DebugOption: computed.NoDebugging, + SchemaHash: schemaHash, + }, + req.ResourceID, + p.chunkSize, + ) + if err != nil { + return CheckResult{}, err + } + + switch cr.Membership { + case dispatchv1.ResourceCheckResult_MEMBER: + return CheckResult{HasPermission: true}, nil + case dispatchv1.ResourceCheckResult_CAVEATED_MEMBER: + return CheckResult{IsConditional: true, MissingContext: cr.MissingExprFields}, nil + default: + return CheckResult{}, nil + } +} + +// Close releases resources held by the checker. It does not close the datastore. +func (p *Permissions) Close() error { + return p.dispatcher.Close() +} diff --git a/pkg/embedded/permissions_test.go b/pkg/embedded/permissions_test.go new file mode 100644 index 0000000000..f120b37917 --- /dev/null +++ b/pkg/embedded/permissions_test.go @@ -0,0 +1,155 @@ +package embedded_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + caveattypes "github.com/authzed/spicedb/pkg/caveats/types" + dscfg "github.com/authzed/spicedb/pkg/cmd/datastore" + "github.com/authzed/spicedb/pkg/datalayer" + "github.com/authzed/spicedb/pkg/embedded" +) + +const testBootstrap = ` +schema: |- + definition user {} + + caveat is_tuesday(day string) { + day == "tuesday" + } + + definition document { + relation viewer: user + relation caveated_viewer: user with is_tuesday + + permission view = viewer + permission caveated_view = caveated_viewer + } +relationships: |- + document:doc1#viewer@user:alice + + document:doc1#caveated_viewer@user:bob[is_tuesday] +` + +func newTestPermissions(t *testing.T, mode datalayer.SchemaMode) *embedded.Permissions { + t.Helper() + ctx := t.Context() + + ds, err := dscfg.NewDatastore(ctx, + dscfg.DefaultDatastoreConfig().ToOption(), + dscfg.SetBootstrapFileContents(map[string][]byte{"test.yaml": []byte(testBootstrap)}), + dscfg.WithCaveatTypeSet(caveattypes.Default.TypeSet), + dscfg.WithBootstrapSchemaMode(mode), + ) + require.NoError(t, err) + + // A stored-schema cache is only meaningful (and only exercises the schema-tied compiled + // caveat cache) when reading from the unified schema. + var schemaCacheCost int64 + if mode.ReadsFromNew() { + schemaCacheCost = 1 << 20 + } + + perms, err := embedded.NewPermissions(embedded.Config{ + Datastore: ds, + SchemaMode: mode, + SchemaCacheMaxCostBytes: schemaCacheCost, + }) + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, perms.Close()) + require.NoError(t, ds.Close()) + }) + return perms +} + +func TestPermissionsCheck(t *testing.T) { + modes := []struct { + name string + mode datalayer.SchemaMode + }{ + {"legacy", datalayer.SchemaModeReadLegacyWriteLegacy}, + {"single_store", datalayer.SchemaModeReadNewWriteNew}, + } + + for _, m := range modes { + t.Run(m.name, func(t *testing.T) { + ctx := t.Context() + perms := newTestPermissions(t, m.mode) + + t.Run("member", func(t *testing.T) { + res, err := perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "doc1", Permission: "view", + SubjectType: "user", SubjectID: "alice", + }) + require.NoError(t, err) + require.True(t, res.HasPermission) + require.False(t, res.IsConditional) + }) + + t.Run("non_member", func(t *testing.T) { + res, err := perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "doc1", Permission: "view", + SubjectType: "user", SubjectID: "carol", + }) + require.NoError(t, err) + require.False(t, res.HasPermission) + require.False(t, res.IsConditional) + }) + + t.Run("caveated_missing_context", func(t *testing.T) { + res, err := perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "doc1", Permission: "caveated_view", + SubjectType: "user", SubjectID: "bob", + }) + require.NoError(t, err) + require.False(t, res.HasPermission) + require.True(t, res.IsConditional) + require.Contains(t, res.MissingContext, "day") + }) + + t.Run("caveated_satisfied", func(t *testing.T) { + res, err := perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "doc1", Permission: "caveated_view", + SubjectType: "user", SubjectID: "bob", + CaveatContext: map[string]any{"day": "tuesday"}, + }) + require.NoError(t, err) + require.True(t, res.HasPermission) + require.False(t, res.IsConditional) + }) + + t.Run("caveated_unsatisfied", func(t *testing.T) { + res, err := perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "doc1", Permission: "caveated_view", + SubjectType: "user", SubjectID: "bob", + CaveatContext: map[string]any{"day": "monday"}, + }) + require.NoError(t, err) + require.False(t, res.HasPermission) + require.False(t, res.IsConditional) + }) + + // Repeated checks must return consistent results (exercises the schema-tied + // compiled-caveat cache on the unified-schema path). + t.Run("repeated_consistent", func(t *testing.T) { + for i := 0; i < 3; i++ { + res, err := perms.Check(ctx, embedded.CheckRequest{ + ResourceType: "document", ResourceID: "doc1", Permission: "caveated_view", + SubjectType: "user", SubjectID: "bob", + CaveatContext: map[string]any{"day": "tuesday"}, + }) + require.NoError(t, err) + require.True(t, res.HasPermission) + } + }) + }) + } +} + +func TestNewPermissionsRequiresDatastore(t *testing.T) { + _, err := embedded.NewPermissions(embedded.Config{}) + require.Error(t, err) +}