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/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..f0f72f7 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); + 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..62c47a1 --- /dev/null +++ b/src/exceptions/instance-id-required.exception.ts @@ -0,0 +1,7 @@ +import { InstanceResolutionException } from './instance-resolution.exception'; + +export class InstanceIdRequiredException extends InstanceResolutionException { + 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/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..c53bc1b --- /dev/null +++ b/src/exceptions/unknown-instance.exception.ts @@ -0,0 +1,10 @@ +import { InstanceResolutionException } from './instance-resolution.exception'; + +export class UnknownInstanceException extends InstanceResolutionException { + constructor( + public readonly instanceId: string, + public readonly configuredInstanceIds: string[] + ) { + super(`Unknown instanceId '${instanceId}'`); + } +} 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..6b357fa --- /dev/null +++ b/src/instances/instance-registry.spec.ts @@ -0,0 +1,164 @@ +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 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 + ); + }); + + 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'); + + 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..a3fb33b --- /dev/null +++ b/src/instances/instance-registry.ts @@ -0,0 +1,126 @@ +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[]; + + public readonly defaultInstanceId?: string; + + constructor( + configuration: Pick, + defaultInstanceId: string | undefined = configuration.defaultInstanceId + ) { + this.defaultInstanceId = defaultInstanceId; + 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(); + const seenSchemaPrefixes = new Set(); + for (const instance of declared) { + this.assertInstance(instance, seenVendorIds, seenSchemaPrefixes); + 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, + seenSchemaPrefixes: 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]$/` + ); + } + + 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 new file mode 100644 index 0000000..ebb57bd --- /dev/null +++ b/src/instances/resolve-instance.spec.ts @@ -0,0 +1,76 @@ +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 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/instances/resolve-instance.ts b/src/instances/resolve-instance.ts new file mode 100644 index 0000000..025680d --- /dev/null +++ b/src/instances/resolve-instance.ts @@ -0,0 +1,39 @@ +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 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; + } + + 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.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(); + }); +}); 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..e68824c --- /dev/null +++ b/src/spicedb/spicedb-entitlements.client.instances.spec.ts @@ -0,0 +1,354 @@ +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'; +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'; +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 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( + { 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('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 {}`, + `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 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); + + 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')); + 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..d981c8f 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 }, @@ -264,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 }] ); }); @@ -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..bf5e394 100644 --- a/src/spicedb/spicedb-entitlements.client.ts +++ b/src/spicedb/spicedb-entitlements.client.ts @@ -37,18 +37,35 @@ 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 { ConfigurationInputIsInvalidException } from '../exceptions/configuration-input-is-invalid.exception'; + +export interface InstanceOptions { + instanceId?: string; +} 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; + 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); + try { this.spiceClient = v1.NewClient( this.configuration.engineToken, @@ -70,18 +87,27 @@ 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 }, + { + action: 'SpiceDB:isEntitledToMany:request', + instanceId: instance.instanceId, + subjectContext, + requestContexts + }, null ); } @@ -102,7 +128,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 +137,7 @@ export class SpiceDBEntitlementsClient { return { index, - result: await this.executeEntitlementQuery(subjectContext, requestContext) + result: await this.executeEntitlementQuery(subjectContext, requestContext, instance) }; }) ) @@ -126,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 ); } @@ -134,18 +165,25 @@ export class SpiceDBEntitlementsClient { return results; } - public async lookupTargetEntities(req: LookupTargetEntitiesRequest): Promise { + public async lookupTargetEntities( + req: LookupTargetEntitiesRequest, + options?: InstanceOptions + ): Promise { + const { scope } = this.resolve(options?.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); @@ -159,15 +197,22 @@ export class SpiceDBEntitlementsClient { } } - public async lookupEntities(req: LookupEntitiesRequest): Promise { + public async lookupEntities( + req: LookupEntitiesRequest, + options?: InstanceOptions + ): Promise { + const { scope } = this.resolve(options?.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); @@ -181,9 +226,13 @@ export class SpiceDBEntitlementsClient { } } - public async lookupEntitlements(req: LookupEntitlementsRequest): Promise { + public async lookupEntitlements( + req: LookupEntitlementsRequest, + options?: InstanceOptions + ): Promise { + const { scope } = this.resolve(options?.instanceId); try { - if (!(await this.isLookupEntitlementsTenantMember(req))) { + if (!(await this.isLookupEntitlementsTenantMember(req, scope))) { return { entitlements: [], totalReturned: 0, @@ -199,7 +248,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 +276,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,25 +302,86 @@ 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 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, + instance: ResolvedInstance, logAction?: string ): Promise { const logPerItem = this.logResults && logAction != null; try { if (logPerItem) { await this.loggingClient.logRequest( - { action: `${logAction}:request`, subjectContext, requestContext }, + { action: `${logAction}:request`, instanceId: instance.instanceId, subjectContext, requestContext }, null ); } - const res = await this.spiceDBQueryClient.spiceDBQuery(subjectContext, requestContext); + const res = await this.spiceDBQueryClient.spiceDBQuery(subjectContext, requestContext, instance.scope); if (logPerItem) { await this.loggingClient.logRequest( - { action: `${logAction}:response`, subjectContext, requestContext }, + { + action: `${logAction}:response`, + instanceId: instance.instanceId, + subjectContext, + requestContext + }, res ); } @@ -282,8 +395,11 @@ export class SpiceDBEntitlementsClient { } return res.result; } catch (err) { + if (err instanceof ConfigurationInputIsInvalidException) { + throw err; + } await this.loggingClient.error(err); - return this.constructFallbackResult(requestContext); + return this.constructFallbackResult(requestContext, instance); } } @@ -424,7 +540,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 +554,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 ConfigurationInputIsInvalidException) { + 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/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 ); } }