diff --git a/eslint-rules/require-namespaced-object-type.js b/eslint-rules/require-namespaced-object-type.js new file mode 100644 index 0000000..a09b6dc --- /dev/null +++ b/eslint-rules/require-namespaced-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 isNamespaceTypeCall(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 SchemaNamespace.type() so every read is namespaced to one instance' + }, + schema: [], + messages: { + unnamespaced: + "'{{field}}' is a SpiceDB request field and must be built with namespace.type(...). " + + 'An un-namespaced 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 (isNamespaceTypeCall(node.value)) { + return; + } + + const tsNode = services.esTreeNodeToTSNodeMap.get(node.parent); + if (!tsNode || !declaredBySpiceDB(checker, tsNode)) { + return; + } + + context.report({ node, messageId: 'unnamespaced', data: { field: node.key.name } }); + } + }; + } +}; diff --git a/eslint.config.js b/eslint.config.js index 8e115be..b164744 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 requireNamespacedObjectType = require('./eslint-rules/require-namespaced-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-namespaced-object-type': requireNamespacedObjectType}}}, + rules: { + 'frontegg/require-namespaced-object-type': 'error', + }, + }, ); diff --git a/src/instances/schema-namespace.guard.spec.ts b/src/instances/schema-namespace.guard.spec.ts deleted file mode 100644 index 74b2a3f..0000000 --- a/src/instances/schema-namespace.guard.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFileSync, readdirSync, statSync } from 'fs'; -import { join } from 'path'; - -const SRC_ROOT = join(__dirname, '..'); - -const OBJECT_TYPE_FIELD = /\b(objectType|resourceObjectType|subjectObjectType|resourceType):\s*([^\n,]+)/g; - -const REQUEST_SPAN_START = - /v1\.[A-Za-z]+\.create\(|this\.(?:client|spiceClient)\.[A-Za-z]+\(|createBulkPermissionRequestItem\([\s\S]*?\)\s*:\s*v1\.CheckBulkPermissionsRequestItem\s*\{/g; - -function collectSources(dir: string, acc: string[] = []): string[] { - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) { - collectSources(full, acc); - } else if (entry.endsWith('.ts') && !entry.includes('.spec')) { - acc.push(full); - } - } - return acc; -} - -function requestSpans(source: string): [number, number][] { - const spans: [number, number][] = []; - REQUEST_SPAN_START.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = REQUEST_SPAN_START.exec(source)) !== null) { - let depth = 1; - let index = match.index + match[0].length; - while (index < source.length && depth > 0) { - const char = source[index]; - if (char === '(' || char === '{') depth++; - else if (char === ')' || char === '}') depth--; - index++; - } - spans.push([match.index, index]); - } - return spans; -} - -describe('schema namespace guard', () => { - const sources = collectSources(SRC_ROOT); - - it('should find the sources it is guarding', () => { - expect(sources.length).toBeGreaterThan(10); - }); - - it('should build every SpiceDB request object type through namespace.type()', () => { - const offenders: string[] = []; - - for (const file of sources) { - const relative = file.slice(SRC_ROOT.length + 1); - const source = readFileSync(file, 'utf8'); - const spans = requestSpans(source); - - OBJECT_TYPE_FIELD.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = OBJECT_TYPE_FIELD.exec(source)) !== null) { - const position = match.index; - if (!spans.some(([start, end]) => position >= start && position < end)) { - continue; - } - - const value = match[2].trim(); - if (value.startsWith('namespace.type(') || value === 'string') { - continue; - } - - const line = source.slice(0, position).split('\n').length; - offenders.push(`${relative}:${line} ${match[0].trim()}`); - } - } - - expect(offenders).toEqual([]); - }); -}); diff --git a/src/instances/schema-namespace.lint-rule.spec.ts b/src/instances/schema-namespace.lint-rule.spec.ts new file mode 100644 index 0000000..a90e5c2 --- /dev/null +++ b/src/instances/schema-namespace.lint-rule.spec.ts @@ -0,0 +1,33 @@ +import eslintConfig = require('../../eslint.config.js'); + +const RULE = 'frontegg/require-namespaced-object-type'; + +interface FlatConfigEntry { + files?: string[]; + ignores?: string[]; + rules?: Record; + plugins?: Record }>; + languageOptions?: { parserOptions?: { project?: string } }; +} + +describe('namespace lint rule wiring', () => { + const config = eslintConfig as unknown as FlatConfigEntry[]; + const entry = config.find((candidate) => candidate.rules?.[RULE] !== undefined); + + it('should enable the namespacing 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-namespaced-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/spicedb/spicedb-entitlements.client.ts b/src/spicedb/spicedb-entitlements.client.ts index 33da178..7dceda4 100644 --- a/src/spicedb/spicedb-entitlements.client.ts +++ b/src/spicedb/spicedb-entitlements.client.ts @@ -49,8 +49,7 @@ export interface InstanceOptions { export class SpiceDBEntitlementsClient { private static readonly MONITORING_RESULT: EntitlementsResult = { monitoring: true, result: true }; - /** @deprecated Bypasses instance namespacing and can read another instance's data; use isEntitledTo, the lookup methods, or readSchemaFor. */ - public readonly spiceClient: v1.ZedPromiseClientInterface; + private readonly spiceClient: v1.ZedPromiseClientInterface; private readonly spiceDBQueryClient: SpiceDBQueryClient; private readonly registry: InstanceRegistry;