|
| 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