Skip to content

Commit

Permalink
feat(Feat/permission decorator): Add permissions for specific GraphQL…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
Ho-s authored Jan 22, 2025
1 parent d91e731 commit 6fc65b5
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 17 deletions.
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ You can solve them with Sending JWT token in `Http Header` with the `Authorizati
}
```

#### Example of some protected GraphQL

- getMe (must be authenticated)
- All methods generated by the generator (must be authenticated and must be admin)

### GraphQL Query To Select and relations

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

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

#### Example of some protected GraphQL
### Permission for specific field

- getMe (must be authenticated)
- All methods generated by the generator (must be authenticated and must be admin)
The [permission guard](/src/common/decorators/query-guard.decorator.ts) is used to block access to specific fields in client requests.

#### Why it was created

- In GraphQL, clients can request any field, which could expose sensitive information. This guard ensures that sensitive fields are protected.

- It allows controlling access to specific fields based on the server's permissions.

#### How to use

```ts
@Query(()=>Some)
@UseQueryPermissionGuard(Some, { something: true })
async getManySomeList(){
return this.someService.getMany()
}
```

With this API, if the client request includes the field "something," a `Forbidden` error will be triggered.

#### Note

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.

## License

Expand Down
2 changes: 1 addition & 1 deletion generator/templates/resolver.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class {{pascalCase tableName}}Resolver {
@UseRepositoryInterceptor({{pascalCase tableName}})
getMany{{pascalCase tableName}}List(
@Args({ name: 'input', nullable: true }) condition: GetManyInput<{{pascalCase tableName}}>,
@GraphQLQueryToOption<{{pascalCase tableName}}>(true)
@GraphQLQueryToOption<{{pascalCase tableName}}>()
option: GetInfoFromQueryProps<{{pascalCase tableName}}>,
) {
return this.{{tableName}}Service.getMany({ ...condition, ...option });
Expand Down
16 changes: 5 additions & 11 deletions src/common/decorators/option.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ const addKeyValuesInObject = <Entity>({
relations,
select,
expandRelation,
hasCountType,
}: AddKeyValueInObjectProps<Entity>): GetInfoFromQueryProps<Entity> => {
if (stack.length) {
let stackToString = stack.join('.');

if (hasCountType) {
if (stack.length && stack[0] === DATA) {
if (stack[0] !== DATA || (stack.length === 1 && stack[0] === DATA)) {
return { relations, select };
}
Expand All @@ -46,7 +45,6 @@ const addKeyValuesInObject = <Entity>({
export function getOptionFromGqlQuery<Entity>(
this: Repository<Entity>,
query: string,
hasCountType?: boolean,
): GetInfoFromQueryProps<Entity> {
const splitted = query.split('\n');

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

if (line.includes('{')) {
stack.push(replacedLine);
const isFirstLineDataType = hasCountType && replacedLine === DATA;
const isFirstLineDataType = replacedLine === DATA;

if (!isFirstLineDataType) {
lastMetadata = lastMetadata.relations.find(
Expand All @@ -78,11 +76,9 @@ export function getOptionFromGqlQuery<Entity>(
relations: acc.relations,
select: acc.select,
expandRelation: true,
hasCountType,
});
} else if (line.includes('}')) {
const hasDataTypeInStack =
hasCountType && stack.length && stack[0] === DATA;
const hasDataTypeInStack = stack.length && stack[0] === DATA;

lastMetadata =
stack.length < (hasDataTypeInStack ? 3 : 2)
Expand Down Expand Up @@ -110,7 +106,6 @@ export function getOptionFromGqlQuery<Entity>(
stack: addedStack,
relations: acc.relations,
select: acc.select,
hasCountType,
});
},
{
Expand All @@ -120,7 +115,7 @@ export function getOptionFromGqlQuery<Entity>(
);
}

const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
export const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
const { fieldName, path } = ctx.getArgByIndex(3) as {
fieldName: string;
path: { key: string };
Expand Down Expand Up @@ -159,7 +154,7 @@ const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => {
return stack.join('\n');
};

export const GraphQLQueryToOption = <T>(hasCountType?: boolean) =>
export const GraphQLQueryToOption = <T>() =>
createParamDecorator((_: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
Expand All @@ -175,7 +170,6 @@ export const GraphQLQueryToOption = <T>(hasCountType?: boolean) =>
const queryOption: GetInfoFromQueryProps<T> = getOptionFromGqlQuery.call(
repository,
query,
hasCountType,
);

return queryOption;
Expand Down
20 changes: 20 additions & 0 deletions src/common/decorators/query-guard.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';

import { FindOptionsSelect } from 'typeorm';

import { GraphqlQueryPermissionGuard } from '../guards/graphql-query-permission.guard';

export type ClassConstructor<T = unknown> = new (...args: unknown[]) => T;

export const PERMISSION = Symbol('PERMISSION');
export const INSTANCE = Symbol('INSTANCE');

export const UseQueryPermissionGuard = <T extends ClassConstructor>(
instance: T,
permission: FindOptionsSelect<InstanceType<T>>,
) =>
applyDecorators(
SetMetadata(INSTANCE, instance),
SetMetadata(PERMISSION, permission),
UseGuards(GraphqlQueryPermissionGuard<T>),
);
1 change: 0 additions & 1 deletion src/common/graphql/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,4 @@ export interface AddKeyValueInObjectProps<Entity>
extends GetInfoFromQueryProps<Entity> {
stack: string[];
expandRelation?: boolean;
hasCountType?: boolean;
}
57 changes: 57 additions & 0 deletions src/common/guards/graphql-query-permission.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';

import { DataSource, FindOptionsSelect } from 'typeorm';

import {
getCurrentGraphQLQuery,
getOptionFromGqlQuery,
} from '../decorators/option.decorator';
import {
ClassConstructor,
INSTANCE,
PERMISSION,
} from '../decorators/query-guard.decorator';
import { GetInfoFromQueryProps } from '../graphql/utils/types';

const checkPermission = <T extends ClassConstructor>(
permission: FindOptionsSelect<InstanceType<T>>,
select: FindOptionsSelect<InstanceType<T>>,
): boolean => {
return Object.entries(permission)
.filter((v) => !!v[1])
.every(([key, value]) => {
if (typeof value === 'boolean') {
return select[key] ? false : true;
}

return checkPermission(value, select[key]);
});
};

@Injectable()
export class GraphqlQueryPermissionGuard<T extends ClassConstructor> {
constructor(
private reflector: Reflector,
private readonly dataSource: DataSource,
) {}

canActivate(context: ExecutionContext): boolean {
const permission = this.reflector.get<FindOptionsSelect<InstanceType<T>>>(
PERMISSION,
context.getHandler(),
);

const entity = this.reflector.get<T>(INSTANCE, context.getHandler());
const repository = this.dataSource.getRepository<T>(entity);

const ctx = GqlExecutionContext.create(context);
const query = getCurrentGraphQLQuery(ctx);

const { select }: GetInfoFromQueryProps<InstanceType<T>> =
getOptionFromGqlQuery.call(repository, query);

return checkPermission<T>(permission, select);
}
}
2 changes: 1 addition & 1 deletion src/user/user.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class UserResolver {
getManyUserList(
@Args({ name: 'input', nullable: true })
condition: GetManyInput<User>,
@GraphQLQueryToOption<User>(true)
@GraphQLQueryToOption<User>()
option: GetInfoFromQueryProps<User>,
) {
return this.userService.getMany({ ...condition, ...option });
Expand Down

0 comments on commit 6fc65b5

Please sign in to comment.