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:
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user