Skip to content

feat(instances): scope every SpiceDB read to one Frontegg instance (FR-26219) - #75

Closed
dianaKhortiuk-frontegg wants to merge 6 commits into
masterfrom
fr-26219-sdk-instance-isolation
Closed

feat(instances): scope every SpiceDB read to one Frontegg instance (FR-26219)#75
dianaKhortiuk-frontegg wants to merge 6 commits into
masterfrom
fr-26219-sdk-instance-isolation

Conversation

@dianaKhortiuk-frontegg

@dianaKhortiuk-frontegg dianaKhortiuk-frontegg commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Scopes every SpiceDB read in the SDK to exactly one Frontegg instance, so a single SpiceDB can serve several vendors with no cross-instance reads.

Workstream C (steps 8–10) of FR-26219. Depends on nothing else being merged first; entitlements-agent (workstream D) depends on this shipping and being published.

What changed

Step
C1 · config + instance registry instances / defaultInstanceId on ClientConfiguration; InstanceRegistry validates at construction and throws at boot, never at request time
C2 · SchemaScope adds the prefix on the way in, strips it on the way out, rejects any caller type containing /
C3 · resolution, fail closed all four resolution rules; an unknown or ambiguous instance throws
C4 · scope threaded everywhere four query strategies, both lookup builders, response mappers, batch feature path, route cache
C5 · public surface (additive) readSchemaFor(instanceId), per-instance fallback, instanceId on log lines, spiceClient marked @deprecated
C6 · regression guard spec fails on any request object type not built via scope.type()
C7 · backwards-compat proof legacy gRPC requests diffed byte-identical against master

No instances configured ⇒ a legacy instance with an empty prefix ⇒ behaviour identical to today.

The change that matters most

isEntitledTo catches every error and returns the configured fallback boolean. That would have turned a misconfigured instance into a silent false — a wrong answer to a question scoped to no instance at all.

Instance resolution now happens before the try block, so it throws and makes no gRPC call. A genuine SpiceDB error still returns the fallback, as before.

This was confirmed against real SpiceDB in the FR-26219 Step 0 spike: a check against an undefined prefix returns FailedPrecondition, not NO_PERMISSION. SpiceDB fails closed; the SDK was the part defeating it.

Verification

  • 238 tests pass (was 171). Every pre-existing test passes untouched apart from the added scope argument.
  • yarn build clean; lint warnings unchanged from master (33, zero errors).
  • C6 guard verified by deliberately reintroducing a raw object type — it fails, and passes again once reverted.
  • C7 proven by building the legacy lookup requests on both master and this branch and diffing the serialized gRPC objects: identical.

Three corrections to the written plan

The plan was written against an earlier state of this repo. Reviewers checking the code against it will find mismatches — these are the plan's errors, not the code's:

  1. Config field names. The plan says spiceDBEndpoint / spiceDBToken. This repo uses engineEndpoint / engineToken.
  2. The lookup API differs. The plan names lookupResources / lookupSubjects. The real public methods are lookupTargetEntities, lookupEntities and lookupEntitlements — the last with cursor-paginated multi-stream logic the plan does not mention. All three now accept instanceId.
  3. The plan's C4 table is missing call sites. isEntitledToMany / spiceDBBatchFeatureQuery (an entire batch path), plus isLookupEntitlementsTenantMember and getLookupEntitlementsStreams inside the client, all set object types directly. Left unscoped they would have leaked.

And one bug following the plan literally would have introduced: in route-spicedb.query.ts, firstRule.resourceType is read back from SpiceDB and is therefore already prefixed. Scoping it as C4 instructs yields v_a/v_a/frontegg_route. It needs scope.strip() first — done, with a test pinning it.

Reviewing

Commits split cleanly if you want them separate:

  • aefcd95 — registry, scope, resolution, threading
  • e1a4ec8readSchemaFor, logging, deprecation

Worth a close look at route-spicedb.query.ts (the strip-then-scope round trip and the per-prefix cache key) and at schema-scope.guard.spec.ts, which is regex over source rather than an ESLint rule — an ESLint rule can't see that objectType: needs scope.type() without type information, but a creative enough refactor could slip past this guard.

Not in this PR

  • C8 · wrapper passthrough (template-and-libs/nestjs-packages/e10s-client-wrapper) — I don't have access to that repo.
  • The isolation integration suite (§10.2) — needs a real SpiceDB plus the syncer, so it is gated on workstream B.

Adds a per-instance schema prefix so one SpiceDB can serve several
Frontegg vendors with no cross-instance reads.

- InstanceRegistry validates instances at construction, never at request
  time; no `instances` synthesises a legacy instance with an empty prefix
- SchemaScope prefixes object types on the way in, strips on the way out,
  and rejects any caller type containing '/'
- resolveInstance implements the four resolution rules and throws
  InstanceResolutionException rather than returning a fallback boolean
- scope threaded through all four query strategies, both lookup builders,
  the response mappers and the batch feature path
- route relationship cache is keyed per prefix
- fallback configuration resolves per instance, then client-wide
- guard spec fails on any request object type not built via scope.type()

Legacy single-instance behaviour is unchanged: all 171 pre-existing tests
pass untouched apart from the added scope argument.

FR-26219
…ate raw spiceClient

readSchemaFor(instanceId) returns only that prefix's definitions and
caveats, closing the schema leak where any caller could read every
vendor's schema. Log lines now carry instanceId, and the raw spiceClient
escape hatch is marked deprecated for multi-instance use.

FR-26219
Code review of #75 found the prefix-escape guard was defeated by the same
catch-all this PR was written to fix.

- executeEntitlementQuery and resolveFeatureEntitlements now rethrow
  ConfigurationInputIsInvalidException alongside InstanceResolutionException,
  so an entityType containing '/' propagates instead of becoming the
  configured fallback boolean. With defaultFallback: true it was returning
  an allow. Covered by tests that go through isEntitledTo / isEntitledToMany
  rather than the strategy directly, which is how the original tests missed it.
- InstanceRegistry now defaults its defaultInstanceId parameter from the
  configuration object, so the config field is honoured and validated at
  construction instead of being silently dropped.
- The scope guard also treats direct client.<method>({...}) calls as request
  spans, so an object type in a plain literal is no longer invisible to it.
- resolveInstance catches a rejected logRequest rather than leaving an
  unhandled rejection.
- Dropped the no-op scope.strip() in the lookup response mappers: it was
  stripping the caller's own unprefixed input, not anything returned, which
  read as protection that was not there.

FR-26219

@mariavlasov mariavlasov 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.

Reviewed this against the description. Two framing notes before the line items:

  • This scopes SpiceDB reads/writes for multiple Frontegg instances/vendors behind one shared engineEndpoint/engineToken and one SpiceDB, partitioned by schema prefix — it doesn't add support for separate credentials per account (ClientConfiguration still has a single engineEndpoint/engineToken). Flagging in case the ask was literally per-account credentials rather than logical partitioning.
  • There are regexes in both production and test code: schema-prefix.ts (SPICEDB_PREFIX_PATTERN, .replace(/-/g, '_')), spicedb-entitlements.client.ts readSchemaFor (split(/\n(?=definition |caveat )/)), and schema-scope.guard.spec.ts (OBJECT_TYPE_FIELD, REQUEST_SPAN_START). Calling this out since regex-free was a stated goal.

Findings

1. [High / data-leak risk] No duplicate-schema-prefix validationsrc/instances/instance-registry.ts, assertInstance
assertInstance rejects a duplicate raw instanceId or vendorId, but never checks whether two instances resolve to the same schemaPrefix — either two explicit overrides colliding, or two different vendorIds normalizing to the same derived prefix (deriveSchemaPrefix lowercases and swaps -_, so e.g. "ACME-CORP" and "acme_corp" derive to the same prefix). If that happens, two vendors silently share the same SpiceDB namespace — the exact cross-tenant leak this PR exists to prevent. Suggest tracking seenSchemaPrefixes alongside seenVendorIds and throwing on collision.

2. [Medium] schema-scope.guard.spec.ts guard is regex-based and admittedly incomplete
The PR description already flags this ("a creative enough refactor could slip past this guard") — agreed, and worth taking seriously since this is the only thing guarding against an unscoped objectType leaking cross-instance data. REQUEST_SPAN_START only recognizes three call shapes; a request built any other way won't be scanned, with no failure signal. Given this backs a real security property, I'd treat "replace with an actual ESLint rule with type info" as a near-term follow-up rather than a someday item.

3. [Low] readSchemaFor's text-split heuristicspicedb-entitlements.client.ts
schemaText.split(/\n(?=definition |caveat )/) is a plain-text heuristic over the SpiceDB schema DSL rather than a real parse. Low risk of an actual cross-instance leak given the trailing / in the marker check, but it'll silently misplace blocks with leading comments or non-standard formatting. Worth a comment noting the assumption, or a real parser if one's available.

4. [Low] Exception messages enumerate configured instance/vendor IDs
UnknownInstanceException, InstanceIdRequiredException, and the ConfigurationInputIsInvalidException variants all include the full configured instanceIds/vendorId list in the thrown message. Fine if these stay internal, but if a caller (e.g. entitlements-agent) ever bubbles an SDK exception message straight into an API response, that's an info leak of the vendor list to an unauthenticated or cross-tenant caller. Worth a doc note that callers should catch and sanitize before surfacing.

5. [Nit] Dead-looking branchspicedb-entitlements.client.ts, executeEntitlementQuery catch block
err instanceof InstanceResolutionException can't actually be thrown here anymore since resolution now happens in the caller before this method runs. Harmless as defense-in-depth, but worth a comment on why it's there (or drop it if genuinely unreachable) so it doesn't look like dead code.

6. [Nit] Redundant default-instance-id wiringentitlements-client-factory.ts
new InstanceRegistry(configuration, configuration.defaultInstanceId) — the second constructor param already defaults to configuration.defaultInstanceId, so this is two ways of saying the same thing.

7. [Nit] API asymmetryinstanceId placement
isEntitledTo/isEntitledToMany take instanceId via a separate options param, while the lookup* methods embed it in the request object (req: X & InstanceOptions). Not wrong, just inconsistent for callers to remember.

Performance-wise nothing stood out: prefix derivation/validation happens once at registry construction (not per request), and the route cache is correctly keyed per schema prefix so it scales with configured instance count, not request volume.

Not requesting changes — flagging #1 as the one I'd want addressed before merge, the rest are discretionary.

Review feedback on #75.

- InstanceRegistry now rejects two instances that resolve to the same
  schemaPrefix, whether from colliding explicit overrides or from two
  vendorIds that normalise to the same value ("ACME-CORP" and "acme_corp"
  both derive v_acme_corp). Without this two vendors silently share one
  SpiceDB namespace, which is the leak this work exists to prevent. Two
  explicitly-legacy instances are rejected on the same grounds.
- readSchemaFor splits the schema by tracking brace depth rather than by a
  regex lookahead, so blocks that are indented or preceded by comments are
  attributed correctly instead of being silently dropped or merged.
- UnknownInstanceException and InstanceIdRequiredException no longer put the
  configured instance list in the message; it stays available as
  configuredInstanceIds for debugging, so a caller that surfaces err.message
  cannot leak the vendor list.
- Dropped the InstanceResolutionException branch from the query catch blocks;
  resolution happens in the caller, so it was unreachable.
- Dropped the redundant defaultInstanceId argument at both InstanceRegistry
  call sites now that the parameter defaults from the configuration object.

FR-26219
…rule

The guard was regex over source text: it only recognised three call shapes,
so a request built any other way was invisible to it with no failure signal.
That was the one thing holding the scoping invariant, so it is now a real
ESLint rule with type information.

eslint-rules/require-scoped-object-type.js flags any objectType /
resourceObjectType / subjectObjectType / resourceType property whose
contextual type originates in @authzed/authzed-node and whose value is not a
scope.type(...) call. Keying on the declaring package is what separates a
genuine request field from a log payload or a value read back off a response,
which the regex version could only approximate.

Contextual types arrive as PartialMessage<T> | undefined, so the rule walks
union members and generic arguments rather than reading the property off the
top-level type, which resolves to nothing.

Verified against three planted violations: a raw type inside v1.X.create(),
a plain object literal passed straight to client.checkPermission() (the shape
the regex guard missed entirely), and a RelationshipFilter.resourceType. All
three are reported; the existing log payloads and response reads are not.

schema-scope.guard.spec.ts is deleted. Its replacement asserts the rule stays
wired as an error, with a files glob covering src and with parserOptions
.project set — without type information the rule silently matches nothing,
which is the failure mode worth guarding.

Addresses review finding 2 on #75.
@dianaKhortiuk-frontegg

dianaKhortiuk-frontegg commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all seven addressed. 0be4a5b + da8ed31 + 855d684, CI green, 252 tests.

#
1 Registry now rejects instances resolving to the same prefix — colliding overrides, your ACME-CORP/acme_corp normalisation case, and two explicitly-legacy instances. Real bug, good catch.
2 Replaced the regex guard with a typed ESLint rule keyed on whether the field's contextual type comes from @authzed/authzed-node. Caught all three planted violations, including the plain-literal shape the old guard missed.
3 readSchemaFor splits by brace depth instead of a regex lookahead; test covers indented and comment-preceded blocks.
4 Instance lists moved off exception messages onto configuredInstanceIds. Left the boot-time config errors as-is — they never reach a request path.
5 Dropped, it was unreachable.
6 Dropped.
7 All five methods now take instanceId as a trailing options argument, lookup* included. Done now since the API is unreleased.

One thing back to you: regex-free — I can't find this as a convention here (no CONTRIBUTING or lint rule, and master uses regex in base64.utils.ts, entitlements-spicedb.query.ts and route matching). I removed the two fragile ones anyway; the only one left in my diff is SPICEDB_PREFIX_PATTERN, which is SpiceDB's own identifier rule. If there's a norm I'm missing, point me at it and I'll do a pass.

On single credentials — correct, and intentional: that's D5 in the plan (one SpiceDB, one preshared key, so the agent process is the trust boundary). Per-prefix credentials don't exist in self-hosted SpiceDB.

…method

lookupTargetEntities, lookupEntities and lookupEntitlements took instanceId
inside the request object while isEntitledTo and isEntitledToMany took it as a
trailing options argument. All five now use the options argument, so callers
have one thing to remember.

Doing it now because the multi-instance API is unreleased; once shipped this
becomes a breaking change.

Addresses review finding 7 on #75.
@dianaKhortiuk-frontegg

Copy link
Copy Markdown
Contributor Author

Split as requested — part 1 is #78 (the scoping mechanism).

Part 2 is the review follow-ups: prefix-collision rejection, the typed ESLint rule replacing the regex guard, and instanceId as an options argument on every method. Branch is pushed as fr-26219-sdk-hardening; I'll open it once #78 merges — this repo's CI only runs for PRs targeting master/next, so a stacked PR would get no checks at all.

Closing this one.

dianaKhortiuk-frontegg added a commit that referenced this pull request Sep 9, 2026
Review feedback on #75.

- InstanceRegistry now rejects two instances that resolve to the same
  schemaPrefix, whether from colliding explicit overrides or from two
  vendorIds that normalise to the same value ("ACME-CORP" and "acme_corp"
  both derive v_acme_corp). Without this two vendors silently share one
  SpiceDB namespace, which is the leak this work exists to prevent. Two
  explicitly-legacy instances are rejected on the same grounds.
- readSchemaFor splits the schema by tracking brace depth rather than by a
  regex lookahead, so blocks that are indented or preceded by comments are
  attributed correctly instead of being silently dropped or merged.
- UnknownInstanceException and InstanceIdRequiredException no longer put the
  configured instance list in the message; it stays available as
  configuredInstanceIds for debugging, so a caller that surfaces err.message
  cannot leak the vendor list.
- Dropped the InstanceResolutionException branch from the query catch blocks;
  resolution happens in the caller, so it was unreachable.
- Dropped the redundant defaultInstanceId argument at both InstanceRegistry
  call sites now that the parameter defaults from the configuration object.

FR-26219
dianaKhortiuk-frontegg added a commit that referenced this pull request Sep 9, 2026
…rule

The guard was regex over source text: it only recognised three call shapes,
so a request built any other way was invisible to it with no failure signal.
That was the one thing holding the scoping invariant, so it is now a real
ESLint rule with type information.

eslint-rules/require-scoped-object-type.js flags any objectType /
resourceObjectType / subjectObjectType / resourceType property whose
contextual type originates in @authzed/authzed-node and whose value is not a
scope.type(...) call. Keying on the declaring package is what separates a
genuine request field from a log payload or a value read back off a response,
which the regex version could only approximate.

Contextual types arrive as PartialMessage<T> | undefined, so the rule walks
union members and generic arguments rather than reading the property off the
top-level type, which resolves to nothing.

Verified against three planted violations: a raw type inside v1.X.create(),
a plain object literal passed straight to client.checkPermission() (the shape
the regex guard missed entirely), and a RelationshipFilter.resourceType. All
three are reported; the existing log payloads and response reads are not.

schema-scope.guard.spec.ts is deleted. Its replacement asserts the rule stays
wired as an error, with a files glob covering src and with parserOptions
.project set — without type information the rule silently matches nothing,
which is the failure mode worth guarding.

Addresses review finding 2 on #75.
dianaKhortiuk-frontegg added a commit that referenced this pull request Sep 9, 2026
…method

lookupTargetEntities, lookupEntities and lookupEntitlements took instanceId
inside the request object while isEntitledTo and isEntitledToMany took it as a
trailing options argument. All five now use the options argument, so callers
have one thing to remember.

Doing it now because the multi-instance API is unreleased; once shipped this
becomes a breaking change.

Addresses review finding 7 on #75.
dianaKhortiuk-frontegg added a commit that referenced this pull request Sep 9, 2026
Review feedback on #75.

- InstanceRegistry now rejects two instances that resolve to the same
  schemaPrefix, whether from colliding explicit overrides or from two
  vendorIds that normalise to the same value ("ACME-CORP" and "acme_corp"
  both derive v_acme_corp). Without this two vendors silently share one
  SpiceDB namespace, which is the leak this work exists to prevent. Two
  explicitly-legacy instances are rejected on the same grounds.
- readSchemaFor splits the schema by tracking brace depth rather than by a
  regex lookahead, so blocks that are indented or preceded by comments are
  attributed correctly instead of being silently dropped or merged.
- UnknownInstanceException and InstanceIdRequiredException no longer put the
  configured instance list in the message; it stays available as
  configuredInstanceIds for debugging, so a caller that surfaces err.message
  cannot leak the vendor list.
- Dropped the InstanceResolutionException branch from the query catch blocks;
  resolution happens in the caller, so it was unreachable.
- Dropped the redundant defaultInstanceId argument at both InstanceRegistry
  call sites now that the parameter defaults from the configuration object.

FR-26219
dianaKhortiuk-frontegg added a commit that referenced this pull request Sep 9, 2026
…rule

The guard was regex over source text: it only recognised three call shapes,
so a request built any other way was invisible to it with no failure signal.
That was the one thing holding the scoping invariant, so it is now a real
ESLint rule with type information.

eslint-rules/require-scoped-object-type.js flags any objectType /
resourceObjectType / subjectObjectType / resourceType property whose
contextual type originates in @authzed/authzed-node and whose value is not a
scope.type(...) call. Keying on the declaring package is what separates a
genuine request field from a log payload or a value read back off a response,
which the regex version could only approximate.

Contextual types arrive as PartialMessage<T> | undefined, so the rule walks
union members and generic arguments rather than reading the property off the
top-level type, which resolves to nothing.

Verified against three planted violations: a raw type inside v1.X.create(),
a plain object literal passed straight to client.checkPermission() (the shape
the regex guard missed entirely), and a RelationshipFilter.resourceType. All
three are reported; the existing log payloads and response reads are not.

schema-scope.guard.spec.ts is deleted. Its replacement asserts the rule stays
wired as an error, with a files glob covering src and with parserOptions
.project set — without type information the rule silently matches nothing,
which is the failure mode worth guarding.

Addresses review finding 2 on #75.
dianaKhortiuk-frontegg added a commit that referenced this pull request Sep 9, 2026
…method

lookupTargetEntities, lookupEntities and lookupEntitlements took instanceId
inside the request object while isEntitledTo and isEntitledToMany took it as a
trailing options argument. All five now use the options argument, so callers
have one thing to remember.

Doing it now because the multi-instance API is unreleased; once shipped this
becomes a breaking change.

Addresses review finding 7 on #75.
dianaKhortiuk-frontegg added a commit that referenced this pull request Sep 9, 2026
Review feedback on #75.

- InstanceRegistry now rejects two instances that resolve to the same
  schemaPrefix, whether from colliding explicit overrides or from two
  vendorIds that normalise to the same value ("ACME-CORP" and "acme_corp"
  both derive v_acme_corp). Without this two vendors silently share one
  SpiceDB namespace, which is the leak this work exists to prevent. Two
  explicitly-legacy instances are rejected on the same grounds.
- readSchemaFor splits the schema by tracking brace depth rather than by a
  regex lookahead, so blocks that are indented or preceded by comments are
  attributed correctly instead of being silently dropped or merged.
- UnknownInstanceException and InstanceIdRequiredException no longer put the
  configured instance list in the message; it stays available as
  configuredInstanceIds for debugging, so a caller that surfaces err.message
  cannot leak the vendor list.
- Dropped the InstanceResolutionException branch from the query catch blocks;
  resolution happens in the caller, so it was unreachable.
- Dropped the redundant defaultInstanceId argument at both InstanceRegistry
  call sites now that the parameter defaults from the configuration object.

FR-26219
dianaKhortiuk-frontegg added a commit that referenced this pull request Sep 9, 2026
…method

lookupTargetEntities, lookupEntities and lookupEntitlements took instanceId
inside the request object while isEntitledTo and isEntitledToMany took it as a
trailing options argument. All five now use the options argument, so callers
have one thing to remember.

Doing it now because the multi-instance API is unreleased; once shipped this
becomes a breaking change.

Addresses review finding 7 on #75.
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