Skip to content

Commit

Permalink
feat: access token permission (#352)
Browse files Browse the repository at this point in the history
* feat: limit access token permission in scopes

* fix: space order

* fix: personal access token manange ui

* fix: uploading attachment width and height

* feat: permision guard set as global guard

* fix: typecheck
  • Loading branch information
boris-w authored Jan 30, 2024
1 parent 98143c0 commit b557dae
Show file tree
Hide file tree
Showing 30 changed files with 318 additions and 193 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ import {
} from '@teable-group/core';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
import { Permissions } from '../../auth/decorators/permissions.decorator';
import { PermissionGuard } from '../../auth/guard/permission.guard';
import { TqlPipe } from '../../record/open-api/tql.pipe';
import { AggregationOpenApiService } from './aggregation-open-api.service';

@Controller('api/table/:tableId/aggregation')
@UseGuards(PermissionGuard)
export class AggregationOpenApiController {
constructor(private readonly aggregationOpenApiService: AggregationOpenApiService) {}

Expand Down
8 changes: 8 additions & 0 deletions apps/nestjs-backend/src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class AuthService {
email,
salt,
password: hashPassword,
lastSignTime: new Date().toISOString(),
});
}

Expand Down Expand Up @@ -96,4 +97,11 @@ export class AuthService {
// clear session
await this.sessionStoreService.clearByUserId(userId);
}

async refreshLastSignTime(userId: string) {
await this.prismaService.user.update({
where: { id: userId, deletedTime: null },
data: { lastSignTime: new Date().toISOString() },
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';

export const IS_TOKEN_ACCESS = 'isTokenAccess';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const TokenAccess = () => SetMetadata(IS_TOKEN_ACCESS, true);
82 changes: 67 additions & 15 deletions apps/nestjs-backend/src/features/auth/guard/permission.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import type { IResourceMeta } from '../decorators/resource_meta.decorator';
import { RESOURCE_META } from '../decorators/resource_meta.decorator';
import { IS_TOKEN_ACCESS } from '../decorators/token.decorator';
import { PermissionService } from '../permission.service';

@Injectable()
Expand All @@ -33,23 +34,20 @@ export class PermissionGuard {
return req.params.baseId || req.params.spaceId || req.params.tableId;
}

async canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);

if (isPublic) {
return true;
/**
* Space creation permissions are more specific and only pertain to users,
* but tokens can be disallowed from being created.
*/
private async permissionCreateSpace() {
const accessTokenId = this.cls.get('accessTokenId');
if (accessTokenId) {
const { scopes } = await this.permissionService.getAccessToken(accessTokenId);
return scopes.includes('space|create');
}
const permissions = this.reflector.getAllAndOverride<PermissionAction[] | undefined>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()]
);
return true;
}

if (!permissions?.length) {
return true;
}
private async resourcePermission(context: ExecutionContext, permissions: PermissionAction[]) {
const resourceId = this.getResourceId(context);
if (!resourceId) {
throw new ForbiddenException('permission check ID does not exist');
Expand Down Expand Up @@ -85,4 +83,58 @@ export class PermissionGuard {
this.cls.set('permissions', permissionsByCheck);
return true;
}

/**
* permission step:
* 1. public decorator sign
* full public interface
* 2. token decorator sign
* The token can only access interfaces that are restricted by permissions or have a token access indicator.
* 3. permissions decorator sign
* Decorate what permissions are needed to operate the interface,
* if none then it means just logging in is sufficient
* 4. space create permission check
* The space create permission is special, it has nothing to do with resources, but only with users.
* 5. resource permission check
* Because the token is user-generated, the permissions will only be less than the current user,
* so first determine the current user permissions
* 5.1. by user for space
* 5.2. by access token if exists
*/
async canActivate(context: ExecutionContext) {
// public check
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);

if (isPublic) {
return true;
}

const permissions = this.reflector.getAllAndOverride<PermissionAction[] | undefined>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()]
);

const accessTokenId = this.cls.get('accessTokenId');
if (accessTokenId && !permissions?.length) {
// Pre-checking of tokens
// The token can only access interfaces that are restricted by permissions or have a token access indicator.
return this.reflector.getAllAndOverride<boolean>(IS_TOKEN_ACCESS, [
context.getHandler(),
context.getClass(),
]);
}

if (!permissions?.length) {
return true;
}
// space create permission check
if (permissions?.includes('space|create')) {
return await this.permissionCreateSpace();
}
// resource permission check
return await this.resourcePermission(context, permissions);
}
}
10 changes: 9 additions & 1 deletion apps/nestjs-backend/src/features/auth/permission.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { PermissionGuard } from './guard/permission.guard';
import { PermissionService } from './permission.service';

@Global()
@Module({
providers: [PermissionService, PermissionGuard],
providers: [
PermissionService,
PermissionGuard,
{
provide: APP_GUARD,
useClass: PermissionGuard,
},
],
exports: [PermissionService, PermissionGuard],
})
export class PermissionModule {}
21 changes: 15 additions & 6 deletions apps/nestjs-backend/src/features/auth/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,24 @@ export class PermissionService {
return await this.checkPermissionByBaseId(table.base.id, permissions);
}

async getAccessToken(accessTokenId: string) {
const { scopes, spaceIds, baseIds } = await this.prismaService.accessToken.findFirstOrThrow({
where: { id: accessTokenId },
select: { scopes: true, spaceIds: true, baseIds: true },
});
return {
scopes: JSON.parse(scopes) as PermissionAction[],
spaceIds: spaceIds ? JSON.parse(spaceIds) : undefined,
baseIds: baseIds ? JSON.parse(baseIds) : undefined,
};
}

async checkPermissionByAccessToken(
resourceId: string,
accessTokenId: string,
permissions: PermissionAction[]
) {
const { scopes, spaceIds, baseIds } = await this.prismaService.accessToken.findFirstOrThrow({
where: { id: accessTokenId },
select: { scopes: true, spaceIds: true, baseIds: true },
});
const { scopes, spaceIds, baseIds } = await this.getAccessToken(accessTokenId);

if (resourceId.startsWith(IdPrefix.Table)) {
const table = await this.prismaService.tableMeta.findFirst({
Expand All @@ -123,11 +132,11 @@ export class PermissionService {
throw new ForbiddenException(`not allowed to base ${resourceId}`);
}

const accessTokenPermissions = JSON.parse(scopes) as PermissionAction[];
const accessTokenPermissions = scopes;
if (permissions.some((permission) => !accessTokenPermissions.includes(permission))) {
throw new ForbiddenException(`not allowed to ${resourceId}`);
}

return JSON.parse(scopes) as PermissionAction[];
return scopes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
if (!user) {
throw new BadRequestException('Incorrect password.');
}
await this.authService.refreshLastSignTime(user.id);
return pickUserMe(user);
}
}
4 changes: 1 addition & 3 deletions apps/nestjs-backend/src/features/base/base.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import {
createBaseRoSchema,
ICreateBaseRo,
Expand All @@ -18,13 +18,11 @@ import { Events } from '../../event-emitter/events';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { ResourceMeta } from '../auth/decorators/resource_meta.decorator';
import { PermissionGuard } from '../auth/guard/permission.guard';
import { CollaboratorService } from '../collaborator/collaborator.service';
import { BaseService } from './base.service';
import { DbConnectionService } from './db-connection.service';

@Controller('api/base/')
@UseGuards(PermissionGuard)
export class BaseController {
constructor(
private readonly baseService: BaseService,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Put,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Put, Post, Query } from '@nestjs/common';
import type { IFieldVo } from '@teable-group/core';
import {
createFieldRoSchema,
Expand All @@ -25,12 +14,10 @@ import {
import type { IPlanFieldConvertVo, IPlanFieldVo } from '@teable-group/openapi';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
import { Permissions } from '../../auth/decorators/permissions.decorator';
import { PermissionGuard } from '../../auth/guard/permission.guard';
import { FieldService } from '../field.service';
import { FieldOpenApiService } from './field-open-api.service';

@Controller('api/table/:tableId/field')
@UseGuards(PermissionGuard)
export class FieldOpenApiController {
constructor(
private readonly fieldService: FieldService,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,4 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import type { ICreateRecordsVo, IRecord, IRecordsVo } from '@teable-group/core';
import {
createRecordsRoSchema,
Expand All @@ -23,13 +13,11 @@ import {
import { deleteRecordsQuerySchema, IDeleteRecordsQuery } from '@teable-group/openapi';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
import { Permissions } from '../../auth/decorators/permissions.decorator';
import { PermissionGuard } from '../../auth/guard/permission.guard';
import { RecordService } from '../record.service';
import { RecordOpenApiService } from './record-open-api.service';
import { TqlPipe } from './tql.pipe';

@Controller('api/table/:tableId/record')
@UseGuards(PermissionGuard)
export class RecordOpenApiController {
constructor(
private readonly recordService: RecordService,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
import type { ICopyVo, IRangesToIdVo, IPasteVo } from '@teable-group/openapi';
import {
IRangesToIdQuery,
Expand All @@ -11,12 +11,10 @@ import {
} from '@teable-group/openapi';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { PermissionGuard } from '../auth/guard/permission.guard';
import { TqlPipe } from '../record/open-api/tql.pipe';
import { SelectionService } from './selection.service';

@Controller('api/table/:tableId/selection')
@UseGuards(PermissionGuard)
export class SelectionController {
constructor(private selectionService: SelectionService) {}

Expand Down
15 changes: 2 additions & 13 deletions apps/nestjs-backend/src/features/space/space.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
Body,
Controller,
Param,
Patch,
Post,
Get,
Delete,
Query,
UseGuards,
} from '@nestjs/common';
import { Body, Controller, Param, Patch, Post, Get, Delete, Query } from '@nestjs/common';
import type {
ICreateSpaceVo,
IUpdateSpaceVo,
Expand Down Expand Up @@ -39,13 +29,11 @@ import { EmitControllerEvent } from '../../event-emitter/decorators/emit-control
import { Events } from '../../event-emitter/events';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { PermissionGuard } from '../auth/guard/permission.guard';
import { CollaboratorService } from '../collaborator/collaborator.service';
import { InvitationService } from '../invitation/invitation.service';
import { SpaceService } from './space.service';

@Controller('api/space/')
@UseGuards(PermissionGuard)
export class SpaceController {
constructor(
private readonly spaceService: SpaceService,
Expand All @@ -54,6 +42,7 @@ export class SpaceController {
) {}

@Post()
@Permissions('space|create')
@EmitControllerEvent(Events.SPACE_CREATE)
async createSpace(
@Body(new ZodValidationPipe(createSpaceRoSchema))
Expand Down
1 change: 1 addition & 0 deletions apps/nestjs-backend/src/features/space/space.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class SpaceService {
const spaceList = await this.prismaService.space.findMany({
where: { id: { in: spaceIds } },
select: { id: true, name: true },
orderBy: { createdTime: 'asc' },
});
const roleMap = keyBy(collaboratorSpaceList, 'spaceId');
return spaceList.map((space) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import type { ITableFullVo, ITableListVo, ITableVo } from '@teable-group/core';
import {
getTableQuerySchema,
Expand All @@ -25,13 +25,11 @@ import {
} from '@teable-group/openapi';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
import { Permissions } from '../../auth/decorators/permissions.decorator';
import { PermissionGuard } from '../../auth/guard/permission.guard';
import { TableService } from '../table.service';
import { TableOpenApiService } from './table-open-api.service';
import { TablePipe } from './table.pipe';

@Controller('api/base/:baseId/table')
@UseGuards(PermissionGuard)
export class TableController {
constructor(
private readonly tableService: TableService,
Expand Down
Loading

0 comments on commit b557dae

Please sign in to comment.