Skip to content
90 changes: 90 additions & 0 deletions eslint-rules/require-scoped-object-type.js
Original file line number Diff line number Diff line change
@@ -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 } });
}
};
}
};
16 changes: 16 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
},
},
);
9 changes: 9 additions & 0 deletions src/client-configuration.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
6 changes: 5 additions & 1 deletion src/entitlements-client-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -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';
7 changes: 7 additions & 0 deletions src/exceptions/instance-id-required.exception.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
6 changes: 6 additions & 0 deletions src/exceptions/instance-resolution.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export abstract class InstanceResolutionException extends Error {
protected constructor(message: string) {
super(message);
this.name = new.target.name;
}
}
10 changes: 10 additions & 0 deletions src/exceptions/unknown-instance.exception.ts
Original file line number Diff line number Diff line change
@@ -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}'`);
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 4 additions & 0 deletions src/instances/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './schema-scope';
export * from './schema-prefix';
export * from './instance-registry';
export * from './resolve-instance';
164 changes: 164 additions & 0 deletions src/instances/instance-registry.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading