From aefcd95767ed9667141efd2de20f426a1df2e17d Mon Sep 17 00:00:00 2001 From: dianaKhortiuk-frontegg Date: Fri, 28 Aug 2026 14:20:53 +0300 Subject: [PATCH 1/6] feat(instances): scope every SpiceDB read to one Frontegg instance 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 --- src/client-configuration.ts | 9 + src/entitlements-client-factory.ts | 6 +- src/exceptions/index.ts | 5 + .../instance-id-required.exception.ts | 10 + .../instance-resolution.exception.ts | 6 + src/exceptions/unknown-instance.exception.ts | 14 ++ src/index.ts | 2 + src/instances/index.ts | 4 + src/instances/instance-registry.spec.ts | 106 ++++++++ src/instances/instance-registry.ts | 110 +++++++++ src/instances/resolve-instance.spec.ts | 65 +++++ src/instances/resolve-instance.ts | 37 +++ src/instances/schema-prefix.spec.ts | 38 +++ src/instances/schema-prefix.ts | 9 + src/instances/schema-scope.guard.spec.ts | 76 ++++++ src/instances/schema-scope.spec.ts | 49 ++++ src/instances/schema-scope.ts | 32 +++ ...cedb-entitlements.client.instances.spec.ts | 181 ++++++++++++++ .../spicedb-entitlements.client.spec.ts | 72 ++++-- src/spicedb/spicedb-entitlements.client.ts | 151 ++++++++---- .../entitlements-spicedb.query.spec-helper.ts | 36 ++- .../entitlements-spicedb.query.spec.ts | 27 ++- .../entitlements-spicedb.query.ts | 33 ++- .../features-spicedb.query.spec.ts | 5 +- .../spicedb-queries/features-spicedb.query.ts | 16 +- .../spicedb-queries/fga-spicedb.query.spec.ts | 31 ++- .../spicedb-queries/fga-spicedb.query.ts | 13 +- .../lookup-request.builder.spec.ts | 139 ++++++----- .../spicedb-queries/lookup-request.builder.ts | 24 +- .../lookup-response.mapper.spec.ts | 23 +- .../spicedb-queries/lookup-response.mapper.ts | 11 +- .../permission-spicedb.query.ts | 13 +- .../route-spicedb.query.spec.ts | 102 +++++--- .../spicedb-queries/route-spicedb.query.ts | 19 +- .../schema-scope-threading.spec.ts | 229 ++++++++++++++++++ .../spicedb-query.client.spec.ts | 7 +- .../spicedb-queries/spicedb-query.client.ts | 12 +- 37 files changed, 1477 insertions(+), 245 deletions(-) create mode 100644 src/exceptions/index.ts create mode 100644 src/exceptions/instance-id-required.exception.ts create mode 100644 src/exceptions/instance-resolution.exception.ts create mode 100644 src/exceptions/unknown-instance.exception.ts create mode 100644 src/instances/index.ts create mode 100644 src/instances/instance-registry.spec.ts create mode 100644 src/instances/instance-registry.ts create mode 100644 src/instances/resolve-instance.spec.ts create mode 100644 src/instances/resolve-instance.ts create mode 100644 src/instances/schema-prefix.spec.ts create mode 100644 src/instances/schema-prefix.ts create mode 100644 src/instances/schema-scope.guard.spec.ts create mode 100644 src/instances/schema-scope.spec.ts create mode 100644 src/instances/schema-scope.ts create mode 100644 src/spicedb/spicedb-entitlements.client.instances.spec.ts create mode 100644 src/spicedb/spicedb-queries/schema-scope-threading.spec.ts diff --git a/src/client-configuration.ts b/src/client-configuration.ts index d34128f..5c18b4c 100644 --- a/src/client-configuration.ts +++ b/src/client-configuration.ts @@ -1,9 +1,18 @@ import { LoggingClient } from './logging'; import { RequestContext, RequestContextType } from './types'; +export interface InstanceConfiguration { + instanceId: string; + vendorId: string; + schemaPrefix?: string; + fallbackConfiguration?: FallbackConfiguration; +} + export interface ClientConfiguration { engineEndpoint: string; engineToken: string; + instances?: InstanceConfiguration[]; + defaultInstanceId?: string; logging?: { client?: LoggingClient; logResults?: boolean; diff --git a/src/entitlements-client-factory.ts b/src/entitlements-client-factory.ts index 13fa10a..d898b80 100644 --- a/src/entitlements-client-factory.ts +++ b/src/entitlements-client-factory.ts @@ -2,6 +2,7 @@ import { ClientConfiguration } from './client-configuration'; import { LoggingClient, SimpleLoggingClient } from './logging'; import { ConfigurationInputIsMissingException } from './exceptions/configuration-input-is-missing.exception'; import { SpiceDBEntitlementsClient } from './spicedb/spicedb-entitlements.client'; +import { InstanceRegistry } from './instances/instance-registry'; export class EntitlementsClientFactory { public static create(configuration: ClientConfiguration): SpiceDBEntitlementsClient { @@ -13,13 +14,16 @@ export class EntitlementsClientFactory { throw new ConfigurationInputIsMissingException('engineToken is required'); } + const registry = new InstanceRegistry(configuration, configuration.defaultInstanceId); + const { loggingClient, logResults } = this.configureLoggingClient(configuration.logging); return new SpiceDBEntitlementsClient( configuration, loggingClient, logResults, - configuration.fallbackConfiguration + configuration.fallbackConfiguration, + registry ); } diff --git a/src/exceptions/index.ts b/src/exceptions/index.ts new file mode 100644 index 0000000..6e4aca0 --- /dev/null +++ b/src/exceptions/index.ts @@ -0,0 +1,5 @@ +export * from './configuration-input-is-invalid.exception'; +export * from './configuration-input-is-missing.exception'; +export * from './instance-resolution.exception'; +export * from './unknown-instance.exception'; +export * from './instance-id-required.exception'; diff --git a/src/exceptions/instance-id-required.exception.ts b/src/exceptions/instance-id-required.exception.ts new file mode 100644 index 0000000..06ceb48 --- /dev/null +++ b/src/exceptions/instance-id-required.exception.ts @@ -0,0 +1,10 @@ +import { InstanceResolutionException } from './instance-resolution.exception'; + +export class InstanceIdRequiredException extends InstanceResolutionException { + constructor(configuredInstanceIds: string[]) { + super( + `instanceId is required when more than one instance is configured and no defaultInstanceId is set. ` + + `Configured instances: ${configuredInstanceIds.join(', ')}` + ); + } +} diff --git a/src/exceptions/instance-resolution.exception.ts b/src/exceptions/instance-resolution.exception.ts new file mode 100644 index 0000000..06f96df --- /dev/null +++ b/src/exceptions/instance-resolution.exception.ts @@ -0,0 +1,6 @@ +export abstract class InstanceResolutionException extends Error { + protected constructor(message: string) { + super(message); + this.name = new.target.name; + } +} diff --git a/src/exceptions/unknown-instance.exception.ts b/src/exceptions/unknown-instance.exception.ts new file mode 100644 index 0000000..983e0c7 --- /dev/null +++ b/src/exceptions/unknown-instance.exception.ts @@ -0,0 +1,14 @@ +import { InstanceResolutionException } from './instance-resolution.exception'; + +export class UnknownInstanceException extends InstanceResolutionException { + constructor( + public readonly instanceId: string, + configuredInstanceIds: string[] + ) { + super( + `Unknown instanceId '${instanceId}'. Configured instances: ${ + configuredInstanceIds.length ? configuredInstanceIds.join(', ') : '' + }` + ); + } +} diff --git a/src/index.ts b/src/index.ts index 4887a97..d85cff1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,5 @@ export * from './types'; export * from './client-configuration'; export * from './entitlements-client-factory'; export * from './spicedb/spicedb-entitlements.client'; +export * from './instances'; +export * from './exceptions'; diff --git a/src/instances/index.ts b/src/instances/index.ts new file mode 100644 index 0000000..11ed2a9 --- /dev/null +++ b/src/instances/index.ts @@ -0,0 +1,4 @@ +export * from './schema-scope'; +export * from './schema-prefix'; +export * from './instance-registry'; +export * from './resolve-instance'; diff --git a/src/instances/instance-registry.spec.ts b/src/instances/instance-registry.spec.ts new file mode 100644 index 0000000..96b3986 --- /dev/null +++ b/src/instances/instance-registry.spec.ts @@ -0,0 +1,106 @@ +import { InstanceRegistry, LEGACY_INSTANCE_ID } from './instance-registry'; +import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception'; + +const VENDOR_A = '2f9c1a44-7b0e-4a1e-9f8a-1c2d3e4f5a6b'; +const VENDOR_B = '8b1d0e77-3c5a-4f2b-9d6e-7a8b9c0d1e2f'; + +describe(InstanceRegistry.name, () => { + it('should synthesise a legacy instance when no instances are configured', () => { + const registry = new InstanceRegistry({}); + + expect(registry.size).toBe(1); + expect(registry.instanceIds).toEqual([LEGACY_INSTANCE_ID]); + expect(registry.onlyInstance.scope.isLegacy).toBe(true); + }); + + it('should synthesise a legacy instance for an empty instances array', () => { + const registry = new InstanceRegistry({ instances: [] }); + + expect(registry.onlyInstance.scope.isLegacy).toBe(true); + }); + + it('should derive a schema prefix from each vendorId', () => { + const registry = new InstanceRegistry({ + instances: [ + { instanceId: 'a', vendorId: VENDOR_A }, + { instanceId: 'b', vendorId: VENDOR_B } + ] + }); + + expect(registry.get('a')?.scope.schemaPrefix).toBe('v_2f9c1a44_7b0e_4a1e_9f8a_1c2d3e4f5a6b'); + expect(registry.get('b')?.scope.schemaPrefix).toBe('v_8b1d0e77_3c5a_4f2b_9d6e_7a8b9c0d1e2f'); + }); + + it('should honour an explicit schemaPrefix override', () => { + const registry = new InstanceRegistry({ + instances: [{ instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: 'v_custom' }] + }); + + expect(registry.get('a')?.scope.schemaPrefix).toBe('v_custom'); + }); + + it('should treat an explicit empty schemaPrefix as legacy', () => { + const registry = new InstanceRegistry({ + instances: [{ instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: '' }] + }); + + expect(registry.get('a')?.scope.isLegacy).toBe(true); + }); + + it('should throw on a missing instanceId', () => { + expect(() => new InstanceRegistry({ instances: [{ instanceId: '', vendorId: VENDOR_A }] })).toThrow( + ConfigurationInputIsInvalidException + ); + }); + + it('should throw on a missing vendorId', () => { + expect(() => new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: '' }] })).toThrow( + ConfigurationInputIsInvalidException + ); + }); + + it('should throw on a duplicate instanceId', () => { + expect( + () => + new InstanceRegistry({ + instances: [ + { instanceId: 'a', vendorId: VENDOR_A }, + { instanceId: 'a', vendorId: VENDOR_B } + ] + }) + ).toThrow(ConfigurationInputIsInvalidException); + }); + + it('should throw on a duplicate vendorId', () => { + expect( + () => + new InstanceRegistry({ + instances: [ + { instanceId: 'a', vendorId: VENDOR_A }, + { instanceId: 'b', vendorId: VENDOR_A } + ] + }) + ).toThrow(ConfigurationInputIsInvalidException); + }); + + it('should throw on an invalid schemaPrefix', () => { + expect( + () => + new InstanceRegistry({ + instances: [{ instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: 'Not/Valid' }] + }) + ).toThrow(ConfigurationInputIsInvalidException); + }); + + it('should throw when defaultInstanceId is not a configured instance', () => { + expect(() => new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: VENDOR_A }] }, 'missing')).toThrow( + ConfigurationInputIsInvalidException + ); + }); + + it('should accept a defaultInstanceId that is configured', () => { + const registry = new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: VENDOR_A }] }, 'a'); + + expect(registry.defaultInstanceId).toBe('a'); + }); +}); diff --git a/src/instances/instance-registry.ts b/src/instances/instance-registry.ts new file mode 100644 index 0000000..3c1a016 --- /dev/null +++ b/src/instances/instance-registry.ts @@ -0,0 +1,110 @@ +import { ClientConfiguration, FallbackConfiguration, InstanceConfiguration } from '../client-configuration'; +import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception'; +import { SchemaScope } from './schema-scope'; +import { deriveSchemaPrefix, isValidSchemaPrefix } from './schema-prefix'; + +export const LEGACY_INSTANCE_ID = 'default'; + +export interface ResolvedInstance { + instanceId: string; + vendorId?: string; + scope: SchemaScope; + fallbackConfiguration?: FallbackConfiguration; +} + +export class InstanceRegistry { + private readonly instances: Map; + private readonly order: string[]; + + constructor( + configuration: Pick, + public readonly defaultInstanceId?: string + ) { + const declared = configuration.instances; + this.instances = new Map(); + this.order = []; + + if (!declared || declared.length === 0) { + const legacy: ResolvedInstance = { + instanceId: LEGACY_INSTANCE_ID, + scope: new SchemaScope('') + }; + this.instances.set(legacy.instanceId, legacy); + this.order.push(legacy.instanceId); + } else { + const seenVendorIds = new Set(); + for (const instance of declared) { + this.assertInstance(instance, seenVendorIds); + const resolved: ResolvedInstance = { + instanceId: instance.instanceId, + vendorId: instance.vendorId, + scope: new SchemaScope(this.resolvePrefix(instance)), + fallbackConfiguration: instance.fallbackConfiguration + }; + this.instances.set(resolved.instanceId, resolved); + this.order.push(resolved.instanceId); + } + } + + if (defaultInstanceId !== undefined && !this.instances.has(defaultInstanceId)) { + throw new ConfigurationInputIsInvalidException( + `defaultInstanceId '${defaultInstanceId}' is not one of the configured instances: ${this.order.join(', ')}` + ); + } + } + + public get instanceIds(): string[] { + return [...this.order]; + } + + public get size(): number { + return this.order.length; + } + + public get onlyInstance(): ResolvedInstance { + return this.instances.get(this.order[0]) as ResolvedInstance; + } + + public get(instanceId: string): ResolvedInstance | undefined { + return this.instances.get(instanceId); + } + + private resolvePrefix(instance: InstanceConfiguration): string { + if (instance.schemaPrefix !== undefined) { + return instance.schemaPrefix; + } + + return deriveSchemaPrefix(instance.vendorId); + } + + private assertInstance(instance: InstanceConfiguration, seenVendorIds: Set): void { + if (!instance.instanceId) { + throw new ConfigurationInputIsInvalidException('instanceId is required for every configured instance'); + } + + if (this.instances.has(instance.instanceId)) { + throw new ConfigurationInputIsInvalidException(`Duplicate instanceId '${instance.instanceId}'`); + } + + if (!instance.vendorId) { + throw new ConfigurationInputIsInvalidException( + `vendorId is required for instance '${instance.instanceId}'` + ); + } + + if (seenVendorIds.has(instance.vendorId)) { + throw new ConfigurationInputIsInvalidException( + `Duplicate vendorId '${instance.vendorId}' on instance '${instance.instanceId}'` + ); + } + seenVendorIds.add(instance.vendorId); + + const prefix = this.resolvePrefix(instance); + if (!isValidSchemaPrefix(prefix)) { + throw new ConfigurationInputIsInvalidException( + `Invalid schemaPrefix '${prefix}' for instance '${instance.instanceId}'. ` + + `Expected an empty string or a SpiceDB identifier matching /^[a-z_][a-z0-9_]{1,62}[a-z0-9]$/` + ); + } + } +} diff --git a/src/instances/resolve-instance.spec.ts b/src/instances/resolve-instance.spec.ts new file mode 100644 index 0000000..e22341e --- /dev/null +++ b/src/instances/resolve-instance.spec.ts @@ -0,0 +1,65 @@ +import { InstanceRegistry } from './instance-registry'; +import { resolveInstance } from './resolve-instance'; +import { UnknownInstanceException } from '../exceptions/unknown-instance.exception'; +import { InstanceIdRequiredException } from '../exceptions/instance-id-required.exception'; +import { InstanceResolutionException } from '../exceptions/instance-resolution.exception'; +import { mock } from 'jest-mock-extended'; +import { LoggingClient } from '../logging'; + +const VENDOR_A = '2f9c1a44-7b0e-4a1e-9f8a-1c2d3e4f5a6b'; +const VENDOR_B = '8b1d0e77-3c5a-4f2b-9d6e-7a8b9c0d1e2f'; + +const single = (): InstanceRegistry => new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: VENDOR_A }] }); +const pair = (defaultInstanceId?: string): InstanceRegistry => + new InstanceRegistry( + { + instances: [ + { instanceId: 'a', vendorId: VENDOR_A }, + { instanceId: 'b', vendorId: VENDOR_B } + ] + }, + defaultInstanceId + ); + +function catchError(fn: () => unknown): unknown { + try { + fn(); + } catch (err) { + return err; + } + return undefined; +} + +describe(resolveInstance.name, () => { + it('rule 1: should resolve an explicitly given instanceId', () => { + expect(resolveInstance(pair(), 'b').instanceId).toBe('b'); + }); + + it('rule 1: should throw UnknownInstanceException for an unconfigured instanceId', () => { + expect(() => resolveInstance(pair(), 'nope')).toThrow(UnknownInstanceException); + expect(catchError(() => resolveInstance(pair(), 'nope'))).toBeInstanceOf(InstanceResolutionException); + }); + + it('rule 2: should use the only instance when instanceId is omitted', () => { + expect(resolveInstance(single()).instanceId).toBe('a'); + }); + + it('rule 3: should fall back to defaultInstanceId and log', () => { + const loggingClient = mock(); + + expect(resolveInstance(pair('b'), undefined, loggingClient).instanceId).toBe('b'); + expect(loggingClient.logRequest).toHaveBeenCalledWith( + expect.objectContaining({ action: 'SpiceDB:resolveInstance:default', instanceId: 'b' }), + null + ); + }); + + it('rule 4: should throw InstanceIdRequiredException when ambiguous', () => { + expect(() => resolveInstance(pair())).toThrow(InstanceIdRequiredException); + expect(catchError(() => resolveInstance(pair()))).toBeInstanceOf(InstanceResolutionException); + }); + + it('should name the configured instances in the ambiguity error', () => { + expect(() => resolveInstance(pair())).toThrow(/a, b/); + }); +}); diff --git a/src/instances/resolve-instance.ts b/src/instances/resolve-instance.ts new file mode 100644 index 0000000..0571cfc --- /dev/null +++ b/src/instances/resolve-instance.ts @@ -0,0 +1,37 @@ +import { InstanceRegistry, ResolvedInstance } from './instance-registry'; +import { UnknownInstanceException } from '../exceptions/unknown-instance.exception'; +import { InstanceIdRequiredException } from '../exceptions/instance-id-required.exception'; +import { LoggingClient } from '../logging'; + +export function resolveInstance( + registry: InstanceRegistry, + instanceId?: string, + loggingClient?: LoggingClient +): ResolvedInstance { + if (instanceId !== undefined) { + const instance = registry.get(instanceId); + if (!instance) { + throw new UnknownInstanceException(instanceId, registry.instanceIds); + } + return instance; + } + + if (registry.size === 1) { + return registry.onlyInstance; + } + + const { defaultInstanceId } = registry; + if (defaultInstanceId !== undefined) { + void loggingClient?.logRequest( + { + action: 'SpiceDB:resolveInstance:default', + instanceId: defaultInstanceId, + message: 'instanceId omitted; falling back to defaultInstanceId' + }, + null + ); + return registry.get(defaultInstanceId) as ResolvedInstance; + } + + throw new InstanceIdRequiredException(registry.instanceIds); +} diff --git a/src/instances/schema-prefix.spec.ts b/src/instances/schema-prefix.spec.ts new file mode 100644 index 0000000..c6114b9 --- /dev/null +++ b/src/instances/schema-prefix.spec.ts @@ -0,0 +1,38 @@ +import { deriveSchemaPrefix, isValidSchemaPrefix } from './schema-prefix'; + +describe('schema-prefix', () => { + describe(deriveSchemaPrefix.name, () => { + it('should derive a prefix from a uuid vendorId', () => { + expect(deriveSchemaPrefix('2f9c1a44-7b0e-4a1e-9f8a-1c2d3e4f5a6b')).toBe( + 'v_2f9c1a44_7b0e_4a1e_9f8a_1c2d3e4f5a6b' + ); + }); + + it('should lowercase an uppercase vendorId', () => { + expect(deriveSchemaPrefix('2F9C1A44-7B0E-4A1E-9F8A-1C2D3E4F5A6B')).toBe( + 'v_2f9c1a44_7b0e_4a1e_9f8a_1c2d3e4f5a6b' + ); + }); + + it('should handle a non-uuid vendorId', () => { + expect(deriveSchemaPrefix('acme-corp')).toBe('v_acme_corp'); + }); + }); + + describe(isValidSchemaPrefix.name, () => { + it('should accept the empty legacy prefix', () => { + expect(isValidSchemaPrefix('')).toBe(true); + }); + + it('should accept a derived prefix', () => { + expect(isValidSchemaPrefix(deriveSchemaPrefix('2f9c1a44-7b0e-4a1e-9f8a-1c2d3e4f5a6b'))).toBe(true); + }); + + it.each([['V_UPPER'], ['1leading_digit'], ['has/slash'], ['ends_with_'], ['ab']])( + 'should reject %s', + (prefix) => { + expect(isValidSchemaPrefix(prefix)).toBe(false); + } + ); + }); +}); diff --git a/src/instances/schema-prefix.ts b/src/instances/schema-prefix.ts new file mode 100644 index 0000000..8b735bc --- /dev/null +++ b/src/instances/schema-prefix.ts @@ -0,0 +1,9 @@ +const SPICEDB_PREFIX_PATTERN = /^[a-z_][a-z0-9_]{1,62}[a-z0-9]$/; + +export function deriveSchemaPrefix(vendorId: string): string { + return `v_${vendorId.toLowerCase().replace(/-/g, '_')}`; +} + +export function isValidSchemaPrefix(prefix: string): boolean { + return prefix === '' || SPICEDB_PREFIX_PATTERN.test(prefix); +} diff --git a/src/instances/schema-scope.guard.spec.ts b/src/instances/schema-scope.guard.spec.ts new file mode 100644 index 0000000..9827e33 --- /dev/null +++ b/src/instances/schema-scope.guard.spec.ts @@ -0,0 +1,76 @@ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const SRC_ROOT = join(__dirname, '..'); + +const OBJECT_TYPE_FIELD = /\b(objectType|resourceObjectType|subjectObjectType|resourceType):\s*([^\n,]+)/g; + +const REQUEST_SPAN_START = + /v1\.[A-Za-z]+\.create\(|createBulkPermissionRequestItem\([\s\S]*?\)\s*:\s*v1\.CheckBulkPermissionsRequestItem\s*\{/g; + +function collectSources(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + collectSources(full, acc); + } else if (entry.endsWith('.ts') && !entry.includes('.spec')) { + acc.push(full); + } + } + return acc; +} + +function requestSpans(source: string): [number, number][] { + const spans: [number, number][] = []; + REQUEST_SPAN_START.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = REQUEST_SPAN_START.exec(source)) !== null) { + let depth = 1; + let index = match.index + match[0].length; + while (index < source.length && depth > 0) { + const char = source[index]; + if (char === '(' || char === '{') depth++; + else if (char === ')' || char === '}') depth--; + index++; + } + spans.push([match.index, index]); + } + return spans; +} + +describe('schema scope guard', () => { + const sources = collectSources(SRC_ROOT); + + it('should find the sources it is guarding', () => { + expect(sources.length).toBeGreaterThan(10); + }); + + it('should build every SpiceDB request object type through scope.type()', () => { + const offenders: string[] = []; + + for (const file of sources) { + const relative = file.slice(SRC_ROOT.length + 1); + const source = readFileSync(file, 'utf8'); + const spans = requestSpans(source); + + OBJECT_TYPE_FIELD.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = OBJECT_TYPE_FIELD.exec(source)) !== null) { + const position = match.index; + if (!spans.some(([start, end]) => position >= start && position < end)) { + continue; + } + + const value = match[2].trim(); + if (value.startsWith('scope.type(') || value === 'string') { + continue; + } + + const line = source.slice(0, position).split('\n').length; + offenders.push(`${relative}:${line} ${match[0].trim()}`); + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/instances/schema-scope.spec.ts b/src/instances/schema-scope.spec.ts new file mode 100644 index 0000000..3ee960e --- /dev/null +++ b/src/instances/schema-scope.spec.ts @@ -0,0 +1,49 @@ +import { SchemaScope } from './schema-scope'; +import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception'; + +const PREFIX = 'v_2f9c1a44_7b0e_4a1e_9f8a_1c2d3e4f5a6b'; + +describe(SchemaScope.name, () => { + describe('prefixed scope', () => { + const scope = new SchemaScope(PREFIX); + + it('should report it is not legacy', () => { + expect(scope.isLegacy).toBe(false); + expect(scope.schemaPrefix).toBe(PREFIX); + }); + + it('should prefix an object type', () => { + expect(scope.type('frontegg_feature')).toBe(`${PREFIX}/frontegg_feature`); + }); + + it('should round-trip type and strip', () => { + expect(scope.strip(scope.type('document'))).toBe('document'); + }); + + it('should leave a foreign prefix untouched when stripping', () => { + expect(scope.strip('v_other/document')).toBe('v_other/document'); + }); + + it('should reject an object type that already contains a prefix separator', () => { + expect(() => scope.type('v_other/document')).toThrow(ConfigurationInputIsInvalidException); + }); + }); + + describe('legacy scope', () => { + const scope = new SchemaScope(''); + + it('should report it is legacy', () => { + expect(scope.isLegacy).toBe(true); + expect(scope.schemaPrefix).toBe(''); + }); + + it('should return the object type unchanged', () => { + expect(scope.type('frontegg_feature')).toBe('frontegg_feature'); + expect(scope.strip('frontegg_feature')).toBe('frontegg_feature'); + }); + + it('should still reject an object type containing a prefix separator', () => { + expect(() => scope.type('v_other/document')).toThrow(ConfigurationInputIsInvalidException); + }); + }); +}); diff --git a/src/instances/schema-scope.ts b/src/instances/schema-scope.ts new file mode 100644 index 0000000..b417ac8 --- /dev/null +++ b/src/instances/schema-scope.ts @@ -0,0 +1,32 @@ +import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception'; + +export class SchemaScope { + constructor(private readonly prefix: string) {} + + public get isLegacy(): boolean { + return this.prefix === ''; + } + + public get schemaPrefix(): string { + return this.prefix; + } + + public type(objectType: string): string { + if (objectType.includes('/')) { + throw new ConfigurationInputIsInvalidException( + `Object type '${objectType}' must not contain '/'. Schema prefixes are applied by the SDK.` + ); + } + + return this.isLegacy ? objectType : `${this.prefix}/${objectType}`; + } + + public strip(objectType: string): string { + if (this.isLegacy) { + return objectType; + } + + const scopedPrefix = `${this.prefix}/`; + return objectType.startsWith(scopedPrefix) ? objectType.slice(scopedPrefix.length) : objectType; + } +} diff --git a/src/spicedb/spicedb-entitlements.client.instances.spec.ts b/src/spicedb/spicedb-entitlements.client.instances.spec.ts new file mode 100644 index 0000000..0adf2be --- /dev/null +++ b/src/spicedb/spicedb-entitlements.client.instances.spec.ts @@ -0,0 +1,181 @@ +import { SpiceDBEntitlementsClient } from './spicedb-entitlements.client'; +import { mock, MockProxy } from 'jest-mock-extended'; +import { SpiceDBQueryClient } from './spicedb-queries/spicedb-query.client'; +import { LoggingClient } from '../logging'; +import { RequestContext, RequestContextType, UserSubjectContext } from '../types'; +import { ClientConfiguration } from '../client-configuration'; +import { InstanceRegistry } from '../instances/instance-registry'; +import { UnknownInstanceException } from '../exceptions/unknown-instance.exception'; +import { InstanceIdRequiredException } from '../exceptions/instance-id-required.exception'; + +const VENDOR_A = '2f9c1a44-7b0e-4a1e-9f8a-1c2d3e4f5a6b'; +const VENDOR_B = '8b1d0e77-3c5a-4f2b-9d6e-7a8b9c0d1e2f'; +const PREFIX_A = 'v_2f9c1a44_7b0e_4a1e_9f8a_1c2d3e4f5a6b'; +const PREFIX_B = 'v_8b1d0e77_3c5a_4f2b_9d6e_7a8b9c0d1e2f'; + +const subjectContext: UserSubjectContext = { + tenantId: 'mock-tenant-id', + userId: 'mock-user-id', + permissions: ['mock-permission'], + attributes: {} +}; + +const featureContext: RequestContext = { type: RequestContextType.Feature, featureKey: 'premium' }; + +const TWO_INSTANCES: ClientConfiguration['instances'] = [ + { instanceId: 'a', vendorId: VENDOR_A }, + { instanceId: 'b', vendorId: VENDOR_B } +]; + +function buildClient( + configuration: Partial, + queryClient: SpiceDBQueryClient, + loggingClient: LoggingClient +): SpiceDBEntitlementsClient { + const full: ClientConfiguration = { + engineEndpoint: 'localhost:50051', + engineToken: 'token', + ...configuration + }; + const client = new SpiceDBEntitlementsClient( + full, + loggingClient, + false, + full.fallbackConfiguration, + new InstanceRegistry(full, full.defaultInstanceId) + ); + (client as unknown as { spiceDBQueryClient: SpiceDBQueryClient }).spiceDBQueryClient = queryClient; + return client; +} + +describe('SpiceDBEntitlementsClient instance isolation', () => { + let queryClient: MockProxy; + let loggingClient: MockProxy; + + beforeEach(() => { + queryClient = mock(); + loggingClient = mock(); + queryClient.spiceDBQuery.mockResolvedValue({ result: { result: true } }); + }); + + describe('scope routing', () => { + it('should pass the resolved instance scope to the query client', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + + await client.isEntitledTo(subjectContext, featureContext, { instanceId: 'b' }); + + const scope = queryClient.spiceDBQuery.mock.calls[0][2]; + expect(scope.schemaPrefix).toBe(PREFIX_B); + expect(scope.type('frontegg_feature')).toBe(`${PREFIX_B}/frontegg_feature`); + }); + + it('should route each instanceId to its own prefix', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + + await client.isEntitledTo(subjectContext, featureContext, { instanceId: 'a' }); + await client.isEntitledTo(subjectContext, featureContext, { instanceId: 'b' }); + + expect(queryClient.spiceDBQuery.mock.calls[0][2].schemaPrefix).toBe(PREFIX_A); + expect(queryClient.spiceDBQuery.mock.calls[1][2].schemaPrefix).toBe(PREFIX_B); + }); + + it('should use a legacy scope when no instances are configured', async () => { + const client = buildClient({}, queryClient, loggingClient); + + await client.isEntitledTo(subjectContext, featureContext); + + expect(queryClient.spiceDBQuery.mock.calls[0][2].isLegacy).toBe(true); + }); + }); + + describe('fail closed', () => { + it('should throw for an unconfigured instanceId and make no SpiceDB call', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + + await expect( + client.isEntitledTo(subjectContext, featureContext, { instanceId: 'nope' }) + ).rejects.toBeInstanceOf(UnknownInstanceException); + expect(queryClient.spiceDBQuery).not.toHaveBeenCalled(); + }); + + it('should throw when instanceId is omitted with several instances configured', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + + await expect(client.isEntitledTo(subjectContext, featureContext)).rejects.toBeInstanceOf( + InstanceIdRequiredException + ); + expect(queryClient.spiceDBQuery).not.toHaveBeenCalled(); + }); + + it('should not swallow a resolution failure into the fallback boolean', async () => { + const client = buildClient( + { instances: TWO_INSTANCES, fallbackConfiguration: { defaultFallback: true } }, + queryClient, + loggingClient + ); + + await expect(client.isEntitledTo(subjectContext, featureContext)).rejects.toBeInstanceOf( + InstanceIdRequiredException + ); + }); + + it('should throw from isEntitledToMany as well', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + + await expect(client.isEntitledToMany(subjectContext, [featureContext])).rejects.toBeInstanceOf( + InstanceIdRequiredException + ); + }); + + it('should still return the fallback for a genuine SpiceDB error', async () => { + queryClient.spiceDBQuery.mockRejectedValue(new Error('spicedb unavailable')); + const client = buildClient( + { instances: TWO_INSTANCES, fallbackConfiguration: { defaultFallback: true } }, + queryClient, + loggingClient + ); + + await expect(client.isEntitledTo(subjectContext, featureContext, { instanceId: 'a' })).resolves.toEqual({ + result: true + }); + }); + }); + + describe('defaultInstanceId', () => { + it('should use the default when instanceId is omitted', async () => { + const client = buildClient( + { instances: TWO_INSTANCES, defaultInstanceId: 'a' }, + queryClient, + loggingClient + ); + + await client.isEntitledTo(subjectContext, featureContext); + + expect(queryClient.spiceDBQuery.mock.calls[0][2].schemaPrefix).toBe(PREFIX_A); + }); + }); + + describe('per-instance fallback', () => { + it('should prefer the instance fallback over the client fallback', async () => { + queryClient.spiceDBQuery.mockRejectedValue(new Error('spicedb unavailable')); + const client = buildClient( + { + instances: [ + { instanceId: 'a', vendorId: VENDOR_A, fallbackConfiguration: { defaultFallback: true } }, + { instanceId: 'b', vendorId: VENDOR_B } + ], + fallbackConfiguration: { defaultFallback: false } + }, + queryClient, + loggingClient + ); + + await expect(client.isEntitledTo(subjectContext, featureContext, { instanceId: 'a' })).resolves.toEqual({ + result: true + }); + await expect(client.isEntitledTo(subjectContext, featureContext, { instanceId: 'b' })).resolves.toEqual({ + result: false + }); + }); + }); +}); diff --git a/src/spicedb/spicedb-entitlements.client.spec.ts b/src/spicedb/spicedb-entitlements.client.spec.ts index 9cd90ff..0e62fd8 100644 --- a/src/spicedb/spicedb-entitlements.client.spec.ts +++ b/src/spicedb/spicedb-entitlements.client.spec.ts @@ -13,6 +13,9 @@ import { } from '../types'; import { SpiceDBResponse } from '../types/spicedb.dto'; import { ClientConfiguration, FallbackConfiguration } from '../client-configuration'; +import { SchemaScope } from '../instances/schema-scope'; + +const LEGACY_SCOPE = new SchemaScope(''); // Helper function to create request contexts for each type function getRequestContext(type: RequestContextType): RequestContext { @@ -86,7 +89,11 @@ describe(SpiceDBEntitlementsClient.name, () => { const result = await cut.isEntitledTo(subjectContext, requestContext); // THEN - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, requestContext); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + requestContext, + LEGACY_SCOPE + ); expect(result).toEqual({ result: true }); expect(mockLoggingClient.log).not.toHaveBeenCalled(); }); @@ -100,7 +107,11 @@ describe(SpiceDBEntitlementsClient.name, () => { const result = await cut.isEntitledTo(subjectContext, requestContext); // THEN - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, requestContext); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + requestContext, + LEGACY_SCOPE + ); expect(result).toEqual({ result: true }); expect(mockLoggingClient.log).toHaveBeenCalledWith(subjectContext, requestContext, spiceDBResult); }); @@ -118,7 +129,11 @@ describe(SpiceDBEntitlementsClient.name, () => { const result = await cut.isEntitledTo(subjectContext, requestContext); - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, requestContext); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + requestContext, + LEGACY_SCOPE + ); expect(result).toEqual({ result: false }); }); } @@ -154,7 +169,11 @@ describe(SpiceDBEntitlementsClient.name, () => { const result = await cut.isEntitledTo(subjectContext, requestContext); - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, requestContext); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + requestContext, + LEGACY_SCOPE + ); expect(mockLoggingClient.error).toHaveBeenCalledWith(error); expect(result).toEqual({ result: false }); }); @@ -168,7 +187,11 @@ describe(SpiceDBEntitlementsClient.name, () => { const result = await cut.isEntitledTo(subjectContext, requestContext); - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, requestContext); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + requestContext, + LEGACY_SCOPE + ); expect(mockLoggingClient.error).toHaveBeenCalledWith(error); expect(result).toEqual({ result: true }); }); @@ -230,13 +253,26 @@ describe(SpiceDBEntitlementsClient.name, () => { entityContext ]); - expect(mockSpiceDBQueryClient.spiceDBBatchFeatureQuery).toHaveBeenCalledWith(subjectContext, [ - 'feature-a', - 'feature-b' - ]); - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, permissionContext); - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, routeContext); - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, entityContext); + expect(mockSpiceDBQueryClient.spiceDBBatchFeatureQuery).toHaveBeenCalledWith( + subjectContext, + ['feature-a', 'feature-b'], + LEGACY_SCOPE + ); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + permissionContext, + LEGACY_SCOPE + ); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + routeContext, + LEGACY_SCOPE + ); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + entityContext, + LEGACY_SCOPE + ); expect(result).toEqual([ { result: true }, { result: true }, @@ -435,7 +471,11 @@ describe(SpiceDBEntitlementsClient.name, () => { const result = await cut.isEntitledTo(subjectContext, requestContext); - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, requestContext); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + requestContext, + LEGACY_SCOPE + ); expect(mockLoggingClient.error).toHaveBeenCalledWith(error); expect(result).toEqual(expectedResult); }); @@ -515,7 +555,11 @@ describe(SpiceDBEntitlementsClient.name, () => { const result = await cut.isEntitledTo(subjectContext, requestContext); - expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith(subjectContext, requestContext); + expect(mockSpiceDBQueryClient.spiceDBQuery).toHaveBeenCalledWith( + subjectContext, + requestContext, + LEGACY_SCOPE + ); expect(mockLoggingClient.error).toHaveBeenCalledWith(error); expect(result).toEqual(expectedResult); }); diff --git a/src/spicedb/spicedb-entitlements.client.ts b/src/spicedb/spicedb-entitlements.client.ts index 45370c2..516e9b9 100644 --- a/src/spicedb/spicedb-entitlements.client.ts +++ b/src/spicedb/spicedb-entitlements.client.ts @@ -37,18 +37,31 @@ import { } from './spicedb-queries/lookup-response.mapper'; import { SpiceDBEntities } from '../types/spicedb-consts'; import { decodeObjectId, encodeObjectId } from './spicedb-queries/base64.utils'; +import { InstanceRegistry, ResolvedInstance } from '../instances/instance-registry'; +import { resolveInstance } from '../instances/resolve-instance'; +import { SchemaScope } from '../instances/schema-scope'; +import { InstanceResolutionException } from '../exceptions/instance-resolution.exception'; + +export interface InstanceOptions { + instanceId?: string; +} export class SpiceDBEntitlementsClient { private static readonly MONITORING_RESULT: EntitlementsResult = { monitoring: true, result: true }; public readonly spiceClient: v1.ZedPromiseClientInterface; private readonly spiceDBQueryClient: SpiceDBQueryClient; + private readonly registry: InstanceRegistry; + constructor( private readonly configuration: ClientConfiguration, private readonly loggingClient: LoggingClient, private readonly logResults = false, - private readonly fallbackConfiguration: FallbackConfiguration = { defaultFallback: false } + private readonly fallbackConfiguration: FallbackConfiguration = { defaultFallback: false }, + registry?: InstanceRegistry ) { + this.registry = registry ?? new InstanceRegistry(configuration, configuration.defaultInstanceId); + try { this.spiceClient = v1.NewClient( this.configuration.engineToken, @@ -70,15 +83,19 @@ export class SpiceDBEntitlementsClient { public async isEntitledTo( subjectContext: SubjectContext, - requestContext: RequestContext + requestContext: RequestContext, + options?: InstanceOptions ): Promise { - return this.executeEntitlementQuery(subjectContext, requestContext, 'SpiceDB:isEntitledTo'); + const instance = this.resolve(options?.instanceId); + return this.executeEntitlementQuery(subjectContext, requestContext, instance, 'SpiceDB:isEntitledTo'); } public async isEntitledToMany( subjectContext: SubjectContext, - requestContexts: RequestContext[] + requestContexts: RequestContext[], + options?: InstanceOptions ): Promise { + const instance = this.resolve(options?.instanceId); if (this.logResults) { await this.loggingClient.logRequest( { action: 'SpiceDB:isEntitledToMany:request', subjectContext, requestContexts }, @@ -102,7 +119,7 @@ export class SpiceDBEntitlementsClient { } const [featureResults, nonFeatureResults] = await Promise.all([ - this.resolveFeatureEntitlements(subjectContext, featureRequests), + this.resolveFeatureEntitlements(subjectContext, featureRequests, instance), Promise.all( requestContexts.map(async (requestContext, index) => { if (requestContext.type === RequestContextType.Feature) { @@ -111,7 +128,7 @@ export class SpiceDBEntitlementsClient { return { index, - result: await this.executeEntitlementQuery(subjectContext, requestContext) + result: await this.executeEntitlementQuery(subjectContext, requestContext, instance) }; }) ) @@ -134,56 +151,69 @@ export class SpiceDBEntitlementsClient { return results; } - public async lookupTargetEntities(req: LookupTargetEntitiesRequest): Promise { + public async lookupTargetEntities( + req: LookupTargetEntitiesRequest & InstanceOptions + ): Promise { + const { scope } = this.resolve(req.instanceId); try { const limit = req.limit ? req.limit : DEFAULT_LOOKUP_LIMIT; - const request = buildLookupTargetEntitiesRequest({ - entityType: req.entityType, - entityId: req.entityId, - TargetEntityType: req.TargetEntityType, - action: req.action, - limit, - cursor: req.cursor, - at: req.at - }); + const request = buildLookupTargetEntitiesRequest( + { + entityType: req.entityType, + entityId: req.entityId, + TargetEntityType: req.TargetEntityType, + action: req.action, + limit, + cursor: req.cursor, + at: req.at + }, + scope + ); const results = await this.spiceClient.lookupResources(request); if (this.logResults) { await this.loggingClient.logRequest(request, results); } - return mapLookupTargetEntitiesResponse(results, req.TargetEntityType, limit); + return mapLookupTargetEntitiesResponse(results, req.TargetEntityType, limit, scope); } catch (err) { await this.loggingClient.error(err); throw err; } } - public async lookupEntities(req: LookupEntitiesRequest): Promise { + public async lookupEntities(req: LookupEntitiesRequest & InstanceOptions): Promise { + const { scope } = this.resolve(req.instanceId); try { - const request = buildLookupEntitiesRequest({ - TargetEntityType: req.TargetEntityType, - TargetEntityId: req.TargetEntityId, - entityType: req.entityType, - action: req.action, - at: req.at - }); + const request = buildLookupEntitiesRequest( + { + TargetEntityType: req.TargetEntityType, + TargetEntityId: req.TargetEntityId, + entityType: req.entityType, + action: req.action, + at: req.at + }, + scope + ); const results = await this.spiceClient.lookupSubjects(request); if (this.logResults) { await this.loggingClient.logRequest(request, results); } - return mapLookupEntitiesResponse(results, req.entityType); + return mapLookupEntitiesResponse(results, req.entityType, scope); } catch (err) { await this.loggingClient.error(err); throw err; } } - public async lookupEntitlements(req: LookupEntitlementsRequest): Promise { + public async lookupEntitlements( + req: LookupEntitlementsRequest & InstanceOptions + ): Promise { + const { scope } = this.resolve(req.instanceId); try { - if (!(await this.isLookupEntitlementsTenantMember(req))) { + if (!(await this.isLookupEntitlementsTenantMember(req, scope))) { return { entitlements: [], totalReturned: 0, @@ -199,7 +229,7 @@ export class SpiceDBEntitlementsClient { const streams = this.getLookupEntitlementsStreams(req, previousCursor); const activeStreams = streams.filter((stream) => !stream.state.done); const requests = activeStreams.map((stream) => - buildLookupEntitlementsRequest({ ...req, limit }, stream.subject, now) + buildLookupEntitlementsRequest({ ...req, limit }, stream.subject, scope, now) ); const resultsByStream = await Promise.all( requests.map((request) => this.spiceClient.lookupResources(request)) @@ -227,20 +257,23 @@ export class SpiceDBEntitlementsClient { } } - private async isLookupEntitlementsTenantMember(req: LookupEntitlementsRequest): Promise { + private async isLookupEntitlementsTenantMember( + req: LookupEntitlementsRequest, + scope: SchemaScope + ): Promise { if (!req.subject.userId) { return true; } const request = v1.CheckPermissionRequest.create({ resource: { - objectType: SpiceDBEntities.Tenant, + objectType: scope.type(SpiceDBEntities.Tenant), objectId: encodeObjectId(req.subject.tenantId) }, permission: 'access', subject: { object: { - objectType: SpiceDBEntities.User, + objectType: scope.type(SpiceDBEntities.User), objectId: encodeObjectId(req.subject.userId) }, optionalRelation: '' @@ -250,9 +283,29 @@ export class SpiceDBEntitlementsClient { return result.permissionship === v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION; } + private resolve(instanceId?: string): ResolvedInstance { + return resolveInstance(this.registry, instanceId, this.loggingClient); + } + + public async readSchemaFor(instanceId?: string): Promise { + const { scope } = this.resolve(instanceId); + const { schemaText } = await this.spiceClient.readSchema({}); + + if (scope.isLegacy) { + return schemaText; + } + + const marker = `${scope.schemaPrefix}/`; + return schemaText + .split(/\n(?=definition |caveat )/) + .filter((block) => block.startsWith(`definition ${marker}`) || block.startsWith(`caveat ${marker}`)) + .join('\n'); + } + private async executeEntitlementQuery( subjectContext: SubjectContext, requestContext: RequestContext, + instance: ResolvedInstance, logAction?: string ): Promise { const logPerItem = this.logResults && logAction != null; @@ -264,7 +317,7 @@ export class SpiceDBEntitlementsClient { ); } - const res = await this.spiceDBQueryClient.spiceDBQuery(subjectContext, requestContext); + const res = await this.spiceDBQueryClient.spiceDBQuery(subjectContext, requestContext, instance.scope); if (logPerItem) { await this.loggingClient.logRequest( @@ -282,8 +335,11 @@ export class SpiceDBEntitlementsClient { } return res.result; } catch (err) { + if (err instanceof InstanceResolutionException) { + throw err; + } await this.loggingClient.error(err); - return this.constructFallbackResult(requestContext); + return this.constructFallbackResult(requestContext, instance); } } @@ -424,7 +480,8 @@ export class SpiceDBEntitlementsClient { private async resolveFeatureEntitlements( subjectContext: SubjectContext, - featureRequests: { requestContext: FeatureEntitlementsContext; index: number }[] + featureRequests: { requestContext: FeatureEntitlementsContext; index: number }[], + instance: ResolvedInstance ): Promise<{ index: number; result: EntitlementsResult }[]> { if (!featureRequests.length) { return []; @@ -437,33 +494,41 @@ export class SpiceDBEntitlementsClient { try { const res = await this.spiceDBQueryClient.spiceDBBatchFeatureQuery( subjectContext as UserSubjectContext, - uniqueFeatureKeys + uniqueFeatureKeys, + instance.scope ); return featureRequests.map(({ requestContext, index }) => ({ index, result: res.result[requestContext.featureKey] ?? { result: false } })); } catch (err) { + if (err instanceof InstanceResolutionException) { + throw err; + } await this.loggingClient.error(err); return Promise.all( featureRequests.map(async ({ requestContext, index }) => ({ index, - result: await this.constructFallbackResult(requestContext) + result: await this.constructFallbackResult(requestContext, instance) })) ); } } - private async constructFallbackResult(requestContext: RequestContext): Promise { + private async constructFallbackResult( + requestContext: RequestContext, + instance?: ResolvedInstance + ): Promise { + const configuration = instance?.fallbackConfiguration ?? this.fallbackConfiguration; const fallback = - this.fallbackConfiguration instanceof Function - ? await this.fallbackConfiguration(requestContext) - : this.getStaticFallback(requestContext); + configuration instanceof Function + ? await configuration(requestContext) + : this.getStaticFallback(requestContext, configuration); return { result: fallback }; } - private getStaticFallback(requestContext: RequestContext): boolean { - const staticFallbackConfiguration = this.fallbackConfiguration as StaticFallbackConfiguration; + private getStaticFallback(requestContext: RequestContext, configuration: FallbackConfiguration): boolean { + const staticFallbackConfiguration = configuration as StaticFallbackConfiguration; const fallbackMapper = { [RequestContextType.Feature]: diff --git a/src/spicedb/spicedb-queries/entitlements-spicedb.query.spec-helper.ts b/src/spicedb/spicedb-queries/entitlements-spicedb.query.spec-helper.ts index a1f25d2..339db28 100644 --- a/src/spicedb/spicedb-queries/entitlements-spicedb.query.spec-helper.ts +++ b/src/spicedb/spicedb-queries/entitlements-spicedb.query.spec-helper.ts @@ -8,6 +8,9 @@ import { UserSubjectContext, EntitlementsDynamicQueryRequestContext } from '../../types'; +import { SchemaScope } from '../../instances/schema-scope'; + +const LEGACY_SCOPE = new SchemaScope(''); export function getRequestContext(type: RequestContextType): RequestContext { switch (type) { @@ -93,10 +96,13 @@ export function EntitlementsSpiceDBQueryCommonTests> { - return this.executeCommonQuery(objectType, objectId, subjectContext); + return this.executeCommonQuery(scope, objectType, objectId, subjectContext); } public testExecuteManyCommonQuery( objectType: string, objectIds: string[], - subjectContext: UserSubjectContext + subjectContext: UserSubjectContext, + scope: SchemaScope = LEGACY_SCOPE ): Promise> { - return this.executeManyCommonQuery(objectType, objectIds, subjectContext); + return this.executeManyCommonQuery(scope, objectType, objectIds, subjectContext); } } diff --git a/src/spicedb/spicedb-queries/entitlements-spicedb.query.ts b/src/spicedb/spicedb-queries/entitlements-spicedb.query.ts index ddf49ac..eef22b1 100644 --- a/src/spicedb/spicedb-queries/entitlements-spicedb.query.ts +++ b/src/spicedb/spicedb-queries/entitlements-spicedb.query.ts @@ -12,6 +12,7 @@ import { SpiceDBEntities } from '../../types/spicedb-consts'; import { decodeObjectId, encodeObjectId } from './base64.utils'; import { LoggingClient } from '../../logging'; import { createTargetingCaveatContext } from './caveat-context.utils'; +import { SchemaScope } from '../../instances/schema-scope'; export interface HashOptions { hashResourceId: boolean; @@ -26,7 +27,8 @@ export abstract class EntitlementsSpiceDBQuery { ) {} abstract query( - entitlementsQuery: EntitlementsDynamicQuery + entitlementsQuery: EntitlementsDynamicQuery, + scope: SchemaScope ): Promise>; protected createCaveatContext(context: UserSubjectContext): v1.PbStruct { @@ -34,6 +36,7 @@ export abstract class EntitlementsSpiceDBQuery { } protected createBulkPermissionRequestItem( + scope: SchemaScope, resourceObjectType: string, resourceObjectId: string, subjectObjectType: string, @@ -43,13 +46,13 @@ export abstract class EntitlementsSpiceDBQuery { ): v1.CheckBulkPermissionsRequestItem { return { resource: { - objectType: resourceObjectType, + objectType: scope.type(resourceObjectType), objectId: hashOptions.hashResourceId ? encodeObjectId(resourceObjectId) : resourceObjectId }, permission: 'access', subject: { object: { - objectType: subjectObjectType, + objectType: scope.type(subjectObjectType), objectId: hashOptions.hashSubjectId ? encodeObjectId(subjectObjectId) : subjectObjectId }, optionalRelation: '' @@ -59,6 +62,7 @@ export abstract class EntitlementsSpiceDBQuery { } protected createBulkPermissionsRequest( + scope: SchemaScope, objectType: string, objectId: string, context: UserSubjectContext, @@ -66,6 +70,7 @@ export abstract class EntitlementsSpiceDBQuery { hashOptions: HashOptions = { hashSubjectId: true, hashResourceId: true } ): v1.CheckBulkPermissionsRequest { const tenantRequest = this.createBulkPermissionRequestItem( + scope, objectType, objectId, SpiceDBEntities.Tenant, @@ -79,6 +84,7 @@ export abstract class EntitlementsSpiceDBQuery { ? [ tenantRequest, this.createBulkPermissionRequestItem( + scope, objectType, objectId, SpiceDBEntities.User, @@ -91,6 +97,7 @@ export abstract class EntitlementsSpiceDBQuery { } protected createManyBulkPermissionsRequest( + scope: SchemaScope, objectType: string, objectIds: string[], context: UserSubjectContext, @@ -100,7 +107,8 @@ export abstract class EntitlementsSpiceDBQuery { return v1.CheckBulkPermissionsRequest.create({ items: objectIds.flatMap( (objectId) => - this.createBulkPermissionsRequest(objectType, objectId, context, caveatContext, hashOptions).items + this.createBulkPermissionsRequest(scope, objectType, objectId, context, caveatContext, hashOptions) + .items ) }); } @@ -146,13 +154,14 @@ export abstract class EntitlementsSpiceDBQuery { } protected async executeCommonQuery( + scope: SchemaScope, objectType: string, objectId: string, subjectContext: UserSubjectContext ): Promise> { const context = subjectContext; const caveatContext = this.createCaveatContext(context); - const request = this.createBulkPermissionsRequest(objectType, objectId, context, caveatContext); + const request = this.createBulkPermissionsRequest(scope, objectType, objectId, context, caveatContext); if (this.logResults) { await this.loggingClient?.logRequest( @@ -178,6 +187,7 @@ export abstract class EntitlementsSpiceDBQuery { } protected async executeManyCommonQuery( + scope: SchemaScope, objectType: string, objectIds: string[], subjectContext: UserSubjectContext @@ -189,7 +199,13 @@ export abstract class EntitlementsSpiceDBQuery { } const caveatContext = this.createCaveatContext(context); - const request = this.createManyBulkPermissionsRequest(objectType, uniqueObjectIds, context, caveatContext); + const request = this.createManyBulkPermissionsRequest( + scope, + objectType, + uniqueObjectIds, + context, + caveatContext + ); if (this.logResults) { await this.loggingClient?.logRequest( @@ -217,16 +233,17 @@ export abstract class EntitlementsSpiceDBQuery { }; } protected async isPermissionLinkedToFeatures( + scope: SchemaScope, requestContext: PermissionsEntitlementsContext, hashResourceId: boolean = true ): Promise { const lookupRequest = v1.LookupSubjectsRequest.create({ permission: 'parent', resource: { - objectType: SpiceDBEntities.Permission, + objectType: scope.type(SpiceDBEntities.Permission), objectId: hashResourceId ? encodeObjectId(requestContext.permissionKey) : requestContext.permissionKey }, - subjectObjectType: SpiceDBEntities.Feature + subjectObjectType: scope.type(SpiceDBEntities.Feature) }); const lookUpRes = await this.client.lookupSubjects(lookupRequest); return !!lookUpRes.length; diff --git a/src/spicedb/spicedb-queries/features-spicedb.query.spec.ts b/src/spicedb/spicedb-queries/features-spicedb.query.spec.ts index 04b685c..ac45a3a 100644 --- a/src/spicedb/spicedb-queries/features-spicedb.query.spec.ts +++ b/src/spicedb/spicedb-queries/features-spicedb.query.spec.ts @@ -3,6 +3,9 @@ import { mock, MockProxy, mockReset } from 'jest-mock-extended'; import { FeaturesSpiceDBQuery } from './features-spicedb.query'; import { EntitlementsSpiceDBQueryCommonTests } from './entitlements-spicedb.query.spec-helper'; import { EntitlementsDynamicQueryRequestContext, RequestContextType, UserSubjectContext } from '../../types'; +import { SchemaScope } from '../../instances/schema-scope'; + +const LEGACY_SCOPE = new SchemaScope(''); describe(FeaturesSpiceDBQuery.name, () => { EntitlementsSpiceDBQueryCommonTests( @@ -52,7 +55,7 @@ describe(FeaturesSpiceDBQuery.name, () => { }); mockClient.checkBulkPermissions.mockResolvedValue(mockResponse); - await queryClient.query({ subjectContext, requestContext }); + await queryClient.query({ subjectContext, requestContext }, LEGACY_SCOPE); const call = mockClient.checkBulkPermissions.mock.calls[0][0]; const context = call.items?.[0]?.context; diff --git a/src/spicedb/spicedb-queries/features-spicedb.query.ts b/src/spicedb/spicedb-queries/features-spicedb.query.ts index 24c9457..6d69b91 100644 --- a/src/spicedb/spicedb-queries/features-spicedb.query.ts +++ b/src/spicedb/spicedb-queries/features-spicedb.query.ts @@ -10,6 +10,7 @@ import { SpiceDBResponse } from '../../types/spicedb.dto'; import { v1 } from '@authzed/authzed-node'; import { SpiceDBEntities } from '../../types/spicedb-consts'; import { LoggingClient } from '../../logging'; +import { SchemaScope } from '../../instances/schema-scope'; export class FeaturesSpiceDBQuery extends EntitlementsSpiceDBQuery { constructor( @@ -20,18 +21,19 @@ export class FeaturesSpiceDBQuery extends EntitlementsSpiceDBQuery { super(client, loggingClient, logResults); } - async query({ - subjectContext, - requestContext - }: EntitlementsDynamicQuery): Promise> { + async query( + { subjectContext, requestContext }: EntitlementsDynamicQuery, + scope: SchemaScope + ): Promise> { const context = subjectContext as UserSubjectContext; - return this.executeCommonQuery(SpiceDBEntities.Feature, requestContext.featureKey, context); + return this.executeCommonQuery(scope, SpiceDBEntities.Feature, requestContext.featureKey, context); } async queryMany( subjectContext: UserSubjectContext, - featureKeys: string[] + featureKeys: string[], + scope: SchemaScope ): Promise> { - return this.executeManyCommonQuery(SpiceDBEntities.Feature, featureKeys, subjectContext); + return this.executeManyCommonQuery(scope, SpiceDBEntities.Feature, featureKeys, subjectContext); } } diff --git a/src/spicedb/spicedb-queries/fga-spicedb.query.spec.ts b/src/spicedb/spicedb-queries/fga-spicedb.query.spec.ts index 3efa4ee..4869d29 100644 --- a/src/spicedb/spicedb-queries/fga-spicedb.query.spec.ts +++ b/src/spicedb/spicedb-queries/fga-spicedb.query.spec.ts @@ -2,6 +2,9 @@ import { v1 } from '@authzed/authzed-node'; import { mock, MockProxy, mockReset } from 'jest-mock-extended'; import { EntitlementsDynamicQueryRequestContext, RequestContextType } from '../../types'; import { FgaSpiceDBQuery } from './fga-spicedb.query'; +import { SchemaScope } from '../../instances/schema-scope'; + +const LEGACY_SCOPE = new SchemaScope(''); describe(FgaSpiceDBQuery.name, () => { let queryClient: FgaSpiceDBQuery; @@ -39,10 +42,13 @@ describe(FgaSpiceDBQuery.name, () => { }); mockClient.checkPermission.mockResolvedValue(mockResponse); - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); expect(mockClient.checkPermission).toHaveBeenCalled(); expect(result.result.result).toBe(true); @@ -65,7 +71,7 @@ describe(FgaSpiceDBQuery.name, () => { }); mockClient.checkPermission.mockResolvedValue(mockResponse); - await queryClient.query({ subjectContext, requestContext }); + await queryClient.query({ subjectContext, requestContext }, LEGACY_SCOPE); const call = mockClient.checkPermission.mock.calls[0][0]; expect(call.context).toBeDefined(); @@ -94,7 +100,7 @@ describe(FgaSpiceDBQuery.name, () => { }); mockClient.checkPermission.mockResolvedValue(mockResponse); - await queryClient.query({ subjectContext, requestContext }); + await queryClient.query({ subjectContext, requestContext }, LEGACY_SCOPE); const call = mockClient.checkPermission.mock.calls[0][0]; expect(call.context?.fields?.at?.kind).toEqual({ @@ -122,7 +128,7 @@ describe(FgaSpiceDBQuery.name, () => { }); mockClient.checkPermission.mockResolvedValue(mockResponse); - await queryClient.query({ subjectContext, requestContext }); + await queryClient.query({ subjectContext, requestContext }, LEGACY_SCOPE); const call = mockClient.checkPermission.mock.calls[0][0]; expect(call.context?.fields?.at?.kind).toEqual({ @@ -147,10 +153,13 @@ describe(FgaSpiceDBQuery.name, () => { mockClient.checkPermission.mockRejectedValue(mockError); await expect( - queryClient.query({ - subjectContext, - requestContext - }) + queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ) ).rejects.toThrow(mockError); }); }); diff --git a/src/spicedb/spicedb-queries/fga-spicedb.query.ts b/src/spicedb/spicedb-queries/fga-spicedb.query.ts index 2ebdfc2..809e7a0 100644 --- a/src/spicedb/spicedb-queries/fga-spicedb.query.ts +++ b/src/spicedb/spicedb-queries/fga-spicedb.query.ts @@ -5,6 +5,7 @@ import { SpiceDBResponse } from '../../types/spicedb.dto'; import { v1 } from '@authzed/authzed-node'; import { encodeObjectId } from './base64.utils'; import { LoggingClient } from '../../logging'; +import { SchemaScope } from '../../instances/schema-scope'; export class FgaSpiceDBQuery extends EntitlementsSpiceDBQuery { constructor( @@ -15,22 +16,22 @@ export class FgaSpiceDBQuery extends EntitlementsSpiceDBQuery { super(client, loggingClient, logResults); } - async query({ - requestContext, - subjectContext - }: EntitlementsDynamicQuery): Promise> { + async query( + { requestContext, subjectContext }: EntitlementsDynamicQuery, + scope: SchemaScope + ): Promise> { const context = subjectContext as FGASubjectContext; const caveatContext = createActiveAtCaveatContext(requestContext.at); const request = v1.CheckPermissionRequest.create({ subject: { object: { - objectType: context.entityType, + objectType: scope.type(context.entityType), objectId: encodeObjectId(context.key) }, optionalRelation: '' }, resource: { - objectType: requestContext.entityType, + objectType: scope.type(requestContext.entityType), objectId: encodeObjectId(requestContext.key) }, permission: requestContext.action, diff --git a/src/spicedb/spicedb-queries/lookup-request.builder.spec.ts b/src/spicedb/spicedb-queries/lookup-request.builder.spec.ts index 0438b80..2de1cfc 100644 --- a/src/spicedb/spicedb-queries/lookup-request.builder.spec.ts +++ b/src/spicedb/spicedb-queries/lookup-request.builder.spec.ts @@ -5,18 +5,24 @@ import { } from './lookup-request.builder'; import { encodeObjectId } from './base64.utils'; import { RequestContextType } from '../../types'; +import { SchemaScope } from '../../instances/schema-scope'; + +const LEGACY_SCOPE = new SchemaScope(''); describe('lookup-request.builder', () => { describe('buildLookupTargetEntitiesRequest', () => { it('should build request with all parameters and encode entityId', () => { - const result = buildLookupTargetEntitiesRequest({ - entityType: 'user', - entityId: 'user-123', - TargetEntityType: 'document', - action: 'read', - limit: 100, - cursor: 'cursor-token' - }); + const result = buildLookupTargetEntitiesRequest( + { + entityType: 'user', + entityId: 'user-123', + TargetEntityType: 'document', + action: 'read', + limit: 100, + cursor: 'cursor-token' + }, + LEGACY_SCOPE + ); expect(result.resourceObjectType).toBe('document'); expect(result.permission).toBe('read'); @@ -29,25 +35,31 @@ describe('lookup-request.builder', () => { }); it('should build request without cursor when not provided', () => { - const result = buildLookupTargetEntitiesRequest({ - entityType: 'user', - entityId: 'user-123', - TargetEntityType: 'document', - action: 'read', - limit: 50 - }); + const result = buildLookupTargetEntitiesRequest( + { + entityType: 'user', + entityId: 'user-123', + TargetEntityType: 'document', + action: 'read', + limit: 50 + }, + LEGACY_SCOPE + ); expect(result.optionalCursor).toBeUndefined(); }); it('should create valid v1.LookupResourcesRequest', () => { - const result = buildLookupTargetEntitiesRequest({ - entityType: 'user', - entityId: 'user-123', - TargetEntityType: 'document', - action: 'read', - limit: 50 - }); + const result = buildLookupTargetEntitiesRequest( + { + entityType: 'user', + entityId: 'user-123', + TargetEntityType: 'document', + action: 'read', + limit: 50 + }, + LEGACY_SCOPE + ); // Verify it's a valid LookupResourcesRequest structure expect(result).toHaveProperty('resourceObjectType'); @@ -57,13 +69,16 @@ describe('lookup-request.builder', () => { }); it('should encode entityId to URL-safe base64', () => { - const result = buildLookupTargetEntitiesRequest({ - entityType: 'user', - entityId: 'user+special/chars=test', - TargetEntityType: 'document', - action: 'read', - limit: 50 - }); + const result = buildLookupTargetEntitiesRequest( + { + entityType: 'user', + entityId: 'user+special/chars=test', + TargetEntityType: 'document', + action: 'read', + limit: 50 + }, + LEGACY_SCOPE + ); const encodedId = result.subject?.object?.objectId; // Should not contain +, /, or = (URL-safe base64) @@ -75,12 +90,15 @@ describe('lookup-request.builder', () => { describe('buildLookupEntitiesRequest', () => { it('should build request with all parameters and encode TargetEntityId', () => { - const result = buildLookupEntitiesRequest({ - TargetEntityType: 'document', - TargetEntityId: 'doc-123', - entityType: 'user', - action: 'view' - }); + const result = buildLookupEntitiesRequest( + { + TargetEntityType: 'document', + TargetEntityId: 'doc-123', + entityType: 'user', + action: 'view' + }, + LEGACY_SCOPE + ); expect(result.resource?.objectType).toBe('document'); // TargetEntityId should be base64 encoded @@ -90,12 +108,15 @@ describe('lookup-request.builder', () => { }); it('should create valid v1.LookupSubjectsRequest', () => { - const result = buildLookupEntitiesRequest({ - TargetEntityType: 'document', - TargetEntityId: 'doc-123', - entityType: 'user', - action: 'view' - }); + const result = buildLookupEntitiesRequest( + { + TargetEntityType: 'document', + TargetEntityId: 'doc-123', + entityType: 'user', + action: 'view' + }, + LEGACY_SCOPE + ); // Verify it's a valid LookupSubjectsRequest structure expect(result).toHaveProperty('resource'); @@ -104,12 +125,15 @@ describe('lookup-request.builder', () => { }); it('should handle different target entity types and encode TargetEntityId', () => { - const result = buildLookupEntitiesRequest({ - TargetEntityType: 'folder', - TargetEntityId: 'folder-456', - entityType: 'group', - action: 'admin' - }); + const result = buildLookupEntitiesRequest( + { + TargetEntityType: 'folder', + TargetEntityId: 'folder-456', + entityType: 'group', + action: 'admin' + }, + LEGACY_SCOPE + ); expect(result.resource?.objectType).toBe('folder'); expect(result.resource?.objectId).toBe(encodeObjectId('folder-456')); @@ -118,12 +142,15 @@ describe('lookup-request.builder', () => { }); it('should encode TargetEntityId to URL-safe base64', () => { - const result = buildLookupEntitiesRequest({ - TargetEntityType: 'document', - TargetEntityId: 'doc+special/chars=test', - entityType: 'user', - action: 'view' - }); + const result = buildLookupEntitiesRequest( + { + TargetEntityType: 'document', + TargetEntityId: 'doc+special/chars=test', + entityType: 'user', + action: 'view' + }, + LEGACY_SCOPE + ); const encodedId = result.resource?.objectId; // Should not contain +, /, or = (URL-safe base64) @@ -153,7 +180,8 @@ describe('lookup-request.builder', () => { entityType: 'frontegg_tenant', entityId: 'tenant-1', cursor: 'cursor-token' - } + }, + LEGACY_SCOPE ); expect(result.resourceObjectType).toBe('frontegg_feature'); @@ -181,7 +209,8 @@ describe('lookup-request.builder', () => { { entityType: 'frontegg_user', entityId: 'user-1' - } + }, + LEGACY_SCOPE ); expect(result.resourceObjectType).toBe('frontegg_feature'); diff --git a/src/spicedb/spicedb-queries/lookup-request.builder.ts b/src/spicedb/spicedb-queries/lookup-request.builder.ts index 74b9273..9616d31 100644 --- a/src/spicedb/spicedb-queries/lookup-request.builder.ts +++ b/src/spicedb/spicedb-queries/lookup-request.builder.ts @@ -9,17 +9,21 @@ import { import { encodeObjectId } from './base64.utils'; import { createActiveAtCaveatContext, createTargetingCaveatContext } from './caveat-context.utils'; import { SpiceDBEntities } from '../../types/spicedb-consts'; +import { SchemaScope } from '../../instances/schema-scope'; -export function buildLookupTargetEntitiesRequest(params: LookupTargetEntitiesRequest): v1.LookupResourcesRequest { +export function buildLookupTargetEntitiesRequest( + params: LookupTargetEntitiesRequest, + scope: SchemaScope +): v1.LookupResourcesRequest { const { entityType, entityId, TargetEntityType, action, limit, cursor, at } = params; const caveatContext = createActiveAtCaveatContext(at); return v1.LookupResourcesRequest.create({ - resourceObjectType: TargetEntityType, + resourceObjectType: scope.type(TargetEntityType), permission: action, subject: { object: { - objectType: entityType, + objectType: scope.type(entityType), objectId: encodeObjectId(entityId) }, optionalRelation: '' @@ -30,17 +34,20 @@ export function buildLookupTargetEntitiesRequest(params: LookupTargetEntitiesReq }); } -export function buildLookupEntitiesRequest(params: LookupEntitiesRequest): v1.LookupSubjectsRequest { +export function buildLookupEntitiesRequest( + params: LookupEntitiesRequest, + scope: SchemaScope +): v1.LookupSubjectsRequest { const { TargetEntityType, TargetEntityId, entityType, action, at } = params; const caveatContext = createActiveAtCaveatContext(at); return v1.LookupSubjectsRequest.create({ resource: { - objectType: TargetEntityType, + objectType: scope.type(TargetEntityType), objectId: encodeObjectId(TargetEntityId) }, permission: action, - subjectObjectType: entityType, + subjectObjectType: scope.type(entityType), context: caveatContext }); } @@ -48,6 +55,7 @@ export function buildLookupEntitiesRequest(params: LookupEntitiesRequest): v1.Lo export function buildLookupEntitlementsRequest( params: LookupEntitlementsRequest, subject: LookupEntitlementsSubject, + scope: SchemaScope, now?: string ): v1.LookupResourcesRequest { const { criteria, limit } = params; @@ -61,11 +69,11 @@ export function buildLookupEntitlementsRequest( } return v1.LookupResourcesRequest.create({ - resourceObjectType: SpiceDBEntities.Feature, + resourceObjectType: scope.type(SpiceDBEntities.Feature), permission: 'access', subject: { object: { - objectType: subject.entityType, + objectType: scope.type(subject.entityType), objectId: encodeObjectId(subject.entityId) }, optionalRelation: '' diff --git a/src/spicedb/spicedb-queries/lookup-response.mapper.spec.ts b/src/spicedb/spicedb-queries/lookup-response.mapper.spec.ts index 27922b0..a950b28 100644 --- a/src/spicedb/spicedb-queries/lookup-response.mapper.spec.ts +++ b/src/spicedb/spicedb-queries/lookup-response.mapper.spec.ts @@ -7,6 +7,9 @@ import { import { permissionshipMap } from '../lookup.constants'; import { encodeObjectId } from './base64.utils'; import { RequestContextType } from '../../types'; +import { SchemaScope } from '../../instances/schema-scope'; + +const LEGACY_SCOPE = new SchemaScope(''); describe('lookup-response.mapper', () => { describe('mapPermissionship', () => { @@ -49,7 +52,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupTargetEntitiesResponse(results, 'document', 50); + const response = mapLookupTargetEntitiesResponse(results, 'document', 50, LEGACY_SCOPE); expect(response.targets).toHaveLength(2); // Should decode to original IDs @@ -75,7 +78,7 @@ describe('lookup-response.mapper', () => { afterResultCursor: i === 9 ? { token: 'next-cursor' } : undefined })); - const response = mapLookupTargetEntitiesResponse(results, 'document', 10); + const response = mapLookupTargetEntitiesResponse(results, 'document', 10, LEGACY_SCOPE); expect(response.cursor).toBe('next-cursor'); }); @@ -91,13 +94,13 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupTargetEntitiesResponse(results, 'document', 50); + const response = mapLookupTargetEntitiesResponse(results, 'document', 50, LEGACY_SCOPE); expect(response.cursor).toBeUndefined(); }); it('should handle empty results', () => { - const response = mapLookupTargetEntitiesResponse([], 'document', 50); + const response = mapLookupTargetEntitiesResponse([], 'document', 50, LEGACY_SCOPE); expect(response.targets).toHaveLength(0); expect(response.totalReturned).toBe(0); @@ -116,7 +119,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupTargetEntitiesResponse(results, 'document', 50); + const response = mapLookupTargetEntitiesResponse(results, 'document', 50, LEGACY_SCOPE); expect(response.targets[0].TargetEntityId).toBe(originalId); }); @@ -156,7 +159,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupEntitiesResponse(results, 'user'); + const response = mapLookupEntitiesResponse(results, 'user', LEGACY_SCOPE); expect(response.entities).toHaveLength(2); // Should decode to original IDs @@ -174,7 +177,7 @@ describe('lookup-response.mapper', () => { }); it('should handle empty results', () => { - const response = mapLookupEntitiesResponse([], 'user'); + const response = mapLookupEntitiesResponse([], 'user', LEGACY_SCOPE); expect(response.entities).toHaveLength(0); expect(response.totalReturned).toBe(0); @@ -194,7 +197,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupEntitiesResponse(results, 'user'); + const response = mapLookupEntitiesResponse(results, 'user', LEGACY_SCOPE); expect(response.entities[0].entityId).toBe(''); expect(response.entities[0].permissionship).toBeUndefined(); @@ -220,7 +223,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupEntitiesResponse(results, 'user'); + const response = mapLookupEntitiesResponse(results, 'user', LEGACY_SCOPE); // Should use the subject field value, not the deprecated top-level one expect(response.entities[0].permissionship).toBe('CONDITIONAL_PERMISSION'); @@ -246,7 +249,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupEntitiesResponse(results, 'cust_user'); + const response = mapLookupEntitiesResponse(results, 'cust_user', LEGACY_SCOPE); expect(response.entities[0].entityId).toBe(originalUserId); }); diff --git a/src/spicedb/spicedb-queries/lookup-response.mapper.ts b/src/spicedb/spicedb-queries/lookup-response.mapper.ts index 1f9fb81..cd5dafc 100644 --- a/src/spicedb/spicedb-queries/lookup-response.mapper.ts +++ b/src/spicedb/spicedb-queries/lookup-response.mapper.ts @@ -10,14 +10,16 @@ import { } from '../../types'; import { permissionshipMap } from '../lookup.constants'; import { decodeObjectId } from './base64.utils'; +import { SchemaScope } from '../../instances/schema-scope'; export function mapLookupTargetEntitiesResponse( results: v1.LookupResourcesResponse[], TargetEntityType: string, - limit: number + limit: number, + scope: SchemaScope ): LookupTargetEntitiesResponse { const targets: TargetEntityItem[] = results.map((result) => ({ - TargetEntityType, + TargetEntityType: scope.strip(TargetEntityType), TargetEntityId: decodeObjectId(result.resourceObjectId), permissionship: permissionshipMap.get(result.permissionship) })); @@ -34,10 +36,11 @@ export function mapLookupTargetEntitiesResponse( export function mapLookupEntitiesResponse( results: v1.LookupSubjectsResponse[], - entityType: string + entityType: string, + scope: SchemaScope ): LookupEntitiesResponse { const entities: EntityItem[] = results.map((result) => ({ - entityType, + entityType: scope.strip(entityType), entityId: decodeObjectId(result.subject?.subjectObjectId ?? ''), permissionship: result.subject ? permissionshipMap.get(result.subject.permissionship) : undefined })); diff --git a/src/spicedb/spicedb-queries/permission-spicedb.query.ts b/src/spicedb/spicedb-queries/permission-spicedb.query.ts index ba8ef34..8e2fc6e 100644 --- a/src/spicedb/spicedb-queries/permission-spicedb.query.ts +++ b/src/spicedb/spicedb-queries/permission-spicedb.query.ts @@ -4,6 +4,7 @@ import { SpiceDBResponse } from '../../types/spicedb.dto'; import { v1 } from '@authzed/authzed-node'; import { SpiceDBEntities } from '../../types/spicedb-consts'; import { LoggingClient } from '../../logging'; +import { SchemaScope } from '../../instances/schema-scope'; export class PermissionSpiceDBQuery extends EntitlementsSpiceDBQuery { constructor( @@ -14,10 +15,10 @@ export class PermissionSpiceDBQuery extends EntitlementsSpiceDBQuery { super(client, loggingClient, logResults); } - public async query({ - requestContext, - subjectContext - }: EntitlementsDynamicQuery): Promise> { + public async query( + { requestContext, subjectContext }: EntitlementsDynamicQuery, + scope: SchemaScope + ): Promise> { const context = subjectContext as UserSubjectContext; if (!this.hasPermission(requestContext.permissionKey, context.permissions)) { @@ -28,7 +29,7 @@ export class PermissionSpiceDBQuery extends EntitlementsSpiceDBQuery { }; } - const isPermissionLinkedToFeatures = await this.isPermissionLinkedToFeatures(requestContext); + const isPermissionLinkedToFeatures = await this.isPermissionLinkedToFeatures(scope, requestContext); if (!isPermissionLinkedToFeatures) { return { result: { @@ -36,6 +37,6 @@ export class PermissionSpiceDBQuery extends EntitlementsSpiceDBQuery { } }; } - return this.executeCommonQuery(SpiceDBEntities.Permission, requestContext.permissionKey, context); + return this.executeCommonQuery(scope, SpiceDBEntities.Permission, requestContext.permissionKey, context); } } diff --git a/src/spicedb/spicedb-queries/route-spicedb.query.spec.ts b/src/spicedb/spicedb-queries/route-spicedb.query.spec.ts index ead6ac9..1fbfc7d 100644 --- a/src/spicedb/spicedb-queries/route-spicedb.query.spec.ts +++ b/src/spicedb/spicedb-queries/route-spicedb.query.spec.ts @@ -2,6 +2,9 @@ import { v1 } from '@authzed/authzed-node'; import { mock, MockProxy, mockReset } from 'jest-mock-extended'; import { EntitlementsDynamicQueryRequestContext, RequestContextType, UserSubjectContext } from '../../types'; import { RouteSpiceDBQuery } from './route-spicedb.query'; +import { SchemaScope } from '../../instances/schema-scope'; + +const LEGACY_SCOPE = new SchemaScope(''); describe(RouteSpiceDBQuery.name, () => { let queryClient: RouteSpiceDBQuery; @@ -79,10 +82,13 @@ describe(RouteSpiceDBQuery.name, () => { mockClient.checkBulkPermissions.mockRejectedValue(mockError); await expect( - queryClient.query({ - subjectContext, - requestContext - }) + queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ) ).rejects.toThrow(mockError); }); @@ -102,10 +108,13 @@ describe(RouteSpiceDBQuery.name, () => { mockClient.checkBulkPermissions.mockResolvedValue(mockBulkResponse); try { - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); // If we get here, the implementation has changed to handle empty arrays expect(result.result.result).toBe(false); @@ -174,10 +183,13 @@ describe(RouteSpiceDBQuery.name, () => { }); mockClient.checkBulkPermissions.mockResolvedValue(mockBulkResponse); - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); expect(mockClient.checkBulkPermissions).toHaveBeenCalled(); expect(result.result.result).toBe(true); @@ -242,10 +254,13 @@ describe(RouteSpiceDBQuery.name, () => { }); mockClient.checkBulkPermissions.mockResolvedValue(mockBulkResponse); - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); expect(result.result.result).toBe(true); expect(result.result.monitoring).toBe(true); @@ -292,10 +307,13 @@ describe(RouteSpiceDBQuery.name, () => { // This shouldn't be called for allow policy type mockClient.checkBulkPermissions.mockResolvedValue(null as any); - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); expect(mockClient.checkBulkPermissions).not.toHaveBeenCalled(); expect(result.result.result).toBe(true); @@ -342,10 +360,13 @@ describe(RouteSpiceDBQuery.name, () => { // This shouldn't be called for deny policy type mockClient.checkBulkPermissions.mockResolvedValue(null as any); - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); expect(mockClient.checkBulkPermissions).not.toHaveBeenCalled(); expect(result.result.result).toBe(false); @@ -431,10 +452,13 @@ describe(RouteSpiceDBQuery.name, () => { // This shouldn't be called because the highest priority rule is deny mockClient.checkBulkPermissions.mockResolvedValue(null as any); - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); expect(mockClient.checkBulkPermissions).not.toHaveBeenCalled(); expect(result.result.result).toBe(false); @@ -501,10 +525,13 @@ describe(RouteSpiceDBQuery.name, () => { }); mockClient.checkBulkPermissions.mockResolvedValue(mockBulkResponse); - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); expect(result.result.result).toBe(true); }); @@ -555,10 +582,13 @@ describe(RouteSpiceDBQuery.name, () => { // Mock hasPermission method to return false (missing permission) jest.spyOn(queryClient as any, 'hasPermission').mockReturnValue(false); - const result = await queryClient.query({ - subjectContext, - requestContext - }); + const result = await queryClient.query( + { + subjectContext, + requestContext + }, + LEGACY_SCOPE + ); expect(mockClient.checkBulkPermissions).not.toHaveBeenCalled(); expect(result.result.result).toBe(false); diff --git a/src/spicedb/spicedb-queries/route-spicedb.query.ts b/src/spicedb/spicedb-queries/route-spicedb.query.ts index d369ae3..5ef44bf 100644 --- a/src/spicedb/spicedb-queries/route-spicedb.query.ts +++ b/src/spicedb/spicedb-queries/route-spicedb.query.ts @@ -6,6 +6,7 @@ import { LRUCache } from 'lru-cache'; import { SpiceDBEntities } from '../../types/spicedb-consts'; import { encodeObjectId } from './base64.utils'; import { LoggingClient } from '../../logging'; +import { SchemaScope } from '../../instances/schema-scope'; export class RouteSpiceDBQuery extends EntitlementsSpiceDBQuery { private readonly cache: LRUCache; @@ -30,22 +31,23 @@ export class RouteSpiceDBQuery extends EntitlementsSpiceDBQuery { return { result }; } - async query({ - subjectContext, - requestContext - }: EntitlementsDynamicQuery): Promise> { + async query( + { subjectContext, requestContext }: EntitlementsDynamicQuery, + scope: SchemaScope + ): Promise> { const context = subjectContext as UserSubjectContext; let isMonitoringEnabled = false; const request = v1.ReadRelationshipsRequest.create({ relationshipFilter: { - resourceType: SpiceDBEntities.Route + resourceType: scope.type(SpiceDBEntities.Route) } }); - let relations: v1.ReadRelationshipsResponse[] | undefined = this.cache.get('routes-relations'); + const cacheKey = `routes-relations:${scope.schemaPrefix}`; + let relations: v1.ReadRelationshipsResponse[] | undefined = this.cache.get(cacheKey); if (!relations) { relations = await this.client.readRelationships(request); - this.cache.set('routes-relations', relations); + this.cache.set(cacheKey, relations); } let objects = relations .filter((relation: v1.ReadRelationshipsResponse) => { @@ -115,7 +117,8 @@ export class RouteSpiceDBQuery extends EntitlementsSpiceDBQuery { const caveatContext = this.createCaveatContext(context); const bulkRequest = this.createBulkPermissionsRequest( - firstRule.resourceType, + scope, + scope.strip(firstRule.resourceType), firstRule.resourceId, context, caveatContext, diff --git a/src/spicedb/spicedb-queries/schema-scope-threading.spec.ts b/src/spicedb/spicedb-queries/schema-scope-threading.spec.ts new file mode 100644 index 0000000..2e5c353 --- /dev/null +++ b/src/spicedb/spicedb-queries/schema-scope-threading.spec.ts @@ -0,0 +1,229 @@ +import { v1 } from '@authzed/authzed-node'; +import { mock, MockProxy } from 'jest-mock-extended'; +import { FeaturesSpiceDBQuery } from './features-spicedb.query'; +import { PermissionSpiceDBQuery } from './permission-spicedb.query'; +import { FgaSpiceDBQuery } from './fga-spicedb.query'; +import { RouteSpiceDBQuery } from './route-spicedb.query'; +import { RequestContextType, UserSubjectContext, FGASubjectContext } from '../../types'; +import { SchemaScope } from '../../instances/schema-scope'; +import { ConfigurationInputIsInvalidException } from '../../exceptions/configuration-input-is-invalid.exception'; +import { buildLookupTargetEntitiesRequest, buildLookupEntitiesRequest } from './lookup-request.builder'; + +const PREFIX = 'v_2f9c1a44_7b0e_4a1e_9f8a_1c2d3e4f5a6b'; +const SCOPED = new SchemaScope(PREFIX); +const LEGACY = new SchemaScope(''); + +const userSubject: UserSubjectContext = { + tenantId: 'tenant-1', + userId: 'user-1', + permissions: ['read.*'], + attributes: {} +}; + +function emptyBulkResponse(): v1.CheckBulkPermissionsResponse { + return v1.CheckBulkPermissionsResponse.create({ pairs: [] }); +} + +describe('schema scope threading', () => { + let client: MockProxy; + + beforeEach(() => { + client = mock(); + client.checkBulkPermissions.mockResolvedValue(emptyBulkResponse()); + client.checkPermission.mockResolvedValue( + v1.CheckPermissionResponse.create({ + permissionship: v1.CheckPermissionResponse_Permissionship.NO_PERMISSION + }) + ); + client.lookupSubjects.mockResolvedValue([]); + client.readRelationships.mockResolvedValue([]); + }); + + describe.each([ + ['prefixed', SCOPED, `${PREFIX}/`], + ['legacy', LEGACY, ''] + ])('%s instance', (_label, scope, expected) => { + it('should scope the feature check request', async () => { + const query = new FeaturesSpiceDBQuery(client); + + await query.query( + { + requestContext: { type: RequestContextType.Feature, featureKey: 'premium' }, + subjectContext: userSubject + }, + scope + ); + + const request = client.checkBulkPermissions.mock.calls[0][0]; + expect(request.items[0].resource?.objectType).toBe(`${expected}frontegg_feature`); + expect(request.items[0].subject?.object?.objectType).toBe(`${expected}frontegg_tenant`); + expect(request.items[1].subject?.object?.objectType).toBe(`${expected}frontegg_user`); + }); + + it('should scope the permission linkage lookup', async () => { + const query = new PermissionSpiceDBQuery(client); + + await query.query( + { + requestContext: { type: RequestContextType.Permission, permissionKey: 'read.thing' }, + subjectContext: userSubject + }, + scope + ); + + const request = client.lookupSubjects.mock.calls[0][0]; + expect(request.resource?.objectType).toBe(`${expected}frontegg_permission`); + expect(request.subjectObjectType).toBe(`${expected}frontegg_feature`); + }); + + it('should scope both sides of an FGA check', async () => { + const query = new FgaSpiceDBQuery(client); + const fgaSubject: FGASubjectContext = { entityType: 'cust_user', key: 'user-1' }; + + await query.query( + { + requestContext: { + type: RequestContextType.Entity, + entityType: 'cust_document', + key: 'doc-1', + action: 'access' + }, + subjectContext: fgaSubject + }, + scope + ); + + const request = client.checkPermission.mock.calls[0][0]; + expect(request.resource?.objectType).toBe(`${expected}cust_document`); + expect(request.subject?.object?.objectType).toBe(`${expected}cust_user`); + }); + + it('should scope the route relationship read', async () => { + const query = new RouteSpiceDBQuery(client); + + await query.query( + { + requestContext: { type: RequestContextType.Route, method: 'GET', path: '/a' }, + subjectContext: userSubject + }, + scope + ); + + const request = client.readRelationships.mock.calls[0][0]; + expect(request.relationshipFilter?.resourceType).toBe(`${expected}frontegg_route`); + }); + + it('should scope both lookup request builders', () => { + const targets = buildLookupTargetEntitiesRequest( + { + entityType: 'cust_user', + entityId: 'user-1', + TargetEntityType: 'cust_document', + action: 'access', + limit: 10 + }, + scope + ); + expect(targets.resourceObjectType).toBe(`${expected}cust_document`); + expect(targets.subject?.object?.objectType).toBe(`${expected}cust_user`); + + const entities = buildLookupEntitiesRequest( + { + TargetEntityType: 'cust_document', + TargetEntityId: 'doc-1', + entityType: 'cust_user', + action: 'access' + }, + scope + ); + expect(entities.resource?.objectType).toBe(`${expected}cust_document`); + expect(entities.subjectObjectType).toBe(`${expected}cust_user`); + }); + }); + + describe('prefix escape rejection', () => { + it('should reject an FGA resource entityType containing a prefix separator', async () => { + const query = new FgaSpiceDBQuery(client); + + await expect( + query.query( + { + requestContext: { + type: RequestContextType.Entity, + entityType: 'v_other/cust_document', + key: 'doc-1', + action: 'access' + }, + subjectContext: { entityType: 'cust_user', key: 'user-1' } + }, + SCOPED + ) + ).rejects.toBeInstanceOf(ConfigurationInputIsInvalidException); + expect(client.checkPermission).not.toHaveBeenCalled(); + }); + + it('should reject an FGA subject entityType containing a prefix separator', async () => { + const query = new FgaSpiceDBQuery(client); + + await expect( + query.query( + { + requestContext: { + type: RequestContextType.Entity, + entityType: 'cust_document', + key: 'doc-1', + action: 'access' + }, + subjectContext: { entityType: 'v_other/cust_user', key: 'user-1' } + }, + SCOPED + ) + ).rejects.toBeInstanceOf(ConfigurationInputIsInvalidException); + expect(client.checkPermission).not.toHaveBeenCalled(); + }); + + it('should reject a lookup type containing a prefix separator', () => { + expect(() => + buildLookupTargetEntitiesRequest( + { + entityType: 'cust_user', + entityId: 'user-1', + TargetEntityType: 'v_other/cust_document', + action: 'access', + limit: 10 + }, + SCOPED + ) + ).toThrow(ConfigurationInputIsInvalidException); + }); + }); + + describe('route cache isolation', () => { + it('should not share cached route relationships across instances', async () => { + const query = new RouteSpiceDBQuery(client); + const other = new SchemaScope('v_8b1d0e77_3c5a_4f2b_9d6e_7a8b9c0d1e2f'); + const requestContext = { type: RequestContextType.Route as const, method: 'GET', path: '/a' }; + + await query.query({ requestContext, subjectContext: userSubject }, SCOPED); + await query.query({ requestContext, subjectContext: userSubject }, other); + + expect(client.readRelationships).toHaveBeenCalledTimes(2); + expect(client.readRelationships.mock.calls[0][0].relationshipFilter?.resourceType).toBe( + `${PREFIX}/frontegg_route` + ); + expect(client.readRelationships.mock.calls[1][0].relationshipFilter?.resourceType).toBe( + 'v_8b1d0e77_3c5a_4f2b_9d6e_7a8b9c0d1e2f/frontegg_route' + ); + }); + + it('should reuse the cache within one instance', async () => { + const query = new RouteSpiceDBQuery(client); + const requestContext = { type: RequestContextType.Route as const, method: 'GET', path: '/a' }; + + await query.query({ requestContext, subjectContext: userSubject }, SCOPED); + await query.query({ requestContext, subjectContext: userSubject }, SCOPED); + + expect(client.readRelationships).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/spicedb/spicedb-queries/spicedb-query.client.spec.ts b/src/spicedb/spicedb-queries/spicedb-query.client.spec.ts index f31c9cd..89b2b6e 100644 --- a/src/spicedb/spicedb-queries/spicedb-query.client.spec.ts +++ b/src/spicedb/spicedb-queries/spicedb-query.client.spec.ts @@ -3,6 +3,9 @@ import { mock, MockProxy, mockReset } from 'jest-mock-extended'; import { v1 } from '@authzed/authzed-node'; import { RequestContext, RequestContextType, SubjectContext } from '../../types'; import { getRequestContext } from './entitlements-spicedb.query.spec-helper'; +import { SchemaScope } from '../../instances/schema-scope'; + +const LEGACY_SCOPE = new SchemaScope(''); describe(SpiceDBQueryClient.name, () => { let queryClient: SpiceDBQueryClient; @@ -32,8 +35,8 @@ describe(SpiceDBQueryClient.name, () => { const mockStrategy = jest.spyOn(queryClient['strategy'][requestContextType], 'query'); mockStrategy.mockResolvedValue({ result: { result: true } }); - await queryClient.spiceDBQuery(subjectContext, requestContext); + await queryClient.spiceDBQuery(subjectContext, requestContext, LEGACY_SCOPE); - expect(mockStrategy).toHaveBeenCalledWith({ requestContext, subjectContext }); + expect(mockStrategy).toHaveBeenCalledWith({ requestContext, subjectContext }, LEGACY_SCOPE); }); }); diff --git a/src/spicedb/spicedb-queries/spicedb-query.client.ts b/src/spicedb/spicedb-queries/spicedb-query.client.ts index b3e7f7b..bd8f34a 100644 --- a/src/spicedb/spicedb-queries/spicedb-query.client.ts +++ b/src/spicedb/spicedb-queries/spicedb-query.client.ts @@ -14,6 +14,7 @@ import { FgaSpiceDBQuery } from './fga-spicedb.query'; import { RouteSpiceDBQuery } from './route-spicedb.query'; import { v1 } from '@authzed/authzed-node'; import { LoggingClient } from '../../logging'; +import { SchemaScope } from '../../instances/schema-scope'; export class SpiceDBQueryClient { private readonly strategy: Record; @@ -33,18 +34,21 @@ export class SpiceDBQueryClient { async spiceDBQuery( subjectContext: SubjectContext, - requestContext: RequestContext + requestContext: RequestContext, + scope: SchemaScope ): Promise> { - return this.strategy[requestContext.type].query({ requestContext, subjectContext }); + return this.strategy[requestContext.type].query({ requestContext, subjectContext }, scope); } async spiceDBBatchFeatureQuery( subjectContext: UserSubjectContext, - featureKeys: string[] + featureKeys: string[], + scope: SchemaScope ): Promise> { return (this.strategy[RequestContextType.Feature] as FeaturesSpiceDBQuery).queryMany( subjectContext, - featureKeys + featureKeys, + scope ); } } From e1a4ec81553a3b5921288f5e58d27b5422b5d67b Mon Sep 17 00:00:00 2001 From: dianaKhortiuk-frontegg Date: Fri, 28 Aug 2026 14:22:41 +0300 Subject: [PATCH 2/6] feat(instances): per-instance schema read, instanceId in logs, deprecate 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 --- ...cedb-entitlements.client.instances.spec.ts | 51 +++++++++++++++++++ .../spicedb-entitlements.client.spec.ts | 4 +- src/spicedb/spicedb-entitlements.client.ts | 27 ++++++++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/spicedb/spicedb-entitlements.client.instances.spec.ts b/src/spicedb/spicedb-entitlements.client.instances.spec.ts index 0adf2be..08747ac 100644 --- a/src/spicedb/spicedb-entitlements.client.instances.spec.ts +++ b/src/spicedb/spicedb-entitlements.client.instances.spec.ts @@ -155,6 +155,57 @@ describe('SpiceDBEntitlementsClient instance isolation', () => { }); }); + describe('readSchemaFor', () => { + const schemaText = [ + `definition ${PREFIX_A}/frontegg_feature {}`, + `definition ${PREFIX_A}/cust_document {}`, + `caveat ${PREFIX_A}/targeting(x int) {\n x == x\n}`, + `definition ${PREFIX_B}/frontegg_feature {}`, + `caveat ${PREFIX_B}/targeting(x int) {\n x == x\n}` + ].join('\n'); + + function withSchema(client: SpiceDBEntitlementsClient): void { + (client as unknown as { spiceClient: { readSchema: jest.Mock } }).spiceClient = { + readSchema: jest.fn().mockResolvedValue({ schemaText }) + }; + } + + it('should return only the requested instance definitions', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + withSchema(client); + + const schema = await client.readSchemaFor('a'); + + expect(schema).toContain(`definition ${PREFIX_A}/frontegg_feature`); + expect(schema).toContain(`caveat ${PREFIX_A}/targeting`); + expect(schema).not.toContain(PREFIX_B); + }); + + it('should not leak another instance schema', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + withSchema(client); + + const schema = await client.readSchemaFor('b'); + + expect(schema).toContain(`definition ${PREFIX_B}/frontegg_feature`); + expect(schema).not.toContain(PREFIX_A); + }); + + it('should return the whole schema for a legacy instance', async () => { + const client = buildClient({}, queryClient, loggingClient); + withSchema(client); + + await expect(client.readSchemaFor()).resolves.toBe(schemaText); + }); + + it('should throw for an unknown instanceId', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + withSchema(client); + + await expect(client.readSchemaFor('nope')).rejects.toBeInstanceOf(UnknownInstanceException); + }); + }); + describe('per-instance fallback', () => { it('should prefer the instance fallback over the client fallback', async () => { queryClient.spiceDBQuery.mockRejectedValue(new Error('spicedb unavailable')); diff --git a/src/spicedb/spicedb-entitlements.client.spec.ts b/src/spicedb/spicedb-entitlements.client.spec.ts index 0e62fd8..d981c8f 100644 --- a/src/spicedb/spicedb-entitlements.client.spec.ts +++ b/src/spicedb/spicedb-entitlements.client.spec.ts @@ -300,11 +300,11 @@ describe(SpiceDBEntitlementsClient.name, () => { await cut.isEntitledToMany(subjectContext, requestContexts); expect(mockLoggingClient.logRequest).toHaveBeenCalledWith( - { action: 'SpiceDB:isEntitledToMany:request', subjectContext, requestContexts }, + { action: 'SpiceDB:isEntitledToMany:request', instanceId: 'default', subjectContext, requestContexts }, null ); expect(mockLoggingClient.logRequest).toHaveBeenCalledWith( - { action: 'SpiceDB:isEntitledToMany:response', subjectContext, requestContexts }, + { action: 'SpiceDB:isEntitledToMany:response', instanceId: 'default', subjectContext, requestContexts }, [{ result: true }] ); }); diff --git a/src/spicedb/spicedb-entitlements.client.ts b/src/spicedb/spicedb-entitlements.client.ts index 516e9b9..e582ccd 100644 --- a/src/spicedb/spicedb-entitlements.client.ts +++ b/src/spicedb/spicedb-entitlements.client.ts @@ -48,6 +48,10 @@ export interface InstanceOptions { export class SpiceDBEntitlementsClient { private static readonly MONITORING_RESULT: EntitlementsResult = { monitoring: true, result: true }; + /** + * @deprecated Direct access bypasses instance scoping and can read another instance's data. + * Use isEntitledTo / lookup* / readSchemaFor with an instanceId instead. + */ public readonly spiceClient: v1.ZedPromiseClientInterface; private readonly spiceDBQueryClient: SpiceDBQueryClient; @@ -98,7 +102,12 @@ export class SpiceDBEntitlementsClient { const instance = this.resolve(options?.instanceId); if (this.logResults) { await this.loggingClient.logRequest( - { action: 'SpiceDB:isEntitledToMany:request', subjectContext, requestContexts }, + { + action: 'SpiceDB:isEntitledToMany:request', + instanceId: instance.instanceId, + subjectContext, + requestContexts + }, null ); } @@ -143,7 +152,12 @@ export class SpiceDBEntitlementsClient { if (this.logResults) { await this.loggingClient.logRequest( - { action: 'SpiceDB:isEntitledToMany:response', subjectContext, requestContexts }, + { + action: 'SpiceDB:isEntitledToMany:response', + instanceId: instance.instanceId, + subjectContext, + requestContexts + }, results ); } @@ -312,7 +326,7 @@ export class SpiceDBEntitlementsClient { try { if (logPerItem) { await this.loggingClient.logRequest( - { action: `${logAction}:request`, subjectContext, requestContext }, + { action: `${logAction}:request`, instanceId: instance.instanceId, subjectContext, requestContext }, null ); } @@ -321,7 +335,12 @@ export class SpiceDBEntitlementsClient { if (logPerItem) { await this.loggingClient.logRequest( - { action: `${logAction}:response`, subjectContext, requestContext }, + { + action: `${logAction}:response`, + instanceId: instance.instanceId, + subjectContext, + requestContext + }, res ); } From d05162db05a65b25e216f1b2c93228a198ae6d89 Mon Sep 17 00:00:00 2001 From: dianaKhortiuk-frontegg Date: Mon, 31 Aug 2026 11:40:47 +0300 Subject: [PATCH 3/6] fix(instances): stop scoping violations being answered with a fallback 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.({...}) 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 --- src/instances/instance-registry.spec.ts | 22 ++++++++++ src/instances/instance-registry.ts | 5 ++- src/instances/resolve-instance.ts | 18 ++++---- src/instances/schema-scope.guard.spec.ts | 2 +- ...cedb-entitlements.client.instances.spec.ts | 42 +++++++++++++++++++ src/spicedb/spicedb-entitlements.client.ts | 9 ++-- .../lookup-response.mapper.spec.ts | 23 +++++----- .../spicedb-queries/lookup-response.mapper.ts | 11 ++--- 8 files changed, 98 insertions(+), 34 deletions(-) diff --git a/src/instances/instance-registry.spec.ts b/src/instances/instance-registry.spec.ts index 96b3986..d079736 100644 --- a/src/instances/instance-registry.spec.ts +++ b/src/instances/instance-registry.spec.ts @@ -98,6 +98,28 @@ describe(InstanceRegistry.name, () => { ); }); + it('should take defaultInstanceId from the configuration object', () => { + const registry = new InstanceRegistry({ + instances: [ + { instanceId: 'a', vendorId: VENDOR_A }, + { instanceId: 'b', vendorId: VENDOR_B } + ], + defaultInstanceId: 'b' + }); + + expect(registry.defaultInstanceId).toBe('b'); + }); + + it('should validate a defaultInstanceId given on the configuration object', () => { + expect( + () => + new InstanceRegistry({ + instances: [{ instanceId: 'a', vendorId: VENDOR_A }], + defaultInstanceId: 'missing' + }) + ).toThrow(ConfigurationInputIsInvalidException); + }); + it('should accept a defaultInstanceId that is configured', () => { const registry = new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: VENDOR_A }] }, 'a'); diff --git a/src/instances/instance-registry.ts b/src/instances/instance-registry.ts index 3c1a016..b5c2f7c 100644 --- a/src/instances/instance-registry.ts +++ b/src/instances/instance-registry.ts @@ -16,10 +16,13 @@ export class InstanceRegistry { private readonly instances: Map; private readonly order: string[]; + public readonly defaultInstanceId?: string; + constructor( configuration: Pick, - public readonly defaultInstanceId?: string + defaultInstanceId: string | undefined = configuration.defaultInstanceId ) { + this.defaultInstanceId = defaultInstanceId; const declared = configuration.instances; this.instances = new Map(); this.order = []; diff --git a/src/instances/resolve-instance.ts b/src/instances/resolve-instance.ts index 0571cfc..025680d 100644 --- a/src/instances/resolve-instance.ts +++ b/src/instances/resolve-instance.ts @@ -22,14 +22,16 @@ export function resolveInstance( const { defaultInstanceId } = registry; if (defaultInstanceId !== undefined) { - void loggingClient?.logRequest( - { - action: 'SpiceDB:resolveInstance:default', - instanceId: defaultInstanceId, - message: 'instanceId omitted; falling back to defaultInstanceId' - }, - null - ); + void Promise.resolve( + loggingClient?.logRequest( + { + action: 'SpiceDB:resolveInstance:default', + instanceId: defaultInstanceId, + message: 'instanceId omitted; falling back to defaultInstanceId' + }, + null + ) + ).catch(() => undefined); return registry.get(defaultInstanceId) as ResolvedInstance; } diff --git a/src/instances/schema-scope.guard.spec.ts b/src/instances/schema-scope.guard.spec.ts index 9827e33..032c230 100644 --- a/src/instances/schema-scope.guard.spec.ts +++ b/src/instances/schema-scope.guard.spec.ts @@ -6,7 +6,7 @@ const SRC_ROOT = join(__dirname, '..'); const OBJECT_TYPE_FIELD = /\b(objectType|resourceObjectType|subjectObjectType|resourceType):\s*([^\n,]+)/g; const REQUEST_SPAN_START = - /v1\.[A-Za-z]+\.create\(|createBulkPermissionRequestItem\([\s\S]*?\)\s*:\s*v1\.CheckBulkPermissionsRequestItem\s*\{/g; + /v1\.[A-Za-z]+\.create\(|this\.(?:client|spiceClient)\.[A-Za-z]+\(|createBulkPermissionRequestItem\([\s\S]*?\)\s*:\s*v1\.CheckBulkPermissionsRequestItem\s*\{/g; function collectSources(dir: string, acc: string[] = []): string[] { for (const entry of readdirSync(dir)) { diff --git a/src/spicedb/spicedb-entitlements.client.instances.spec.ts b/src/spicedb/spicedb-entitlements.client.instances.spec.ts index 08747ac..67ecdd8 100644 --- a/src/spicedb/spicedb-entitlements.client.instances.spec.ts +++ b/src/spicedb/spicedb-entitlements.client.instances.spec.ts @@ -7,6 +7,7 @@ import { ClientConfiguration } from '../client-configuration'; import { InstanceRegistry } from '../instances/instance-registry'; import { UnknownInstanceException } from '../exceptions/unknown-instance.exception'; import { InstanceIdRequiredException } from '../exceptions/instance-id-required.exception'; +import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception'; const VENDOR_A = '2f9c1a44-7b0e-4a1e-9f8a-1c2d3e4f5a6b'; const VENDOR_B = '8b1d0e77-3c5a-4f2b-9d6e-7a8b9c0d1e2f'; @@ -127,6 +128,47 @@ describe('SpiceDBEntitlementsClient instance isolation', () => { ); }); + it('should not swallow a prefix escape into the fallback boolean', async () => { + queryClient.spiceDBQuery.mockImplementation(() => { + throw new ConfigurationInputIsInvalidException( + "Object type 'v_other/cust_document' must not contain '/'" + ); + }); + const client = buildClient( + { instances: TWO_INSTANCES, fallbackConfiguration: { defaultFallback: true } }, + queryClient, + loggingClient + ); + + await expect( + client.isEntitledTo( + { entityType: 'v_other/cust_document', key: 'k' }, + { + type: RequestContextType.Entity, + entityType: 'v_other/cust_document', + key: 'doc-1', + action: 'access' + }, + { instanceId: 'a' } + ) + ).rejects.toBeInstanceOf(ConfigurationInputIsInvalidException); + }); + + it('should not swallow a prefix escape from the batch feature path', async () => { + queryClient.spiceDBBatchFeatureQuery.mockImplementation(() => { + throw new ConfigurationInputIsInvalidException("Object type 'v_other/x' must not contain '/'"); + }); + const client = buildClient( + { instances: TWO_INSTANCES, fallbackConfiguration: { defaultFallback: true } }, + queryClient, + loggingClient + ); + + await expect( + client.isEntitledToMany(subjectContext, [featureContext], { instanceId: 'a' }) + ).rejects.toBeInstanceOf(ConfigurationInputIsInvalidException); + }); + it('should still return the fallback for a genuine SpiceDB error', async () => { queryClient.spiceDBQuery.mockRejectedValue(new Error('spicedb unavailable')); const client = buildClient( diff --git a/src/spicedb/spicedb-entitlements.client.ts b/src/spicedb/spicedb-entitlements.client.ts index e582ccd..3a210e1 100644 --- a/src/spicedb/spicedb-entitlements.client.ts +++ b/src/spicedb/spicedb-entitlements.client.ts @@ -41,6 +41,7 @@ import { InstanceRegistry, ResolvedInstance } from '../instances/instance-regist import { resolveInstance } from '../instances/resolve-instance'; import { SchemaScope } from '../instances/schema-scope'; import { InstanceResolutionException } from '../exceptions/instance-resolution.exception'; +import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception'; export interface InstanceOptions { instanceId?: string; @@ -189,7 +190,7 @@ export class SpiceDBEntitlementsClient { if (this.logResults) { await this.loggingClient.logRequest(request, results); } - return mapLookupTargetEntitiesResponse(results, req.TargetEntityType, limit, scope); + return mapLookupTargetEntitiesResponse(results, req.TargetEntityType, limit); } catch (err) { await this.loggingClient.error(err); throw err; @@ -215,7 +216,7 @@ export class SpiceDBEntitlementsClient { if (this.logResults) { await this.loggingClient.logRequest(request, results); } - return mapLookupEntitiesResponse(results, req.entityType, scope); + return mapLookupEntitiesResponse(results, req.entityType); } catch (err) { await this.loggingClient.error(err); throw err; @@ -354,7 +355,7 @@ export class SpiceDBEntitlementsClient { } return res.result; } catch (err) { - if (err instanceof InstanceResolutionException) { + if (err instanceof InstanceResolutionException || err instanceof ConfigurationInputIsInvalidException) { throw err; } await this.loggingClient.error(err); @@ -521,7 +522,7 @@ export class SpiceDBEntitlementsClient { result: res.result[requestContext.featureKey] ?? { result: false } })); } catch (err) { - if (err instanceof InstanceResolutionException) { + if (err instanceof InstanceResolutionException || err instanceof ConfigurationInputIsInvalidException) { throw err; } await this.loggingClient.error(err); diff --git a/src/spicedb/spicedb-queries/lookup-response.mapper.spec.ts b/src/spicedb/spicedb-queries/lookup-response.mapper.spec.ts index a950b28..27922b0 100644 --- a/src/spicedb/spicedb-queries/lookup-response.mapper.spec.ts +++ b/src/spicedb/spicedb-queries/lookup-response.mapper.spec.ts @@ -7,9 +7,6 @@ import { import { permissionshipMap } from '../lookup.constants'; import { encodeObjectId } from './base64.utils'; import { RequestContextType } from '../../types'; -import { SchemaScope } from '../../instances/schema-scope'; - -const LEGACY_SCOPE = new SchemaScope(''); describe('lookup-response.mapper', () => { describe('mapPermissionship', () => { @@ -52,7 +49,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupTargetEntitiesResponse(results, 'document', 50, LEGACY_SCOPE); + const response = mapLookupTargetEntitiesResponse(results, 'document', 50); expect(response.targets).toHaveLength(2); // Should decode to original IDs @@ -78,7 +75,7 @@ describe('lookup-response.mapper', () => { afterResultCursor: i === 9 ? { token: 'next-cursor' } : undefined })); - const response = mapLookupTargetEntitiesResponse(results, 'document', 10, LEGACY_SCOPE); + const response = mapLookupTargetEntitiesResponse(results, 'document', 10); expect(response.cursor).toBe('next-cursor'); }); @@ -94,13 +91,13 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupTargetEntitiesResponse(results, 'document', 50, LEGACY_SCOPE); + const response = mapLookupTargetEntitiesResponse(results, 'document', 50); expect(response.cursor).toBeUndefined(); }); it('should handle empty results', () => { - const response = mapLookupTargetEntitiesResponse([], 'document', 50, LEGACY_SCOPE); + const response = mapLookupTargetEntitiesResponse([], 'document', 50); expect(response.targets).toHaveLength(0); expect(response.totalReturned).toBe(0); @@ -119,7 +116,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupTargetEntitiesResponse(results, 'document', 50, LEGACY_SCOPE); + const response = mapLookupTargetEntitiesResponse(results, 'document', 50); expect(response.targets[0].TargetEntityId).toBe(originalId); }); @@ -159,7 +156,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupEntitiesResponse(results, 'user', LEGACY_SCOPE); + const response = mapLookupEntitiesResponse(results, 'user'); expect(response.entities).toHaveLength(2); // Should decode to original IDs @@ -177,7 +174,7 @@ describe('lookup-response.mapper', () => { }); it('should handle empty results', () => { - const response = mapLookupEntitiesResponse([], 'user', LEGACY_SCOPE); + const response = mapLookupEntitiesResponse([], 'user'); expect(response.entities).toHaveLength(0); expect(response.totalReturned).toBe(0); @@ -197,7 +194,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupEntitiesResponse(results, 'user', LEGACY_SCOPE); + const response = mapLookupEntitiesResponse(results, 'user'); expect(response.entities[0].entityId).toBe(''); expect(response.entities[0].permissionship).toBeUndefined(); @@ -223,7 +220,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupEntitiesResponse(results, 'user', LEGACY_SCOPE); + const response = mapLookupEntitiesResponse(results, 'user'); // Should use the subject field value, not the deprecated top-level one expect(response.entities[0].permissionship).toBe('CONDITIONAL_PERMISSION'); @@ -249,7 +246,7 @@ describe('lookup-response.mapper', () => { } ]; - const response = mapLookupEntitiesResponse(results, 'cust_user', LEGACY_SCOPE); + const response = mapLookupEntitiesResponse(results, 'cust_user'); expect(response.entities[0].entityId).toBe(originalUserId); }); diff --git a/src/spicedb/spicedb-queries/lookup-response.mapper.ts b/src/spicedb/spicedb-queries/lookup-response.mapper.ts index cd5dafc..1f9fb81 100644 --- a/src/spicedb/spicedb-queries/lookup-response.mapper.ts +++ b/src/spicedb/spicedb-queries/lookup-response.mapper.ts @@ -10,16 +10,14 @@ import { } from '../../types'; import { permissionshipMap } from '../lookup.constants'; import { decodeObjectId } from './base64.utils'; -import { SchemaScope } from '../../instances/schema-scope'; export function mapLookupTargetEntitiesResponse( results: v1.LookupResourcesResponse[], TargetEntityType: string, - limit: number, - scope: SchemaScope + limit: number ): LookupTargetEntitiesResponse { const targets: TargetEntityItem[] = results.map((result) => ({ - TargetEntityType: scope.strip(TargetEntityType), + TargetEntityType, TargetEntityId: decodeObjectId(result.resourceObjectId), permissionship: permissionshipMap.get(result.permissionship) })); @@ -36,11 +34,10 @@ export function mapLookupTargetEntitiesResponse( export function mapLookupEntitiesResponse( results: v1.LookupSubjectsResponse[], - entityType: string, - scope: SchemaScope + entityType: string ): LookupEntitiesResponse { const entities: EntityItem[] = results.map((result) => ({ - entityType: scope.strip(entityType), + entityType, entityId: decodeObjectId(result.subject?.subjectObjectId ?? ''), permissionship: result.subject ? permissionshipMap.get(result.subject.permissionship) : undefined })); From 0be4a5bb05d566d5aba6ee0676320fad5e6e9a45 Mon Sep 17 00:00:00 2001 From: dianaKhortiuk-frontegg Date: Mon, 31 Aug 2026 15:08:42 +0300 Subject: [PATCH 4/6] fix(instances): reject colliding schema prefixes, and address review 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 --- src/entitlements-client-factory.ts | 2 +- .../instance-id-required.exception.ts | 7 +-- src/exceptions/unknown-instance.exception.ts | 8 +-- src/instances/instance-registry.spec.ts | 36 ++++++++++++++ src/instances/instance-registry.ts | 17 ++++++- src/instances/resolve-instance.spec.ts | 15 +++++- ...cedb-entitlements.client.instances.spec.ts | 24 +++++++++ src/spicedb/spicedb-entitlements.client.ts | 49 ++++++++++++++++--- 8 files changed, 135 insertions(+), 23 deletions(-) diff --git a/src/entitlements-client-factory.ts b/src/entitlements-client-factory.ts index d898b80..f0f72f7 100644 --- a/src/entitlements-client-factory.ts +++ b/src/entitlements-client-factory.ts @@ -14,7 +14,7 @@ export class EntitlementsClientFactory { throw new ConfigurationInputIsMissingException('engineToken is required'); } - const registry = new InstanceRegistry(configuration, configuration.defaultInstanceId); + const registry = new InstanceRegistry(configuration); const { loggingClient, logResults } = this.configureLoggingClient(configuration.logging); diff --git a/src/exceptions/instance-id-required.exception.ts b/src/exceptions/instance-id-required.exception.ts index 06ceb48..62c47a1 100644 --- a/src/exceptions/instance-id-required.exception.ts +++ b/src/exceptions/instance-id-required.exception.ts @@ -1,10 +1,7 @@ import { InstanceResolutionException } from './instance-resolution.exception'; export class InstanceIdRequiredException extends InstanceResolutionException { - constructor(configuredInstanceIds: string[]) { - super( - `instanceId is required when more than one instance is configured and no defaultInstanceId is set. ` + - `Configured instances: ${configuredInstanceIds.join(', ')}` - ); + constructor(public readonly configuredInstanceIds: string[]) { + super('instanceId is required when more than one instance is configured and no defaultInstanceId is set'); } } diff --git a/src/exceptions/unknown-instance.exception.ts b/src/exceptions/unknown-instance.exception.ts index 983e0c7..c53bc1b 100644 --- a/src/exceptions/unknown-instance.exception.ts +++ b/src/exceptions/unknown-instance.exception.ts @@ -3,12 +3,8 @@ import { InstanceResolutionException } from './instance-resolution.exception'; export class UnknownInstanceException extends InstanceResolutionException { constructor( public readonly instanceId: string, - configuredInstanceIds: string[] + public readonly configuredInstanceIds: string[] ) { - super( - `Unknown instanceId '${instanceId}'. Configured instances: ${ - configuredInstanceIds.length ? configuredInstanceIds.join(', ') : '' - }` - ); + super(`Unknown instanceId '${instanceId}'`); } } diff --git a/src/instances/instance-registry.spec.ts b/src/instances/instance-registry.spec.ts index d079736..6b357fa 100644 --- a/src/instances/instance-registry.spec.ts +++ b/src/instances/instance-registry.spec.ts @@ -92,6 +92,42 @@ describe(InstanceRegistry.name, () => { ).toThrow(ConfigurationInputIsInvalidException); }); + it('should throw when two explicit schemaPrefix overrides collide', () => { + expect( + () => + new InstanceRegistry({ + instances: [ + { instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: 'v_shared' }, + { instanceId: 'b', vendorId: VENDOR_B, schemaPrefix: 'v_shared' } + ] + }) + ).toThrow(ConfigurationInputIsInvalidException); + }); + + it('should throw when two distinct vendorIds normalise to the same prefix', () => { + expect( + () => + new InstanceRegistry({ + instances: [ + { instanceId: 'a', vendorId: 'ACME-CORP' }, + { instanceId: 'b', vendorId: 'acme_corp' } + ] + }) + ).toThrow(ConfigurationInputIsInvalidException); + }); + + it('should throw when two instances are both explicitly legacy', () => { + expect( + () => + new InstanceRegistry({ + instances: [ + { instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: '' }, + { instanceId: 'b', vendorId: VENDOR_B, schemaPrefix: '' } + ] + }) + ).toThrow(ConfigurationInputIsInvalidException); + }); + it('should throw when defaultInstanceId is not a configured instance', () => { expect(() => new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: VENDOR_A }] }, 'missing')).toThrow( ConfigurationInputIsInvalidException diff --git a/src/instances/instance-registry.ts b/src/instances/instance-registry.ts index b5c2f7c..a3fb33b 100644 --- a/src/instances/instance-registry.ts +++ b/src/instances/instance-registry.ts @@ -36,8 +36,9 @@ export class InstanceRegistry { this.order.push(legacy.instanceId); } else { const seenVendorIds = new Set(); + const seenSchemaPrefixes = new Set(); for (const instance of declared) { - this.assertInstance(instance, seenVendorIds); + this.assertInstance(instance, seenVendorIds, seenSchemaPrefixes); const resolved: ResolvedInstance = { instanceId: instance.instanceId, vendorId: instance.vendorId, @@ -80,7 +81,11 @@ export class InstanceRegistry { return deriveSchemaPrefix(instance.vendorId); } - private assertInstance(instance: InstanceConfiguration, seenVendorIds: Set): void { + private assertInstance( + instance: InstanceConfiguration, + seenVendorIds: Set, + seenSchemaPrefixes: Set + ): void { if (!instance.instanceId) { throw new ConfigurationInputIsInvalidException('instanceId is required for every configured instance'); } @@ -109,5 +114,13 @@ export class InstanceRegistry { `Expected an empty string or a SpiceDB identifier matching /^[a-z_][a-z0-9_]{1,62}[a-z0-9]$/` ); } + + if (seenSchemaPrefixes.has(prefix)) { + throw new ConfigurationInputIsInvalidException( + `Instance '${instance.instanceId}' resolves to schemaPrefix '${prefix}', which is already used by ` + + `another configured instance. Two instances sharing a prefix would share SpiceDB data.` + ); + } + seenSchemaPrefixes.add(prefix); } } diff --git a/src/instances/resolve-instance.spec.ts b/src/instances/resolve-instance.spec.ts index e22341e..ebb57bd 100644 --- a/src/instances/resolve-instance.spec.ts +++ b/src/instances/resolve-instance.spec.ts @@ -59,7 +59,18 @@ describe(resolveInstance.name, () => { expect(catchError(() => resolveInstance(pair()))).toBeInstanceOf(InstanceResolutionException); }); - it('should name the configured instances in the ambiguity error', () => { - expect(() => resolveInstance(pair())).toThrow(/a, b/); + it('should expose the configured instances on the error without putting them in the message', () => { + const error = catchError(() => resolveInstance(pair())) as InstanceIdRequiredException; + + expect(error.configuredInstanceIds).toEqual(['a', 'b']); + expect(error.message).not.toContain('a, b'); + }); + + it('should keep the unknown instanceId list off the message too', () => { + const error = catchError(() => resolveInstance(pair(), 'nope')) as UnknownInstanceException; + + expect(error.instanceId).toBe('nope'); + expect(error.configuredInstanceIds).toEqual(['a', 'b']); + expect(error.message).not.toContain('a, b'); }); }); diff --git a/src/spicedb/spicedb-entitlements.client.instances.spec.ts b/src/spicedb/spicedb-entitlements.client.instances.spec.ts index 67ecdd8..f8bfd86 100644 --- a/src/spicedb/spicedb-entitlements.client.instances.spec.ts +++ b/src/spicedb/spicedb-entitlements.client.instances.spec.ts @@ -240,6 +240,30 @@ describe('SpiceDBEntitlementsClient instance isolation', () => { await expect(client.readSchemaFor()).resolves.toBe(schemaText); }); + it('should isolate blocks that are indented or preceded by comments', async () => { + const awkward = [ + '// leading comment', + `definition ${PREFIX_A}/frontegg_feature {`, + '\trelation granted: frontegg_tenant', + '}', + '', + '// another comment', + `definition ${PREFIX_B}/frontegg_feature {`, + '\trelation granted: frontegg_tenant', + '}' + ].join('\n'); + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + (client as unknown as { spiceClient: { readSchema: jest.Mock } }).spiceClient = { + readSchema: jest.fn().mockResolvedValue({ schemaText: awkward }) + }; + + const schema = await client.readSchemaFor('a'); + + expect(schema).toContain(`definition ${PREFIX_A}/frontegg_feature`); + expect(schema).toContain('relation granted'); + expect(schema).not.toContain(PREFIX_B); + }); + it('should throw for an unknown instanceId', async () => { const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); withSchema(client); diff --git a/src/spicedb/spicedb-entitlements.client.ts b/src/spicedb/spicedb-entitlements.client.ts index 3a210e1..ae15615 100644 --- a/src/spicedb/spicedb-entitlements.client.ts +++ b/src/spicedb/spicedb-entitlements.client.ts @@ -40,7 +40,6 @@ import { decodeObjectId, encodeObjectId } from './spicedb-queries/base64.utils'; import { InstanceRegistry, ResolvedInstance } from '../instances/instance-registry'; import { resolveInstance } from '../instances/resolve-instance'; import { SchemaScope } from '../instances/schema-scope'; -import { InstanceResolutionException } from '../exceptions/instance-resolution.exception'; import { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception'; export interface InstanceOptions { @@ -65,7 +64,7 @@ export class SpiceDBEntitlementsClient { private readonly fallbackConfiguration: FallbackConfiguration = { defaultFallback: false }, registry?: InstanceRegistry ) { - this.registry = registry ?? new InstanceRegistry(configuration, configuration.defaultInstanceId); + this.registry = registry ?? new InstanceRegistry(configuration); try { this.spiceClient = v1.NewClient( @@ -311,12 +310,48 @@ export class SpiceDBEntitlementsClient { } const marker = `${scope.schemaPrefix}/`; - return schemaText - .split(/\n(?=definition |caveat )/) - .filter((block) => block.startsWith(`definition ${marker}`) || block.startsWith(`caveat ${marker}`)) + return this.splitSchemaBlocks(schemaText) + .filter((block) => { + const header = block[0].trim(); + return header.startsWith(`definition ${marker}`) || header.startsWith(`caveat ${marker}`); + }) + .map((block) => block.join('\n')) .join('\n'); } + private splitSchemaBlocks(schemaText: string): string[][] { + const blocks: string[][] = []; + let current: string[] | undefined; + let depth = 0; + + for (const line of schemaText.split('\n')) { + const trimmed = line.trim(); + if (depth === 0 && (trimmed.startsWith('definition ') || trimmed.startsWith('caveat '))) { + current = [line]; + blocks.push(current); + } else if (current) { + current.push(line); + } + + depth += this.countChar(line, '{') - this.countChar(line, '}'); + if (depth === 0) { + current = undefined; + } + } + + return blocks; + } + + private countChar(value: string, char: string): number { + let count = 0; + for (const candidate of value) { + if (candidate === char) { + count++; + } + } + return count; + } + private async executeEntitlementQuery( subjectContext: SubjectContext, requestContext: RequestContext, @@ -355,7 +390,7 @@ export class SpiceDBEntitlementsClient { } return res.result; } catch (err) { - if (err instanceof InstanceResolutionException || err instanceof ConfigurationInputIsInvalidException) { + if (err instanceof ConfigurationInputIsInvalidException) { throw err; } await this.loggingClient.error(err); @@ -522,7 +557,7 @@ export class SpiceDBEntitlementsClient { result: res.result[requestContext.featureKey] ?? { result: false } })); } catch (err) { - if (err instanceof InstanceResolutionException || err instanceof ConfigurationInputIsInvalidException) { + if (err instanceof ConfigurationInputIsInvalidException) { throw err; } await this.loggingClient.error(err); From da8ed31dafac53bbf3a89043f822dcc08a92d5f9 Mon Sep 17 00:00:00 2001 From: dianaKhortiuk-frontegg Date: Mon, 31 Aug 2026 15:43:49 +0300 Subject: [PATCH 5/6] refactor(instances): replace the regex scope guard with a typed lint rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 | 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. --- eslint-rules/require-scoped-object-type.js | 90 ++++++++++++++++++++ eslint.config.js | 16 ++++ src/instances/schema-scope.guard.spec.ts | 76 ----------------- src/instances/schema-scope.lint-rule.spec.ts | 33 +++++++ 4 files changed, 139 insertions(+), 76 deletions(-) create mode 100644 eslint-rules/require-scoped-object-type.js delete mode 100644 src/instances/schema-scope.guard.spec.ts create mode 100644 src/instances/schema-scope.lint-rule.spec.ts diff --git a/eslint-rules/require-scoped-object-type.js b/eslint-rules/require-scoped-object-type.js new file mode 100644 index 0000000..31847f2 --- /dev/null +++ b/eslint-rules/require-scoped-object-type.js @@ -0,0 +1,90 @@ +const OBJECT_TYPE_FIELDS = new Set(['objectType', 'resourceObjectType', 'subjectObjectType', 'resourceType']); + +const SPICEDB_TYPE_SOURCE = '@authzed/authzed-node'; + +function isScopeTypeCall(node) { + return ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'type' + ); +} + +function relatedTypes(type, depth = 0, seen = []) { + if (!type || depth > 4 || seen.includes(type)) { + return seen; + } + seen.push(type); + + for (const group of [type.types, type.aliasTypeArguments, type.typeArguments]) { + if (Array.isArray(group)) { + for (const member of group) { + relatedTypes(member, depth + 1, seen); + } + } + } + + return seen; +} + +function declaredBySpiceDB(checker, tsNode) { + const type = checker.getContextualType(tsNode); + if (!type) { + return false; + } + + return relatedTypes(type).some((candidate) => { + const symbol = candidate.aliasSymbol ?? candidate.symbol; + const declarations = symbol?.getDeclarations?.() ?? []; + return declarations.some((declaration) => + declaration.getSourceFile().fileName.includes(SPICEDB_TYPE_SOURCE) + ); + }); +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'SpiceDB request object types must be built through SchemaScope.type() so every read is scoped to one instance' + }, + schema: [], + messages: { + unscoped: + "'{{field}}' is a SpiceDB request field and must be built with scope.type(...). " + + 'An unscoped object type reads across every configured instance.' + } + }, + + create(context) { + const services = context.sourceCode.parserServices; + if (!services?.program || !services.esTreeNodeToTSNodeMap) { + return {}; + } + const checker = services.program.getTypeChecker(); + + return { + Property(node) { + if (node.computed || node.key.type !== 'Identifier' || !OBJECT_TYPE_FIELDS.has(node.key.name)) { + return; + } + if (node.parent?.type !== 'ObjectExpression') { + return; + } + if (isScopeTypeCall(node.value)) { + return; + } + + const tsNode = services.esTreeNodeToTSNodeMap.get(node.parent); + if (!tsNode || !declaredBySpiceDB(checker, tsNode)) { + return; + } + + context.report({ node, messageId: 'unscoped', data: { field: node.key.name } }); + } + }; + } +}; diff --git a/eslint.config.js b/eslint.config.js index 8e115be..24e8538 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,7 @@ const parser = require('@typescript-eslint/parser'); const eslintPlugin = require('@typescript-eslint/eslint-plugin'); const tseslint = require('typescript-eslint'); const eslint = require('@eslint/js'); +const requireScopedObjectType = require('./eslint-rules/require-scoped-object-type'); module.exports = tseslint.config( eslint.configs.recommended, @@ -41,4 +42,19 @@ module.exports = tseslint.config( '@typescript-eslint/no-unused-vars': ['warn', {args: 'none'}], }, }, + { + files: ['src/**/*.ts'], + ignores: ['**/*.spec.ts', '**/*spec-helper.ts'], + languageOptions: { + parser, + parserOptions: { + sourceType: 'module', + project: './tsconfig.json', + }, + }, + plugins: {'frontegg': {rules: {'require-scoped-object-type': requireScopedObjectType}}}, + rules: { + 'frontegg/require-scoped-object-type': 'error', + }, + }, ); diff --git a/src/instances/schema-scope.guard.spec.ts b/src/instances/schema-scope.guard.spec.ts deleted file mode 100644 index 032c230..0000000 --- a/src/instances/schema-scope.guard.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFileSync, readdirSync, statSync } from 'fs'; -import { join } from 'path'; - -const SRC_ROOT = join(__dirname, '..'); - -const OBJECT_TYPE_FIELD = /\b(objectType|resourceObjectType|subjectObjectType|resourceType):\s*([^\n,]+)/g; - -const REQUEST_SPAN_START = - /v1\.[A-Za-z]+\.create\(|this\.(?:client|spiceClient)\.[A-Za-z]+\(|createBulkPermissionRequestItem\([\s\S]*?\)\s*:\s*v1\.CheckBulkPermissionsRequestItem\s*\{/g; - -function collectSources(dir: string, acc: string[] = []): string[] { - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) { - collectSources(full, acc); - } else if (entry.endsWith('.ts') && !entry.includes('.spec')) { - acc.push(full); - } - } - return acc; -} - -function requestSpans(source: string): [number, number][] { - const spans: [number, number][] = []; - REQUEST_SPAN_START.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = REQUEST_SPAN_START.exec(source)) !== null) { - let depth = 1; - let index = match.index + match[0].length; - while (index < source.length && depth > 0) { - const char = source[index]; - if (char === '(' || char === '{') depth++; - else if (char === ')' || char === '}') depth--; - index++; - } - spans.push([match.index, index]); - } - return spans; -} - -describe('schema scope guard', () => { - const sources = collectSources(SRC_ROOT); - - it('should find the sources it is guarding', () => { - expect(sources.length).toBeGreaterThan(10); - }); - - it('should build every SpiceDB request object type through scope.type()', () => { - const offenders: string[] = []; - - for (const file of sources) { - const relative = file.slice(SRC_ROOT.length + 1); - const source = readFileSync(file, 'utf8'); - const spans = requestSpans(source); - - OBJECT_TYPE_FIELD.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = OBJECT_TYPE_FIELD.exec(source)) !== null) { - const position = match.index; - if (!spans.some(([start, end]) => position >= start && position < end)) { - continue; - } - - const value = match[2].trim(); - if (value.startsWith('scope.type(') || value === 'string') { - continue; - } - - const line = source.slice(0, position).split('\n').length; - offenders.push(`${relative}:${line} ${match[0].trim()}`); - } - } - - expect(offenders).toEqual([]); - }); -}); diff --git a/src/instances/schema-scope.lint-rule.spec.ts b/src/instances/schema-scope.lint-rule.spec.ts new file mode 100644 index 0000000..416e71a --- /dev/null +++ b/src/instances/schema-scope.lint-rule.spec.ts @@ -0,0 +1,33 @@ +import eslintConfig = require('../../eslint.config.js'); + +const RULE = 'frontegg/require-scoped-object-type'; + +interface FlatConfigEntry { + files?: string[]; + ignores?: string[]; + rules?: Record; + plugins?: Record }>; + languageOptions?: { parserOptions?: { project?: string } }; +} + +describe('scope lint rule wiring', () => { + const config = eslintConfig as unknown as FlatConfigEntry[]; + const entry = config.find((candidate) => candidate.rules?.[RULE] !== undefined); + + it('should enable the scoping rule as an error', () => { + expect(entry).toBeDefined(); + expect(entry?.rules?.[RULE]).toBe('error'); + }); + + it('should register the rule implementation behind the frontegg plugin', () => { + expect(entry?.plugins?.frontegg?.rules?.['require-scoped-object-type']).toBeDefined(); + }); + + it('should apply the rule across the source tree', () => { + expect(entry?.files).toContain('src/**/*.ts'); + }); + + it('should give the rule type information, without which it silently matches nothing', () => { + expect(entry?.languageOptions?.parserOptions?.project).toBeDefined(); + }); +}); From 855d684e82c99db59b6953036ed98fab7352200a Mon Sep 17 00:00:00 2001 From: dianaKhortiuk-frontegg Date: Mon, 31 Aug 2026 19:09:06 +0300 Subject: [PATCH 6/6] refactor(instances): take instanceId as an options argument on every 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. --- ...cedb-entitlements.client.instances.spec.ts | 56 +++++++++++++++++++ src/spicedb/spicedb-entitlements.client.ts | 17 ++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/spicedb/spicedb-entitlements.client.instances.spec.ts b/src/spicedb/spicedb-entitlements.client.instances.spec.ts index f8bfd86..e68824c 100644 --- a/src/spicedb/spicedb-entitlements.client.instances.spec.ts +++ b/src/spicedb/spicedb-entitlements.client.instances.spec.ts @@ -197,6 +197,62 @@ describe('SpiceDBEntitlementsClient instance isolation', () => { }); }); + describe('lookup instance routing', () => { + function withLookups(client: SpiceDBEntitlementsClient): { + lookupResources: jest.Mock; + lookupSubjects: jest.Mock; + } { + const spice = { + lookupResources: jest.fn().mockResolvedValue([]), + lookupSubjects: jest.fn().mockResolvedValue([]) + }; + (client as unknown as { spiceClient: unknown }).spiceClient = spice; + return spice; + } + + it('should scope lookupTargetEntities by the options instanceId', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + const spice = withLookups(client); + + await client.lookupTargetEntities( + { entityType: 'cust_user', entityId: 'u1', TargetEntityType: 'cust_document', action: 'access' }, + { instanceId: 'b' } + ); + + expect(spice.lookupResources.mock.calls[0][0].resourceObjectType).toBe(`${PREFIX_B}/cust_document`); + }); + + it('should scope lookupEntities by the options instanceId', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + const spice = withLookups(client); + + await client.lookupEntities( + { + TargetEntityType: 'cust_document', + TargetEntityId: 'd1', + entityType: 'cust_user', + action: 'access' + }, + { instanceId: 'a' } + ); + + expect(spice.lookupSubjects.mock.calls[0][0].subjectObjectType).toBe(`${PREFIX_A}/cust_user`); + }); + + it('should throw and make no call when the lookup instanceId is unknown', async () => { + const client = buildClient({ instances: TWO_INSTANCES }, queryClient, loggingClient); + const spice = withLookups(client); + + await expect( + client.lookupTargetEntities( + { entityType: 'cust_user', entityId: 'u1', TargetEntityType: 'cust_document', action: 'access' }, + { instanceId: 'nope' } + ) + ).rejects.toBeInstanceOf(UnknownInstanceException); + expect(spice.lookupResources).not.toHaveBeenCalled(); + }); + }); + describe('readSchemaFor', () => { const schemaText = [ `definition ${PREFIX_A}/frontegg_feature {}`, diff --git a/src/spicedb/spicedb-entitlements.client.ts b/src/spicedb/spicedb-entitlements.client.ts index ae15615..bf5e394 100644 --- a/src/spicedb/spicedb-entitlements.client.ts +++ b/src/spicedb/spicedb-entitlements.client.ts @@ -166,9 +166,10 @@ export class SpiceDBEntitlementsClient { } public async lookupTargetEntities( - req: LookupTargetEntitiesRequest & InstanceOptions + req: LookupTargetEntitiesRequest, + options?: InstanceOptions ): Promise { - const { scope } = this.resolve(req.instanceId); + const { scope } = this.resolve(options?.instanceId); try { const limit = req.limit ? req.limit : DEFAULT_LOOKUP_LIMIT; const request = buildLookupTargetEntitiesRequest( @@ -196,8 +197,11 @@ export class SpiceDBEntitlementsClient { } } - public async lookupEntities(req: LookupEntitiesRequest & InstanceOptions): Promise { - const { scope } = this.resolve(req.instanceId); + public async lookupEntities( + req: LookupEntitiesRequest, + options?: InstanceOptions + ): Promise { + const { scope } = this.resolve(options?.instanceId); try { const request = buildLookupEntitiesRequest( { @@ -223,9 +227,10 @@ export class SpiceDBEntitlementsClient { } public async lookupEntitlements( - req: LookupEntitlementsRequest & InstanceOptions + req: LookupEntitlementsRequest, + options?: InstanceOptions ): Promise { - const { scope } = this.resolve(req.instanceId); + const { scope } = this.resolve(options?.instanceId); try { if (!(await this.isLookupEntitlementsTenantMember(req, scope))) { return {