Skip to content

Commit b557dae

Browse files
authored
feat: access token permission (#352)
* 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
1 parent 98143c0 commit b557dae

File tree

30 files changed

+318
-193
lines changed

30 files changed

+318
-193
lines changed

apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,10 @@ import {
1111
} from '@teable-group/core';
1212
import { ZodValidationPipe } from '../../../zod.validation.pipe';
1313
import { Permissions } from '../../auth/decorators/permissions.decorator';
14-
import { PermissionGuard } from '../../auth/guard/permission.guard';
1514
import { TqlPipe } from '../../record/open-api/tql.pipe';
1615
import { AggregationOpenApiService } from './aggregation-open-api.service';
1716

1817
@Controller('api/table/:tableId/aggregation')
19-
@UseGuards(PermissionGuard)
2018
export class AggregationOpenApiController {
2119
constructor(private readonly aggregationOpenApiService: AggregationOpenApiService) {}
2220

apps/nestjs-backend/src/features/auth/auth.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class AuthService {
5959
email,
6060
salt,
6161
password: hashPassword,
62+
lastSignTime: new Date().toISOString(),
6263
});
6364
}
6465

@@ -96,4 +97,11 @@ export class AuthService {
9697
// clear session
9798
await this.sessionStoreService.clearByUserId(userId);
9899
}
100+
101+
async refreshLastSignTime(userId: string) {
102+
await this.prismaService.user.update({
103+
where: { id: userId, deletedTime: null },
104+
data: { lastSignTime: new Date().toISOString() },
105+
});
106+
}
99107
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
3+
export const IS_TOKEN_ACCESS = 'isTokenAccess';
4+
// eslint-disable-next-line @typescript-eslint/naming-convention
5+
export const TokenAccess = () => SetMetadata(IS_TOKEN_ACCESS, true);

apps/nestjs-backend/src/features/auth/guard/permission.guard.ts

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
88
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
99
import type { IResourceMeta } from '../decorators/resource_meta.decorator';
1010
import { RESOURCE_META } from '../decorators/resource_meta.decorator';
11+
import { IS_TOKEN_ACCESS } from '../decorators/token.decorator';
1112
import { PermissionService } from '../permission.service';
1213

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

36-
async canActivate(context: ExecutionContext) {
37-
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
38-
context.getHandler(),
39-
context.getClass(),
40-
]);
41-
42-
if (isPublic) {
43-
return true;
37+
/**
38+
* Space creation permissions are more specific and only pertain to users,
39+
* but tokens can be disallowed from being created.
40+
*/
41+
private async permissionCreateSpace() {
42+
const accessTokenId = this.cls.get('accessTokenId');
43+
if (accessTokenId) {
44+
const { scopes } = await this.permissionService.getAccessToken(accessTokenId);
45+
return scopes.includes('space|create');
4446
}
45-
const permissions = this.reflector.getAllAndOverride<PermissionAction[] | undefined>(
46-
PERMISSIONS_KEY,
47-
[context.getHandler(), context.getClass()]
48-
);
47+
return true;
48+
}
4949

50-
if (!permissions?.length) {
51-
return true;
52-
}
50+
private async resourcePermission(context: ExecutionContext, permissions: PermissionAction[]) {
5351
const resourceId = this.getResourceId(context);
5452
if (!resourceId) {
5553
throw new ForbiddenException('permission check ID does not exist');
@@ -85,4 +83,58 @@ export class PermissionGuard {
8583
this.cls.set('permissions', permissionsByCheck);
8684
return true;
8785
}
86+
87+
/**
88+
* permission step:
89+
* 1. public decorator sign
90+
* full public interface
91+
* 2. token decorator sign
92+
* The token can only access interfaces that are restricted by permissions or have a token access indicator.
93+
* 3. permissions decorator sign
94+
* Decorate what permissions are needed to operate the interface,
95+
* if none then it means just logging in is sufficient
96+
* 4. space create permission check
97+
* The space create permission is special, it has nothing to do with resources, but only with users.
98+
* 5. resource permission check
99+
* Because the token is user-generated, the permissions will only be less than the current user,
100+
* so first determine the current user permissions
101+
* 5.1. by user for space
102+
* 5.2. by access token if exists
103+
*/
104+
async canActivate(context: ExecutionContext) {
105+
// public check
106+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
107+
context.getHandler(),
108+
context.getClass(),
109+
]);
110+
111+
if (isPublic) {
112+
return true;
113+
}
114+
115+
const permissions = this.reflector.getAllAndOverride<PermissionAction[] | undefined>(
116+
PERMISSIONS_KEY,
117+
[context.getHandler(), context.getClass()]
118+
);
119+
120+
const accessTokenId = this.cls.get('accessTokenId');
121+
if (accessTokenId && !permissions?.length) {
122+
// Pre-checking of tokens
123+
// The token can only access interfaces that are restricted by permissions or have a token access indicator.
124+
return this.reflector.getAllAndOverride<boolean>(IS_TOKEN_ACCESS, [
125+
context.getHandler(),
126+
context.getClass(),
127+
]);
128+
}
129+
130+
if (!permissions?.length) {
131+
return true;
132+
}
133+
// space create permission check
134+
if (permissions?.includes('space|create')) {
135+
return await this.permissionCreateSpace();
136+
}
137+
// resource permission check
138+
return await this.resourcePermission(context, permissions);
139+
}
88140
}
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { Global, Module } from '@nestjs/common';
2+
import { APP_GUARD } from '@nestjs/core';
23
import { PermissionGuard } from './guard/permission.guard';
34
import { PermissionService } from './permission.service';
45

56
@Global()
67
@Module({
7-
providers: [PermissionService, PermissionGuard],
8+
providers: [
9+
PermissionService,
10+
PermissionGuard,
11+
{
12+
provide: APP_GUARD,
13+
useClass: PermissionGuard,
14+
},
15+
],
816
exports: [PermissionService, PermissionGuard],
917
})
1018
export class PermissionModule {}

apps/nestjs-backend/src/features/auth/permission.service.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,24 @@ export class PermissionService {
9090
return await this.checkPermissionByBaseId(table.base.id, permissions);
9191
}
9292

93+
async getAccessToken(accessTokenId: string) {
94+
const { scopes, spaceIds, baseIds } = await this.prismaService.accessToken.findFirstOrThrow({
95+
where: { id: accessTokenId },
96+
select: { scopes: true, spaceIds: true, baseIds: true },
97+
});
98+
return {
99+
scopes: JSON.parse(scopes) as PermissionAction[],
100+
spaceIds: spaceIds ? JSON.parse(spaceIds) : undefined,
101+
baseIds: baseIds ? JSON.parse(baseIds) : undefined,
102+
};
103+
}
104+
93105
async checkPermissionByAccessToken(
94106
resourceId: string,
95107
accessTokenId: string,
96108
permissions: PermissionAction[]
97109
) {
98-
const { scopes, spaceIds, baseIds } = await this.prismaService.accessToken.findFirstOrThrow({
99-
where: { id: accessTokenId },
100-
select: { scopes: true, spaceIds: true, baseIds: true },
101-
});
110+
const { scopes, spaceIds, baseIds } = await this.getAccessToken(accessTokenId);
102111

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

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

131-
return JSON.parse(scopes) as PermissionAction[];
140+
return scopes;
132141
}
133142
}

apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
1818
if (!user) {
1919
throw new BadRequestException('Incorrect password.');
2020
}
21+
await this.authService.refreshLastSignTime(user.id);
2122
return pickUserMe(user);
2223
}
2324
}

apps/nestjs-backend/src/features/base/base.controller.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
2-
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
2+
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
33
import {
44
createBaseRoSchema,
55
ICreateBaseRo,
@@ -18,13 +18,11 @@ import { Events } from '../../event-emitter/events';
1818
import { ZodValidationPipe } from '../../zod.validation.pipe';
1919
import { Permissions } from '../auth/decorators/permissions.decorator';
2020
import { ResourceMeta } from '../auth/decorators/resource_meta.decorator';
21-
import { PermissionGuard } from '../auth/guard/permission.guard';
2221
import { CollaboratorService } from '../collaborator/collaborator.service';
2322
import { BaseService } from './base.service';
2423
import { DbConnectionService } from './db-connection.service';
2524

2625
@Controller('api/base/')
27-
@UseGuards(PermissionGuard)
2826
export class BaseController {
2927
constructor(
3028
private readonly baseService: BaseService,

apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,5 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
2-
import {
3-
Body,
4-
Controller,
5-
Delete,
6-
Get,
7-
Param,
8-
Patch,
9-
Put,
10-
Post,
11-
Query,
12-
UseGuards,
13-
} from '@nestjs/common';
2+
import { Body, Controller, Delete, Get, Param, Patch, Put, Post, Query } from '@nestjs/common';
143
import type { IFieldVo } from '@teable-group/core';
154
import {
165
createFieldRoSchema,
@@ -25,12 +14,10 @@ import {
2514
import type { IPlanFieldConvertVo, IPlanFieldVo } from '@teable-group/openapi';
2615
import { ZodValidationPipe } from '../../../zod.validation.pipe';
2716
import { Permissions } from '../../auth/decorators/permissions.decorator';
28-
import { PermissionGuard } from '../../auth/guard/permission.guard';
2917
import { FieldService } from '../field.service';
3018
import { FieldOpenApiService } from './field-open-api.service';
3119

3220
@Controller('api/table/:tableId/field')
33-
@UseGuards(PermissionGuard)
3421
export class FieldOpenApiController {
3522
constructor(
3623
private readonly fieldService: FieldService,

apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,4 @@
1-
import {
2-
Body,
3-
Controller,
4-
Delete,
5-
Get,
6-
Param,
7-
Patch,
8-
Post,
9-
Query,
10-
UseGuards,
11-
} from '@nestjs/common';
1+
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
122
import type { ICreateRecordsVo, IRecord, IRecordsVo } from '@teable-group/core';
133
import {
144
createRecordsRoSchema,
@@ -23,13 +13,11 @@ import {
2313
import { deleteRecordsQuerySchema, IDeleteRecordsQuery } from '@teable-group/openapi';
2414
import { ZodValidationPipe } from '../../../zod.validation.pipe';
2515
import { Permissions } from '../../auth/decorators/permissions.decorator';
26-
import { PermissionGuard } from '../../auth/guard/permission.guard';
2716
import { RecordService } from '../record.service';
2817
import { RecordOpenApiService } from './record-open-api.service';
2918
import { TqlPipe } from './tql.pipe';
3019

3120
@Controller('api/table/:tableId/record')
32-
@UseGuards(PermissionGuard)
3321
export class RecordOpenApiController {
3422
constructor(
3523
private readonly recordService: RecordService,

0 commit comments

Comments
 (0)