Skip to content
Closed
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-namespaced-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 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 } });
}
};
}
};
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 requireNamespacedObjectType = require('./eslint-rules/require-namespaced-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-namespaced-object-type': requireNamespacedObjectType}}},
rules: {
'frontegg/require-namespaced-object-type': 'error',
},
},
);
76 changes: 0 additions & 76 deletions src/instances/schema-namespace.guard.spec.ts

This file was deleted.

33 changes: 33 additions & 0 deletions src/instances/schema-namespace.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-namespaced-object-type';

interface FlatConfigEntry {
files?: string[];
ignores?: string[];
rules?: Record<string, unknown>;
plugins?: Record<string, { rules?: Record<string, unknown> }>;
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();
});
});
3 changes: 1 addition & 2 deletions src/spicedb/spicedb-entitlements.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down