Skip to content

Commit 6fc65b5

Browse files
authored
feat(Feat/permission decorator): Add permissions for specific GraphQL fields (#36)
* Feat: Implement graphql query guard - Can add inaccessible field from query to block attack request * Feat: Remove has count type * Feat: Add check permission * Chore: Remove test code * Chore: Move md explanation * Chore: Change name of class * Docs: Readme.md * Docs: Add note about permission guard * Fix: typo
1 parent d91e731 commit 6fc65b5

File tree

7 files changed

+113
-17
lines changed

7 files changed

+113
-17
lines changed

README.md

+29-3
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ You can solve them with Sending JWT token in `Http Header` with the `Authorizati
6161
}
6262
```
6363

64+
#### Example of some protected GraphQL
65+
66+
- getMe (must be authenticated)
67+
- All methods generated by the generator (must be authenticated and must be admin)
68+
6469
### GraphQL Query To Select and relations
6570

6671
#### Dynamic Query Optimization
@@ -75,10 +80,31 @@ You can solve them with Sending JWT token in `Http Header` with the `Authorizati
7580

7681
- You can find example code in [/src/user/user.resolver.ts](/src/user/user.resolver.ts)
7782

78-
#### Example of some protected GraphQL
83+
### Permission for specific field
7984

80-
- getMe (must be authenticated)
81-
- All methods generated by the generator (must be authenticated and must be admin)
85+
The [permission guard](/src/common/decorators/query-guard.decorator.ts) is used to block access to specific fields in client requests.
86+
87+
#### Why it was created
88+
89+
- In GraphQL, clients can request any field, which could expose sensitive information. This guard ensures that sensitive fields are protected.
90+
91+
- It allows controlling access to specific fields based on the server's permissions.
92+
93+
#### How to use
94+
95+
```ts
96+
@Query(()=>Some)
97+
@UseQueryPermissionGuard(Some, { something: true })
98+
async getManySomeList(){
99+
return this.someService.getMany()
100+
}
101+
```
102+
103+
With this API, if the client request includes the field "something," a `Forbidden` error will be triggered.
104+
105+
#### Note
106+
107+
There might be duplicate code when using this guard alongside `other interceptors`(name: `UseRepositoryInterceptor`) in this boilerplate. In such cases, you may need to adjust the code to ensure compatibility.
82108

83109
## License
84110

generator/templates/resolver.hbs

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export class {{pascalCase tableName}}Resolver {
2020
@UseRepositoryInterceptor({{pascalCase tableName}})
2121
getMany{{pascalCase tableName}}List(
2222
@Args({ name: 'input', nullable: true }) condition: GetManyInput<{{pascalCase tableName}}>,
23-
@GraphQLQueryToOption<{{pascalCase tableName}}>(true)
23+
@GraphQLQueryToOption<{{pascalCase tableName}}>()
2424
option: GetInfoFromQueryProps<{{pascalCase tableName}}>,
2525
) {
2626
return this.{{tableName}}Service.getMany({ ...condition, ...option });

src/common/decorators/option.decorator.ts

+5-11
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ const addKeyValuesInObject = <Entity>({
2121
relations,
2222
select,
2323
expandRelation,
24-
hasCountType,
2524
}: AddKeyValueInObjectProps<Entity>): GetInfoFromQueryProps<Entity> => {
2625
if (stack.length) {
2726
let stackToString = stack.join('.');
2827

29-
if (hasCountType) {
28+
if (stack.length && stack[0] === DATA) {
3029
if (stack[0] !== DATA || (stack.length === 1 && stack[0] === DATA)) {
3130
return { relations, select };
3231
}
@@ -46,7 +45,6 @@ const addKeyValuesInObject = <Entity>({
4645
export function getOptionFromGqlQuery<Entity>(
4746
this: Repository<Entity>,
4847
query: string,
49-
hasCountType?: boolean,
5048
): GetInfoFromQueryProps<Entity> {
5149
const splitted = query.split('\n');
5250

@@ -65,7 +63,7 @@ export function getOptionFromGqlQuery<Entity>(
6563

6664
if (line.includes('{')) {
6765
stack.push(replacedLine);
68-
const isFirstLineDataType = hasCountType && replacedLine === DATA;
66+
const isFirstLineDataType = replacedLine === DATA;
6967

7068
if (!isFirstLineDataType) {
7169
lastMetadata = lastMetadata.relations.find(
@@ -78,11 +76,9 @@ export function getOptionFromGqlQuery<Entity>(
7876
relations: acc.relations,
7977
select: acc.select,
8078
expandRelation: true,
81-
hasCountType,
8279
});
8380
} else if (line.includes('}')) {
84-
const hasDataTypeInStack =
85-
hasCountType && stack.length && stack[0] === DATA;
81+
const hasDataTypeInStack = stack.length && stack[0] === DATA;
8682

8783
lastMetadata =
8884
stack.length < (hasDataTypeInStack ? 3 : 2)
@@ -110,7 +106,6 @@ export function getOptionFromGqlQuery<Entity>(
110106
stack: addedStack,
111107
relations: acc.relations,
112108
select: acc.select,
113-
hasCountType,
114109
});
115110
},
116111
{
@@ -120,7 +115,7 @@ export function getOptionFromGqlQuery<Entity>(
120115
);
121116
}
122117

123-
const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
118+
export const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
124119
const { fieldName, path } = ctx.getArgByIndex(3) as {
125120
fieldName: string;
126121
path: { key: string };
@@ -159,7 +154,7 @@ const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
159154
return stack.join('\n');
160155
};
161156

162-
export const GraphQLQueryToOption = <T>(hasCountType?: boolean) =>
157+
export const GraphQLQueryToOption = <T>() =>
163158
createParamDecorator((_: unknown, context: ExecutionContext) => {
164159
const ctx = GqlExecutionContext.create(context);
165160
const request = ctx.getContext().req;
@@ -175,7 +170,6 @@ export const GraphQLQueryToOption = <T>(hasCountType?: boolean) =>
175170
const queryOption: GetInfoFromQueryProps<T> = getOptionFromGqlQuery.call(
176171
repository,
177172
query,
178-
hasCountType,
179173
);
180174

181175
return queryOption;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
2+
3+
import { FindOptionsSelect } from 'typeorm';
4+
5+
import { GraphqlQueryPermissionGuard } from '../guards/graphql-query-permission.guard';
6+
7+
export type ClassConstructor<T = unknown> = new (...args: unknown[]) => T;
8+
9+
export const PERMISSION = Symbol('PERMISSION');
10+
export const INSTANCE = Symbol('INSTANCE');
11+
12+
export const UseQueryPermissionGuard = <T extends ClassConstructor>(
13+
instance: T,
14+
permission: FindOptionsSelect<InstanceType<T>>,
15+
) =>
16+
applyDecorators(
17+
SetMetadata(INSTANCE, instance),
18+
SetMetadata(PERMISSION, permission),
19+
UseGuards(GraphqlQueryPermissionGuard<T>),
20+
);

src/common/graphql/utils/types.ts

-1
Original file line numberDiff line numberDiff line change
@@ -115,5 +115,4 @@ export interface AddKeyValueInObjectProps<Entity>
115115
extends GetInfoFromQueryProps<Entity> {
116116
stack: string[];
117117
expandRelation?: boolean;
118-
hasCountType?: boolean;
119118
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ExecutionContext, Injectable } from '@nestjs/common';
2+
import { Reflector } from '@nestjs/core';
3+
import { GqlExecutionContext } from '@nestjs/graphql';
4+
5+
import { DataSource, FindOptionsSelect } from 'typeorm';
6+
7+
import {
8+
getCurrentGraphQLQuery,
9+
getOptionFromGqlQuery,
10+
} from '../decorators/option.decorator';
11+
import {
12+
ClassConstructor,
13+
INSTANCE,
14+
PERMISSION,
15+
} from '../decorators/query-guard.decorator';
16+
import { GetInfoFromQueryProps } from '../graphql/utils/types';
17+
18+
const checkPermission = <T extends ClassConstructor>(
19+
permission: FindOptionsSelect<InstanceType<T>>,
20+
select: FindOptionsSelect<InstanceType<T>>,
21+
): boolean => {
22+
return Object.entries(permission)
23+
.filter((v) => !!v[1])
24+
.every(([key, value]) => {
25+
if (typeof value === 'boolean') {
26+
return select[key] ? false : true;
27+
}
28+
29+
return checkPermission(value, select[key]);
30+
});
31+
};
32+
33+
@Injectable()
34+
export class GraphqlQueryPermissionGuard<T extends ClassConstructor> {
35+
constructor(
36+
private reflector: Reflector,
37+
private readonly dataSource: DataSource,
38+
) {}
39+
40+
canActivate(context: ExecutionContext): boolean {
41+
const permission = this.reflector.get<FindOptionsSelect<InstanceType<T>>>(
42+
PERMISSION,
43+
context.getHandler(),
44+
);
45+
46+
const entity = this.reflector.get<T>(INSTANCE, context.getHandler());
47+
const repository = this.dataSource.getRepository<T>(entity);
48+
49+
const ctx = GqlExecutionContext.create(context);
50+
const query = getCurrentGraphQLQuery(ctx);
51+
52+
const { select }: GetInfoFromQueryProps<InstanceType<T>> =
53+
getOptionFromGqlQuery.call(repository, query);
54+
55+
return checkPermission<T>(permission, select);
56+
}
57+
}

src/user/user.resolver.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class UserResolver {
2525
getManyUserList(
2626
@Args({ name: 'input', nullable: true })
2727
condition: GetManyInput<User>,
28-
@GraphQLQueryToOption<User>(true)
28+
@GraphQLQueryToOption<User>()
2929
option: GetInfoFromQueryProps<User>,
3030
) {
3131
return this.userService.getMany({ ...condition, ...option });

0 commit comments

Comments
 (0)