Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
},
},
);
2 changes: 1 addition & 1 deletion src/entitlements-client-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class EntitlementsClientFactory {
throw new ConfigurationInputIsMissingException('engineToken is required');
}

const registry = new InstanceRegistry(configuration, configuration.defaultInstanceId);
const registry = new InstanceRegistry(configuration);

const { loggingClient, logResults } = this.configureLoggingClient(configuration.logging);

Expand Down
7 changes: 2 additions & 5 deletions src/exceptions/instance-id-required.exception.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { InstanceResolutionException } from './instance-resolution.exception';

export class InstanceIdRequiredException extends InstanceResolutionException {
constructor(configuredInstanceIds: string[]) {
super(
`instanceId is required when more than one instance is configured and no defaultInstanceId is set. ` +
`Configured instances: ${configuredInstanceIds.join(', ')}`
);
constructor(public readonly configuredInstanceIds: string[]) {
super('instanceId is required when more than one instance is configured and no defaultInstanceId is set');
}
}
8 changes: 2 additions & 6 deletions src/exceptions/unknown-instance.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@ import { InstanceResolutionException } from './instance-resolution.exception';
export class UnknownInstanceException extends InstanceResolutionException {
constructor(
public readonly instanceId: string,
configuredInstanceIds: string[]
public readonly configuredInstanceIds: string[]
) {
super(
`Unknown instanceId '${instanceId}'. Configured instances: ${
configuredInstanceIds.length ? configuredInstanceIds.join(', ') : '<none>'
}`
);
super(`Unknown instanceId '${instanceId}'`);
}
}
36 changes: 36 additions & 0 deletions src/instances/instance-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,42 @@ describe(InstanceRegistry.name, () => {
).toThrow(ConfigurationInputIsInvalidException);
});

it('should throw when two explicit schemaPrefix overrides collide', () => {
expect(
() =>
new InstanceRegistry({
instances: [
{ instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: 'v_shared' },
{ instanceId: 'b', vendorId: VENDOR_B, schemaPrefix: 'v_shared' }
]
})
).toThrow(ConfigurationInputIsInvalidException);
});

it('should throw when two distinct vendorIds normalise to the same prefix', () => {
expect(
() =>
new InstanceRegistry({
instances: [
{ instanceId: 'a', vendorId: 'ACME-CORP' },
{ instanceId: 'b', vendorId: 'acme_corp' }
]
})
).toThrow(ConfigurationInputIsInvalidException);
});

it('should throw when two instances are both explicitly legacy', () => {
expect(
() =>
new InstanceRegistry({
instances: [
{ instanceId: 'a', vendorId: VENDOR_A, schemaPrefix: '' },
{ instanceId: 'b', vendorId: VENDOR_B, schemaPrefix: '' }
]
})
).toThrow(ConfigurationInputIsInvalidException);
});

it('should throw when defaultInstanceId is not a configured instance', () => {
expect(() => new InstanceRegistry({ instances: [{ instanceId: 'a', vendorId: VENDOR_A }] }, 'missing')).toThrow(
ConfigurationInputIsInvalidException
Expand Down
17 changes: 15 additions & 2 deletions src/instances/instance-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ export class InstanceRegistry {
this.order.push(legacy.instanceId);
} else {
const seenVendorIds = new Set<string>();
const seenSchemaPrefixes = new Set<string>();
for (const instance of declared) {
this.assertInstance(instance, seenVendorIds);
this.assertInstance(instance, seenVendorIds, seenSchemaPrefixes);
const resolved: ResolvedInstance = {
instanceId: instance.instanceId,
vendorId: instance.vendorId,
Expand Down Expand Up @@ -80,7 +81,11 @@ export class InstanceRegistry {
return deriveSchemaPrefix(instance.vendorId);
}

private assertInstance(instance: InstanceConfiguration, seenVendorIds: Set<string>): void {
private assertInstance(
instance: InstanceConfiguration,
seenVendorIds: Set<string>,
seenSchemaPrefixes: Set<string>
): void {
if (!instance.instanceId) {
throw new ConfigurationInputIsInvalidException('instanceId is required for every configured instance');
}
Expand Down Expand Up @@ -109,5 +114,13 @@ export class InstanceRegistry {
`Expected an empty string or a SpiceDB identifier matching /^[a-z_][a-z0-9_]{1,62}[a-z0-9]$/`
);
}

if (seenSchemaPrefixes.has(prefix)) {
throw new ConfigurationInputIsInvalidException(
`Instance '${instance.instanceId}' resolves to schemaPrefix '${prefix}', which is already used by ` +
`another configured instance. Two instances sharing a prefix would share SpiceDB data.`
);
}
seenSchemaPrefixes.add(prefix);
}
}
15 changes: 13 additions & 2 deletions src/instances/resolve-instance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,18 @@ describe(resolveInstance.name, () => {
expect(catchError(() => resolveInstance(pair()))).toBeInstanceOf(InstanceResolutionException);
});

it('should name the configured instances in the ambiguity error', () => {
expect(() => resolveInstance(pair())).toThrow(/a, b/);
it('should expose the configured instances on the error without putting them in the message', () => {
const error = catchError(() => resolveInstance(pair())) as InstanceIdRequiredException;

expect(error.configuredInstanceIds).toEqual(['a', 'b']);
expect(error.message).not.toContain('a, b');
});

it('should keep the unknown instanceId list off the message too', () => {
const error = catchError(() => resolveInstance(pair(), 'nope')) as UnknownInstanceException;

expect(error.instanceId).toBe('nope');
expect(error.configuredInstanceIds).toEqual(['a', 'b']);
expect(error.message).not.toContain('a, b');
});
});
76 changes: 0 additions & 76 deletions src/instances/schema-scope.guard.spec.ts

This file was deleted.

33 changes: 33 additions & 0 deletions src/instances/schema-scope.lint-rule.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
plugins?: Record<string, { rules?: Record<string, unknown> }>;
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();
});
});
Loading
Loading