Fix workspace hydratation (#12452)

We must separate the concept of hydratation which happens at the request
level (take the token and pass auth/user context), from the concept of
authorization which happens at the query/endpoint/mutation level.

Previously, hydratation exemption happened at the operation name level
which is not correct because the operation name is meaningless and
optional. Still this gave an impression of security by enforcing a
blacklist. So in this PR we introduce linting rule that aim to achieve a
similar behavior, now every api method has to have a guard. That way if
and endpoint is not protected by AuthUserGuard or AuthWorspaceGuard,
then it has to be stated explicitly next to its code.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait
2025-06-09 14:14:32 +02:00
committed by GitHub
parent 322c8a1852
commit ecf21774dd
37 changed files with 741 additions and 138 deletions

View File

@ -1,54 +1,62 @@
import {
RULE_NAME as injectWorkspaceRepositoryName,
rule as injectWorkspaceRepository,
} from './rules/inject-workspace-repository';
import {
rule as componentPropsNaming,
RULE_NAME as componentPropsNamingName,
rule as componentPropsNaming,
RULE_NAME as componentPropsNamingName,
} from './rules/component-props-naming';
import {
rule as effectComponents,
RULE_NAME as effectComponentsName,
rule as effectComponents,
RULE_NAME as effectComponentsName,
} from './rules/effect-components';
import {
rule as explicitBooleanPredicatesInIf,
RULE_NAME as explicitBooleanPredicatesInIfName,
rule as explicitBooleanPredicatesInIf,
RULE_NAME as explicitBooleanPredicatesInIfName,
} from './rules/explicit-boolean-predicates-in-if';
import {
rule as matchingStateVariable,
RULE_NAME as matchingStateVariableName,
rule as graphqlResolversShouldBeGuarded,
RULE_NAME as graphqlResolversShouldBeGuardedName,
} from './rules/graphql-resolvers-should-be-guarded';
import {
rule as injectWorkspaceRepository,
RULE_NAME as injectWorkspaceRepositoryName,
} from './rules/inject-workspace-repository';
import {
rule as matchingStateVariable,
RULE_NAME as matchingStateVariableName,
} from './rules/matching-state-variable';
import {
rule as maxConstsPerFile,
RULE_NAME as maxConstsPerFileName,
rule as maxConstsPerFile,
RULE_NAME as maxConstsPerFileName,
} from './rules/max-consts-per-file';
import {
rule as noHardcodedColors,
RULE_NAME as noHardcodedColorsName,
rule as noHardcodedColors,
RULE_NAME as noHardcodedColorsName,
} from './rules/no-hardcoded-colors';
import {
rule as noNavigatePreferLink,
RULE_NAME as noNavigatePreferLinkName,
rule as noNavigatePreferLink,
RULE_NAME as noNavigatePreferLinkName,
} from './rules/no-navigate-prefer-link';
import {
rule as noStateUseref,
RULE_NAME as noStateUserefName,
rule as noStateUseref,
RULE_NAME as noStateUserefName,
} from './rules/no-state-useref';
import {
rule as sortCssPropertiesAlphabetically,
RULE_NAME as sortCssPropertiesAlphabeticallyName,
rule as restApiMethodsShouldBeGuarded,
RULE_NAME as restApiMethodsShouldBeGuardedName,
} from './rules/rest-api-methods-should-be-guarded';
import {
rule as sortCssPropertiesAlphabetically,
RULE_NAME as sortCssPropertiesAlphabeticallyName,
} from './rules/sort-css-properties-alphabetically';
import {
rule as styledComponentsPrefixedWithStyled,
RULE_NAME as styledComponentsPrefixedWithStyledName,
rule as styledComponentsPrefixedWithStyled,
RULE_NAME as styledComponentsPrefixedWithStyledName,
} from './rules/styled-components-prefixed-with-styled';
import {
rule as useGetLoadableAndGetValueToGetAtoms,
RULE_NAME as useGetLoadableAndGetValueToGetAtomsName,
rule as useGetLoadableAndGetValueToGetAtoms,
RULE_NAME as useGetLoadableAndGetValueToGetAtomsName,
} from './rules/use-getLoadable-and-getValue-to-get-atoms';
import {
rule as useRecoilCallbackHasDependencyArray,
RULE_NAME as useRecoilCallbackHasDependencyArrayName,
rule as useRecoilCallbackHasDependencyArray,
RULE_NAME as useRecoilCallbackHasDependencyArrayName,
} from './rules/useRecoilCallback-has-dependency-array';
/**
@ -93,5 +101,7 @@ module.exports = {
useRecoilCallbackHasDependencyArray,
[noNavigatePreferLinkName]: noNavigatePreferLink,
[injectWorkspaceRepositoryName]: injectWorkspaceRepository,
[restApiMethodsShouldBeGuardedName]: restApiMethodsShouldBeGuarded,
[graphqlResolversShouldBeGuardedName]: graphqlResolversShouldBeGuarded,
},
};

View File

@ -0,0 +1,159 @@
import { TSESLint } from '@typescript-eslint/utils';
import { rule, RULE_NAME } from './graphql-resolvers-should-be-guarded';
const ruleTester = new TSESLint.RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
});
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
class TestResolver {
@Query()
@UseGuards(UserAuthGuard)
testQuery() {}
}
`,
},
{
code: `
class TestResolver {
@Query()
@UseGuards(WorkspaceAuthGuard)
testQuery() {}
}
`,
},
{
code: `
class TestResolver {
@Query()
@UseGuards(PublicEndpointGuard)
testQuery() {}
}
`,
},
{
code: `
class TestResolver {
@Query()
@UseGuards(CaptchaGuard, PublicEndpointGuard)
testQuery() {}
}
`,
},
{
code: `
@UseGuards(UserAuthGuard)
class TestResolver {
@Query()
testQuery() {}
}
`,
},
{
code: `
@UseGuards(WorkspaceAuthGuard)
class TestResolver {
@Query()
testQuery() {}
}
`,
},
{
code: `
@UseGuards(PublicEndpointGuard)
class TestResolver {
@Query()
testQuery() {}
}
`,
},
{
code: `
class TestResolver {
regularMethod() {}
}
`,
},
{
code: `
class TestResolver {
@ResolveField()
testField() {}
}
`,
},
],
invalid: [
{
code: `
class TestResolver {
@Query()
testQuery() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
},
{
code: `
class TestResolver {
@Mutation()
testMutation() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
},
{
code: `
class TestResolver {
@Subscription()
testSubscription() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
},
{
code: `
class TestResolver {
@Query()
@UseGuards(CaptchaGuard)
testQuery() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
},
{
code: `
@UseGuards(CaptchaGuard)
class TestResolver {
@Query()
testQuery() {}
}
`,
errors: [
{
messageId: 'graphqlResolversShouldBeGuarded',
},
],
},
],
});

View File

@ -0,0 +1,70 @@
import { TSESTree } from '@typescript-eslint/utils';
import { createRule } from '../utils/createRule';
import { typedTokenHelpers } from '../utils/typedTokenHelpers';
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-graphql-resolvers-should-be-guarded"
export const RULE_NAME = 'graphql-resolvers-should-be-guarded';
export const graphqlResolversShouldBeGuarded = (node: TSESTree.MethodDefinition) => {
const hasGraphQLResolverDecorator = typedTokenHelpers.nodeHasDecoratorsNamed(
node,
['Query', 'Mutation', 'Subscription']
);
const hasAuthGuards = typedTokenHelpers.nodeHasAuthGuards(node);
function findClassDeclaration(
node: TSESTree.Node
): TSESTree.ClassDeclaration | null {
if (node.type === TSESTree.AST_NODE_TYPES.ClassDeclaration) {
return node;
}
if (node.parent) {
return findClassDeclaration(node.parent);
}
return null;
}
const classNode = findClassDeclaration(node);
const hasAuthGuardsOnResolver = classNode
? typedTokenHelpers.nodeHasAuthGuards(classNode)
: false;
return (
hasGraphQLResolverDecorator &&
!hasAuthGuards &&
!hasAuthGuardsOnResolver
);
};
export const rule = createRule<[], 'graphqlResolversShouldBeGuarded'>({
name: RULE_NAME,
meta: {
docs: {
description:
'GraphQL root resolvers (Query, Mutation, Subscription) should have authentication guards (UserAuthGuard or WorkspaceAuthGuard) or be explicitly marked as public (PublicEndpointGuard) to maintain our security model.',
},
messages: {
graphqlResolversShouldBeGuarded:
'All GraphQL root resolver methods (@Query, @Mutation, @Subscription) should have @UseGuards(UserAuthGuard), @UseGuards(WorkspaceAuthGuard), or @UseGuards(PublicEndpointGuard) decorators, or one decorating the root of the Resolver class.',
},
schema: [],
hasSuggestions: false,
type: 'suggestion',
},
defaultOptions: [],
create(context) {
return {
MethodDefinition(node: TSESTree.MethodDefinition): void {
if (graphqlResolversShouldBeGuarded(node)) {
context.report({
node: node,
messageId: 'graphqlResolversShouldBeGuarded',
});
}
},
};
},
});

View File

@ -0,0 +1,138 @@
import { TSESLint } from '@typescript-eslint/utils';
import { rule, RULE_NAME } from './rest-api-methods-should-be-guarded';
const ruleTester = new TSESLint.RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
});
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `
class TestController {
@Get()
@UseGuards(UserAuthGuard)
testMethod() {}
}
`,
},
{
code: `
class TestController {
@Get()
@UseGuards(WorkspaceAuthGuard)
testMethod() {}
}
`,
},
{
code: `
class TestController {
@Get()
@UseGuards(PublicEndpoint)
testMethod() {}
}
`,
},
{
code: `
class TestController {
@Get()
@UseGuards(CaptchaGuard, PublicEndpoint)
testMethod() {}
}
`,
},
{
code: `
@UseGuards(UserAuthGuard)
class TestController {
@Get()
testMethod() {}
}
`,
},
{
code: `
@UseGuards(WorkspaceAuthGuard)
class TestController {
@Get()
testMethod() {}
}
`,
},
{
code: `
@UseGuards(PublicEndpoint)
class TestController {
@Get()
testMethod() {}
}
`,
},
{
code: `
class TestController {
regularMethod() {}
}
`,
},
],
invalid: [
{
code: `
class TestController {
@Get()
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
},
{
code: `
class TestController {
@Post()
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
},
{
code: `
class TestController {
@Get()
@UseGuards(CaptchaGuard)
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
},
{
code: `
@UseGuards(CaptchaGuard)
class TestController {
@Get()
testMethod() {}
}
`,
errors: [
{
messageId: 'restApiMethodsShouldBeGuarded',
},
],
},
],
});

View File

@ -0,0 +1,70 @@
import { TSESTree } from '@typescript-eslint/utils';
import { createRule } from '../utils/createRule';
import { typedTokenHelpers } from '../utils/typedTokenHelpers';
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-rest-api-methods-should-be-guarded"
export const RULE_NAME = 'rest-api-methods-should-be-guarded';
export const restApiMethodsShouldBeGuarded = (node: TSESTree.MethodDefinition) => {
const hasRestApiMethodDecorator = typedTokenHelpers.nodeHasDecoratorsNamed(
node,
['Get', 'Post', 'Put', 'Delete', 'Patch', 'Options', 'Head', 'All']
);
const hasAuthGuards = typedTokenHelpers.nodeHasAuthGuards(node);
function findClassDeclaration(
node: TSESTree.Node
): TSESTree.ClassDeclaration | null {
if (node.type === TSESTree.AST_NODE_TYPES.ClassDeclaration) {
return node;
}
if (node.parent) {
return findClassDeclaration(node.parent);
}
return null;
}
const classNode = findClassDeclaration(node);
const hasAuthGuardsOnController = classNode
? typedTokenHelpers.nodeHasAuthGuards(classNode)
: false;
return (
hasRestApiMethodDecorator &&
!hasAuthGuards &&
!hasAuthGuardsOnController
);
};
export const rule = createRule<[], 'restApiMethodsShouldBeGuarded'>({
name: RULE_NAME,
meta: {
docs: {
description:
'REST API endpoints should have authentication guards (UserAuthGuard or WorkspaceAuthGuard) or be explicitly marked as public (PublicEndpointGuard) to maintain our security model.',
},
messages: {
restApiMethodsShouldBeGuarded:
'All REST API controller endpoints should have @UseGuards(UserAuthGuard), @UseGuards(WorkspaceAuthGuard), or @UseGuards(PublicEndpointGuard) decorators, or one decorating the root of the Controller.',
},
schema: [],
hasSuggestions: false,
type: 'suggestion',
},
defaultOptions: [],
create(context) {
return {
MethodDefinition(node: TSESTree.MethodDefinition): void {
if (restApiMethodsShouldBeGuarded(node)) {
context.report({
node: node,
messageId: 'restApiMethodsShouldBeGuarded',
});
}
},
};
},
});

View File

@ -0,0 +1,3 @@
import { ESLintUtils } from '@typescript-eslint/utils';
export const createRule = ESLintUtils.RuleCreator(() => __filename);

View File

@ -0,0 +1,56 @@
import { TSESTree } from '@typescript-eslint/utils';
export const typedTokenHelpers = {
nodeHasDecoratorsNamed(
node: TSESTree.MethodDefinition | TSESTree.ClassDeclaration,
decoratorNames: string[]
): boolean {
if (!node.decorators) {
return false;
}
return node.decorators.some((decorator) => {
if (decorator.expression.type === TSESTree.AST_NODE_TYPES.Identifier) {
return decoratorNames.includes(decorator.expression.name);
}
if (decorator.expression.type === TSESTree.AST_NODE_TYPES.CallExpression) {
const callee = decorator.expression.callee;
if (callee.type === TSESTree.AST_NODE_TYPES.Identifier) {
return decoratorNames.includes(callee.name);
}
}
return false;
});
},
nodeHasAuthGuards(
node: TSESTree.MethodDefinition | TSESTree.ClassDeclaration
): boolean {
if (!node.decorators) {
return false;
}
return node.decorators.some((decorator) => {
// Check for @UseGuards() call expression
if (
decorator.expression.type === TSESTree.AST_NODE_TYPES.CallExpression &&
decorator.expression.callee.type === TSESTree.AST_NODE_TYPES.Identifier &&
decorator.expression.callee.name === 'UseGuards'
) {
// Check the arguments for UserAuthGuard, WorkspaceAuthGuard, or PublicEndpoint
return decorator.expression.arguments.some((arg) => {
if (arg.type === TSESTree.AST_NODE_TYPES.Identifier) {
return arg.name === 'UserAuthGuard' ||
arg.name === 'WorkspaceAuthGuard' ||
arg.name === 'PublicEndpointGuard';
}
return false;
});
}
return false;
});
},
};