Skip to content

FR-26219 Namespace every SpiceDB read to the resolved instance - #81

Open
dianaKhortiuk-frontegg wants to merge 10 commits into
fr-26219-sdk-scopingfrom
fr-26219-sdk-namespaced-reads
Open

dianaKhortiuk-frontegg wants to merge 10 commits into
fr-26219-sdk-scopingfrom
fr-26219-sdk-namespaced-reads

Conversation

@dianaKhortiuk-frontegg

@dianaKhortiuk-frontegg dianaKhortiuk-frontegg commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What

Every SpiceDB read the SDK issues is now namespaced to the resolved instance, and every public method takes an instanceId. Part 2/3 of the entitlements-client work for FR-26219, on top of #78. This is the only PR in the stack that changes behaviour.

Breaking: spiceClient becomes private. Direct access bypassed namespacing entirely, so the field could not stay reachable. Ships as a semver-major.

How

Every public method gains an optional trailing options:

await client.isEntitledTo(subject, request, { instanceId: 'eu' });
await client.lookupResources(subject, request, { instanceId: 'eu' });
await client.readSchemaFor({ instanceId: 'eu' });

The client resolves the instance once per call and hands its SchemaNamespace to the query strategy. Every gRPC-bound object type goes through namespace.type() and every type in a response goes through namespace.strip(), so prefixes never appear in SDK inputs or outputs.

Legacy callers pass nothing, resolve to the legacy instance, and build byte-identical requests to today.

Where the namespace is applied

Read path Prefixed
isEntitledTo / isEntitledToMany — feature, permission, route, entity resource type, subject type
lookupResources, lookupSubjects resource type, subject type, response types stripped
RouteSpiceDBQuery cache keyed per prefix, so two instances never share a route set
readSchemaFor returns only the definitions and caveats under that instance's prefix

Three decisions worth a look

1. instanceId is an options argument, not a request field. The request objects are shared with the v2 client and serialised into cache keys; a routing concern does not belong in them. One extra optional parameter is the smallest possible change for existing call sites.

2. Instance-resolution errors are rethrown ahead of the fallback. isEntitledTo catches everything and returns the configured fallback. An unknown instanceId, a missing default, or a / in a caller-supplied type is a caller bug, not an outage, and answering false (or true) would hide it. Those throw; everything else still falls back.

3. readSchemaFor is a read-only view. It filters the live schema by prefix using a brace-depth block splitter, so definitions with nested braces survive. The result drops top-level directives and is not writable back; that is documented on the method.

Not covered

lookup-response.mapper.ts is untouched: it echoes the caller's own type and decodes ids, so no prefix can reach a response through it. The three lookup log lines do not yet carry instanceId.

Testing

  • schema-namespace-threading.spec: every query path built twice, prefixed and legacy, asserting the exact gRPC request either way
  • spicedb-entitlements.client.instances.spec: resolution through the public API, the rethrow-vs-fallback split, a legacy caller keeping a namespaced entityType
  • spicedb-entitlements.client.read-schema.spec: nested braces, unknown prefix, legacy full schema
  • route-spicedb.query.spec: cache isolation between two prefixes
  • 255 tests, lint clean

Stack

#78#81#79. Needs the major release; #79 needs this merged first.


Note

High Risk
Changes core authorization request shaping and tenant/instance isolation; mis-routing or prefix bugs could deny or leak access across vendors, and making spiceClient private is a breaking API change.

Overview
Adds multi-instance configuration so one SpiceDB engine can serve several Frontegg vendors: each call can pass { instanceId }, reads resolve a schema prefix (from vendorId or override), and all gRPC object types are built via SchemaNamespace.type(). Legacy configs with no instances keep unprefixed, pass-through types (e.g. acme/document).

Public APIs (isEntitledTo, lookups, lookupEntitlements, readSchemaFor) take optional instance options; spiceClient is now private (semver-major). Instance-resolution failures and types containing / when instances are configured throw instead of using the error fallback; isEntitledToMany returns { result: false, error } per bad item. Logging log/error gain optional { instanceId }; route relationship cache is keyed per prefix.

readSchemaFor filters live schema text to one instance’s definition/caveat blocks (prefix stripped) via filterSchemaBlocks, throwing SchemaParseException on unsafe parse. Per-instance fallbackConfiguration overrides the client default.

Reviewed by Cursor Bugbot for commit 132411a. Bugbot is set up for automated code reviews on this repo. Configure here.

Verified against a running SpiceDB

The SDK was built and driven as a consumer would use it, against SpiceDB v1.42.1 holding two prefixed instances (v_aaaa1111, v_bbbb2222), each with its own feature:

Check Result
isEntitledTo with instanceId: 'eu' sees its own feature true
the same call on 'us' does not see eu's feature false — isolation holds
instanceId omitted with two configured InstanceIdRequiredException
unknown instanceId UnknownInstanceException
caller-supplied type containing / InvalidObjectTypeException, not a silent false
readSchemaFor('eu') own blocks present, no v_bbbb2222 blocks, prefix stripped from the output
one instance configured, instanceId omitted resolves implicitly

Two notes from doing it. Permission checks run at SpiceDB's default consistency, so a check fired immediately after a write can miss it — expected, but it makes naive tests flaky. And isEntitledTo returns the fallback on any internal error, so a misconfiguration is indistinguishable from a denial without logging.logResults.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/spicedb/spicedb-entitlements.client.ts Outdated
@dianaKhortiuk-frontegg
dianaKhortiuk-frontegg force-pushed the fr-26219-sdk-namespaced-reads branch from 25c14a6 to 1f9c52e Compare September 16, 2026 09:46

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1f9c52e. Configure here.

Comment thread src/instances/schema-blocks.utils.ts
Comment thread src/instances/schema-blocks.utils.ts
Applies the model from the previous PR to every read path. This is the PR that
changes existing behaviour, so it is the one to read for regressions.

The client resolves the instance before the try block, so a resolution failure
throws rather than degrading into the fallback boolean — a fallback would be
answering a question scoped to no instance at all. The resolved namespace is
threaded through all four query strategies, both lookup builders and the batch
feature path; the route cache key is now per prefix, where it previously held
every vendor's routes under one key.

New public surface: instanceId as a trailing options argument on all five
methods, readSchemaFor(instanceId), and per-instance fallback config. Raw
spiceClient is marked @deprecated. Log lines carry instanceId.

With no instances configured every request resolves to a legacy namespace and
the built gRPC requests are byte-identical to master.

253 tests, tsc clean, 0 lint errors.
InvalidObjectTypeException extends Error, not ConfigurationInputIsInvalidException,
so the rethrow guards did not cover it and a caller passing another instance's
prefix — v_other/document — was answered with the fallback boolean instead of an
error. That is the behaviour d05162d exists to prevent.

Single calls throw, which is what isEntitledTo should do for a caller-input
error. Batches fail per item instead: rethrowing inside the Promise.all made one
bad entityType discard every other result, so the offending item now carries
`error` while its neighbours answer normally. `result` is left undefined rather
than false, so nothing is granted and a caller can tell a refusal from a denial.

EntitlementsResult gains an optional `error`, which is additive for consumers.

Verified against an unreachable endpoint, so SpiceDB is never consulted: before,
isEntitledTo resolved to {"result":false}; now it throws. A three-item batch with
one bad entityType returns true / error / true.
The block splitter counted every brace in a line, including braces inside
string literals and comments. A caveat body containing x == "{" left the block
open, so everything after it was returned as part of that instance:

  definition v_aaa/user {}
  caveat v_aaa/c(x string) { x == "{" }
  definition v_bbb/secret {}

  readSchemaFor('a') -> "... definition v_bbb/secret {}"

SpiceDB v1.53.0 accepts that schema, so it was reachable, and it defeats the
reason readSchemaFor exists. Braces are now counted only outside single and
double quoted strings, line comments and block comments, with escapes handled.
A block-comment line is also no longer mistaken for a block header.

Six cases pinned in spicedb-entitlements.client.read-schema.spec.ts, including
that the instance's own caveat body still comes back intact.
BREAKING CHANGE: spiceClient is no longer public. Direct access bypasses
instance namespacing and can read another instance's data. Use isEntitledTo,
the lookup methods, or readSchemaFor with an instanceId instead.

The field carried @deprecated in an earlier revision of this branch. A private
field cannot be deprecated for callers who can no longer reach it, so the note
goes with it.
… document them

- ClientConfiguration extends InstancesConfiguration now that the client reads it
- specs follow the registry API from the base branch (instanceId on every namespace, 'legacy' id)
- README documents instances, defaultInstanceId, schemaPrefix and the resolution exceptions
…nstance, and fail closed on bad input

- the schema block parser lives in its own module, carries string and comment state across lines, and throws SchemaParseException on unbalanced input
- readSchemaFor takes options and returns the instance's blocks with its prefix stripped
- a failing isEntitledToMany item is { result: false, error } and is logged with its instanceId
- LoggingClient.log and error accept an optional { instanceId }
- only InvalidObjectTypeException is treated as caller input; prefix-escape specs run through the real query client
- route checks log the unprefixed type and key the cache by schema prefix
- the client builds its registry once; the injectable registry parameter is gone
…ranch

- InstanceOptions lives with the other instance types
- the schema block parser is a utils module with its constants in instance.constants.ts
A caveat body using a normal CEL field access broke readSchemaFor for every
instance, not just the one whose caveat it was:

  caveat v_aaa/c(attrs map<any>) {
    attrs.definition == 1
  }

SchemaParseException: 'definition' starts before the previous block is closed

The header scan already refused a keyword glued to an identifier or preceded by
a slash, but a dot passed, so the `definition` in `attrs.definition` looked like
a new block. It now refuses a dot too.

Failing closed rather than leaking was already the safer half of this; what was
left is that the read failed at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… canonical

The rewritten base made SchemaNamespace's constructor private behind
prefixed() and legacy(); three spec call sites replayed from this branch still
used new. They follow the base now rather than reopening the constructor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dianaKhortiuk-frontegg
dianaKhortiuk-frontegg force-pushed the fr-26219-sdk-namespaced-reads branch from f4a7ab0 to 8a0ce4a Compare September 16, 2026 11:56
SpiceDB accepts a comment between the keyword and the name, and this is
valid input:

  definition /* generated */ v_aaa/user {}

blockNameAfter skipped only whitespace, so the comment became the name, the
prefix check failed, and readSchemaFor dropped that instance's own block and
returned an empty schema rather than raising SchemaParseException. Silent and
wrong, which is the failure mode this splitter exists to prevent.

The name is now read past line and block comments, using the same
endOfComment the top-level scan already uses. Verified against SpiceDB
v1.42.1: it accepts the schema this rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants