Skip to content

Commit 96c22be

Browse files
committed
feat(embedded): add in-process permissions library
Add pkg/embedded, a focused library for running permission checks in-process against a datastore via the dispatch engine, without standing up a gRPC server. Caveat context is passed as native Go values. Includes a README and tests covering both legacy and unified schema modes.
1 parent 0c20d2e commit 96c22be

4 files changed

Lines changed: 520 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
99

1010
### Changed
1111
- Cache: switch to [otter](https://maypok86.github.io/otter/) as the primary cache implementation (https://github.com/authzed/spicedb/pull/3112)
12+
- 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)
13+
- 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)
14+
- 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)
1215

1316
### Fixed
1417
- 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)

pkg/embedded/README.md

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# embedded
2+
3+
`embedded` runs SpiceDB's permission engine **in-process, without a gRPC server**. It is
4+
intended for callers that embed SpiceDB as a library and want to issue permission checks
5+
directly against a datastore — paying neither network nor gRPC-serialization cost, and
6+
passing caveat context as native Go values rather than `structpb`.
7+
8+
It is a thin, focused wrapper over SpiceDB's dispatch engine (`computed.ComputeCheck` + a
9+
local dispatcher), exposing a single operation: `Check`.
10+
11+
## When to use it
12+
13+
- You already have (or can construct) a `datastore.Datastore` in your process and want fast,
14+
allocation-light permission checks against it.
15+
- You want to avoid the overhead of standing up an embedded gRPC server + in-process client
16+
(bufconn), the full server middleware chain, and the `structpb`/base64 caveat-context
17+
round-trip.
18+
19+
## When **not** to use it
20+
21+
- You need the full SpiceDB v1 API surface (schema writes, relationship writes, bulk
22+
operations, watch, lookup, reflection, etc.). This package only does `CheckPermission`.
23+
- You need remote access or multiple processes sharing one logical SpiceDB. Run a real
24+
SpiceDB server instead.
25+
26+
Checks are always **fully consistent** (evaluated at the datastore head revision).
27+
28+
## Quick start
29+
30+
```go
31+
package main
32+
33+
import (
34+
"context"
35+
"fmt"
36+
"log"
37+
38+
caveattypes "github.com/authzed/spicedb/pkg/caveats/types"
39+
dscfg "github.com/authzed/spicedb/pkg/cmd/datastore"
40+
"github.com/authzed/spicedb/pkg/datalayer"
41+
"github.com/authzed/spicedb/pkg/embedded"
42+
)
43+
44+
const bootstrap = `
45+
schema: |-
46+
definition user {}
47+
48+
caveat is_tuesday(day string) {
49+
day == "tuesday"
50+
}
51+
52+
definition document {
53+
relation viewer: user
54+
relation caveated_viewer: user with is_tuesday
55+
56+
permission view = viewer
57+
permission caveated_view = caveated_viewer
58+
}
59+
relationships: |-
60+
document:readme#viewer@user:alice
61+
62+
document:readme#caveated_viewer@user:bob[is_tuesday]
63+
`
64+
65+
func main() {
66+
ctx := context.Background()
67+
68+
// Any datastore works. Here we use an in-memory datastore populated from a bootstrap
69+
// document and storing schema in the unified ("single store") format.
70+
ds, err := dscfg.NewDatastore(ctx,
71+
dscfg.DefaultDatastoreConfig().ToOption(),
72+
dscfg.SetBootstrapFileContents(map[string][]byte{"bootstrap.yaml": []byte(bootstrap)}),
73+
dscfg.WithCaveatTypeSet(caveattypes.Default.TypeSet),
74+
dscfg.WithBootstrapSchemaMode(datalayer.SchemaModeReadNewWriteNew),
75+
)
76+
if err != nil {
77+
log.Fatal(err)
78+
}
79+
80+
perms, err := embedded.NewPermissions(embedded.Config{
81+
Datastore: ds,
82+
// Read schema from the unified store, and cache it across checks so schema-derived
83+
// caches (e.g. compiled caveats) persist and are not rebuilt per check.
84+
SchemaMode: datalayer.SchemaModeReadNewWriteNew,
85+
SchemaCacheMaxCostBytes: 16 << 20, // 16 MiB
86+
})
87+
if err != nil {
88+
log.Fatal(err)
89+
}
90+
defer perms.Close()
91+
92+
// Plain check.
93+
res, err := perms.Check(ctx, embedded.CheckRequest{
94+
ResourceType: "document", ResourceID: "readme", Permission: "view",
95+
SubjectType: "user", SubjectID: "alice",
96+
})
97+
if err != nil {
98+
log.Fatal(err)
99+
}
100+
fmt.Println("alice can view:", res.HasPermission) // true
101+
102+
// Caveated check — caveat context is passed as native Go values.
103+
res, err = perms.Check(ctx, embedded.CheckRequest{
104+
ResourceType: "document", ResourceID: "readme", Permission: "caveated_view",
105+
SubjectType: "user", SubjectID: "bob",
106+
CaveatContext: map[string]any{"day": "tuesday"},
107+
})
108+
if err != nil {
109+
log.Fatal(err)
110+
}
111+
fmt.Println("bob can view on tuesday:", res.HasPermission) // true
112+
113+
// Without the required context, the result is conditional rather than allowed/denied.
114+
res, _ = perms.Check(ctx, embedded.CheckRequest{
115+
ResourceType: "document", ResourceID: "readme", Permission: "caveated_view",
116+
SubjectType: "user", SubjectID: "bob",
117+
})
118+
fmt.Println("conditional:", res.IsConditional, "missing:", res.MissingContext)
119+
// conditional: true missing: [day]
120+
}
121+
```
122+
123+
You are not limited to bootstrap documents — pass any `datastore.Datastore` you have
124+
populated however you like (the relationships/schema must already be written).
125+
126+
## Configuration
127+
128+
`embedded.Config`:
129+
130+
| Field | Required | Default | Notes |
131+
|---|---|---|---|
132+
| `Datastore` | **yes** || The datastore to check against. The caller owns its lifecycle (`Close` does not close it). |
133+
| `CaveatTypeSet` | no | `caveattypes.Default` | Must match the type set the schema/caveats were written with. |
134+
| `SchemaMode` | no | legacy (per-definition) | Use `datalayer.SchemaModeReadNewWriteNew` (or `*Both`) to read the unified schema. Must match how the datastore's schema was written. |
135+
| `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. |
136+
| `DispatchConcurrencyLimit` | no | `10` | Max concurrent sub-dispatches per check. |
137+
| `DispatchChunkSize` | no | `100` | Datastore query / dispatch chunk size. |
138+
| `MaxDepth` | no | `50` | Maximum dispatch recursion depth. |
139+
140+
## The `Check` API
141+
142+
```go
143+
type CheckRequest struct {
144+
ResourceType string
145+
ResourceID string
146+
Permission string
147+
SubjectType string
148+
SubjectID string
149+
SubjectRelation string // optional; defaults to the "..." (ellipsis) relation
150+
CaveatContext map[string]any // native Go values; no structpb / base64
151+
}
152+
153+
type CheckResult struct {
154+
HasPermission bool // definitively a member of the permission
155+
IsConditional bool // membership depends on a caveat that lacked required context
156+
MissingContext []string // the caveat context fields that were required but not provided
157+
}
158+
```
159+
160+
- `HasPermission == true` → allowed.
161+
- `HasPermission == false && IsConditional == false` → denied.
162+
- `IsConditional == true` → a caveat could not be fully evaluated; supply the values named in
163+
`MissingContext` and check again.
164+
165+
## Caveat context
166+
167+
Because checks run in-process, caveat context is supplied directly as `map[string]any` and
168+
consumed by the caveat engine without conversion. For a caveat parameter typed `bytes`, the
169+
value must still be a base64-encoded string (the caveat type system decodes it); all other
170+
types accept their natural Go representation.
171+
172+
## Lifecycle
173+
174+
Call `Close` when finished to release the dispatcher. `Close` does **not** close the
175+
datastore you passed in — you own that.
176+
177+
A `Permissions` value is safe for concurrent use.

pkg/embedded/permissions.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Package embedded runs SpiceDB's permission engine in-process, without standing up a
2+
// gRPC server. It is intended for callers that embed SpiceDB as a library and want to
3+
// issue permission checks directly against a datastore, paying neither network nor
4+
// gRPC-serialization cost, and passing caveat context as native Go values.
5+
package embedded
6+
7+
import (
8+
"context"
9+
"errors"
10+
"fmt"
11+
12+
"github.com/authzed/spicedb/internal/dispatch"
13+
"github.com/authzed/spicedb/internal/dispatch/graph"
14+
"github.com/authzed/spicedb/internal/graph/computed"
15+
"github.com/authzed/spicedb/pkg/cache"
16+
caveattypes "github.com/authzed/spicedb/pkg/caveats/types"
17+
"github.com/authzed/spicedb/pkg/datalayer"
18+
"github.com/authzed/spicedb/pkg/datastore"
19+
dispatchv1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
20+
"github.com/authzed/spicedb/pkg/tuple"
21+
)
22+
23+
const (
24+
defaultConcurrencyLimit = 10
25+
defaultDispatchChunkSize = 100
26+
defaultMaxDepth = 50
27+
)
28+
29+
// Config configures a Permissions checker.
30+
type Config struct {
31+
// Datastore is the datastore to check against. Required. The caller owns its lifecycle.
32+
Datastore datastore.Datastore
33+
34+
// CaveatTypeSet is the caveat type set used to compile and evaluate caveats.
35+
// Defaults to caveattypes.Default.
36+
CaveatTypeSet *caveattypes.TypeSet
37+
38+
// SchemaMode controls how schema is read. The zero value reads legacy per-definition
39+
// schema. Use datalayer.SchemaModeReadNewWriteNew (or *Both) for the unified schema.
40+
SchemaMode datalayer.SchemaMode
41+
42+
// SchemaCacheMaxCostBytes, when > 0, enables an in-memory stored-schema cache of the
43+
// given size in bytes. Caching the stored schema across checks is what allows
44+
// schema-derived caches (e.g. compiled caveats) to persist; strongly recommended when
45+
// SchemaMode reads from the unified schema.
46+
SchemaCacheMaxCostBytes int64
47+
48+
// DispatchConcurrencyLimit, DispatchChunkSize, and MaxDepth use sane defaults if zero.
49+
DispatchConcurrencyLimit uint16
50+
DispatchChunkSize uint16
51+
MaxDepth uint32
52+
}
53+
54+
// Permissions issues in-process permission checks against a datastore.
55+
type Permissions struct {
56+
dl datalayer.DataLayer
57+
dispatcher dispatch.Dispatcher
58+
cts *caveattypes.TypeSet
59+
chunkSize uint16
60+
maxDepth uint32
61+
}
62+
63+
// NewPermissions builds an in-process permissions checker from the given config.
64+
func NewPermissions(cfg Config) (*Permissions, error) {
65+
if cfg.Datastore == nil {
66+
return nil, errors.New("embedded: Datastore is required")
67+
}
68+
69+
cts := cfg.CaveatTypeSet
70+
if cts == nil {
71+
cts = caveattypes.Default.TypeSet
72+
}
73+
concurrency := cfg.DispatchConcurrencyLimit
74+
if concurrency == 0 {
75+
concurrency = defaultConcurrencyLimit
76+
}
77+
chunkSize := cfg.DispatchChunkSize
78+
if chunkSize == 0 {
79+
chunkSize = defaultDispatchChunkSize
80+
}
81+
maxDepth := cfg.MaxDepth
82+
if maxDepth == 0 {
83+
maxDepth = defaultMaxDepth
84+
}
85+
86+
dlOpts := []datalayer.DataLayerOption{datalayer.WithSchemaMode(cfg.SchemaMode)}
87+
if cfg.SchemaCacheMaxCostBytes > 0 {
88+
schemaCache, err := cache.NewStandardCache[datalayer.SchemaCacheKey, *datalayer.CachedSchema](&cache.Config{
89+
MaxCost: cfg.SchemaCacheMaxCostBytes,
90+
})
91+
if err != nil {
92+
return nil, fmt.Errorf("embedded: failed to create stored schema cache: %w", err)
93+
}
94+
dlOpts = append(dlOpts, datalayer.WithSchemaCache(schemaCache))
95+
}
96+
dl := datalayer.NewDataLayer(cfg.Datastore, dlOpts...)
97+
98+
dispatcher, err := graph.NewLocalOnlyDispatcher(graph.DispatcherParameters{
99+
ConcurrencyLimits: graph.SharedConcurrencyLimits(concurrency),
100+
DispatchChunkSize: chunkSize,
101+
TypeSet: cts,
102+
})
103+
if err != nil {
104+
return nil, fmt.Errorf("embedded: failed to create dispatcher: %w", err)
105+
}
106+
107+
return &Permissions{
108+
dl: dl,
109+
dispatcher: dispatcher,
110+
cts: cts,
111+
chunkSize: chunkSize,
112+
maxDepth: maxDepth,
113+
}, nil
114+
}
115+
116+
// CheckRequest is a single, fully-consistent permission check. The caveat context is passed
117+
// as native Go values (no structpb / base64 round-trip).
118+
type CheckRequest struct {
119+
ResourceType string
120+
ResourceID string
121+
Permission string
122+
SubjectType string
123+
SubjectID string
124+
SubjectRelation string // optional; defaults to the ellipsis ("...") relation
125+
CaveatContext map[string]any
126+
}
127+
128+
// CheckResult is the outcome of a check.
129+
type CheckResult struct {
130+
// HasPermission is true when the subject is definitively a member of the permission.
131+
HasPermission bool
132+
// IsConditional is true when membership depends on a caveat that could not be fully
133+
// evaluated because required context was missing (see MissingContext).
134+
IsConditional bool
135+
// MissingContext lists the caveat context fields that were required but not provided.
136+
MissingContext []string
137+
}
138+
139+
// Check runs a single, fully-consistent permission check in-process.
140+
func (p *Permissions) Check(ctx context.Context, req CheckRequest) (CheckResult, error) {
141+
ctx = datalayer.ContextWithDataLayer(ctx, p.dl)
142+
143+
// The datalayer head revision yields the schema-mode-appropriate schema hash (a real
144+
// hash under the unified schema, or a legacy sentinel otherwise).
145+
revision, schemaHash, err := p.dl.HeadRevision(ctx)
146+
if err != nil {
147+
return CheckResult{}, err
148+
}
149+
150+
subjectRel := req.SubjectRelation
151+
if subjectRel == "" {
152+
subjectRel = tuple.Ellipsis
153+
}
154+
155+
cr, _, err := computed.ComputeCheck(ctx, p.dispatcher, p.cts,
156+
computed.CheckParameters{
157+
ResourceType: tuple.RR(req.ResourceType, req.Permission),
158+
Subject: tuple.ONR(req.SubjectType, req.SubjectID, subjectRel),
159+
CaveatContext: req.CaveatContext,
160+
AtRevision: revision,
161+
MaximumDepth: p.maxDepth,
162+
DebugOption: computed.NoDebugging,
163+
SchemaHash: schemaHash,
164+
},
165+
req.ResourceID,
166+
p.chunkSize,
167+
)
168+
if err != nil {
169+
return CheckResult{}, err
170+
}
171+
172+
switch cr.Membership {
173+
case dispatchv1.ResourceCheckResult_MEMBER:
174+
return CheckResult{HasPermission: true}, nil
175+
case dispatchv1.ResourceCheckResult_CAVEATED_MEMBER:
176+
return CheckResult{IsConditional: true, MissingContext: cr.MissingExprFields}, nil
177+
default:
178+
return CheckResult{}, nil
179+
}
180+
}
181+
182+
// Close releases resources held by the checker. It does not close the datastore.
183+
func (p *Permissions) Close() error {
184+
return p.dispatcher.Close()
185+
}

0 commit comments

Comments
 (0)