Skip to content

Commit d890de7

Browse files
authored
feat: add hasFullAccess field for unlimited resource access (#1574)
1 parent 20b4beb commit d890de7

File tree

22 files changed

+282
-129
lines changed

22 files changed

+282
-129
lines changed

apps/nestjs-backend/src/features/access-token/access-token.service.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,19 @@ export class AccessTokenService {
2727
createdTime?: Date;
2828
lastUsedTime?: Date | null;
2929
expiredTime?: Date;
30+
hasFullAccess?: boolean | null;
3031
},
3132
>(accessTokenEntity: T) {
32-
const { scopes, spaceIds, baseIds, createdTime, lastUsedTime, expiredTime, description } =
33-
accessTokenEntity;
33+
const {
34+
scopes,
35+
spaceIds,
36+
baseIds,
37+
createdTime,
38+
lastUsedTime,
39+
expiredTime,
40+
description,
41+
hasFullAccess,
42+
} = accessTokenEntity;
3443
return {
3544
...accessTokenEntity,
3645
description: description || undefined,
@@ -40,6 +49,7 @@ export class AccessTokenService {
4049
createdTime: createdTime?.toISOString(),
4150
lastUsedTime: lastUsedTime?.toISOString(),
4251
expiredTime: expiredTime?.toISOString(),
52+
hasFullAccess: hasFullAccess ?? undefined,
4353
};
4454
}
4555

@@ -87,6 +97,7 @@ export class AccessTokenService {
8797
scopes: true,
8898
spaceIds: true,
8999
baseIds: true,
100+
hasFullAccess: true,
90101
createdTime: true,
91102
expiredTime: true,
92103
lastUsedTime: true,
@@ -100,7 +111,7 @@ export class AccessTokenService {
100111
createAccessToken: CreateAccessTokenRo & { clientId?: string; userId?: string }
101112
) {
102113
const userId = createAccessToken.userId ?? this.cls.get('user.id')!;
103-
const { name, description, scopes, spaceIds, baseIds, expiredTime, clientId } =
114+
const { name, description, scopes, spaceIds, baseIds, expiredTime, clientId, hasFullAccess } =
104115
createAccessToken;
105116
const id = generateAccessTokenId();
106117
const sign = getRandomString(16);
@@ -116,6 +127,7 @@ export class AccessTokenService {
116127
sign,
117128
clientId,
118129
expiredTime: new Date(expiredTime).toISOString(),
130+
hasFullAccess,
119131
},
120132
select: {
121133
id: true,
@@ -127,6 +139,7 @@ export class AccessTokenService {
127139
expiredTime: true,
128140
createdTime: true,
129141
lastUsedTime: true,
142+
hasFullAccess: true,
130143
},
131144
});
132145
return {
@@ -172,7 +185,7 @@ export class AccessTokenService {
172185

173186
async updateAccessToken(id: string, updateAccessToken: UpdateAccessTokenRo) {
174187
const userId = this.cls.get('user.id');
175-
const { name, description, scopes, spaceIds, baseIds } = updateAccessToken;
188+
const { name, description, scopes, spaceIds, baseIds, hasFullAccess } = updateAccessToken;
176189
const accessTokenEntity = await this.prismaService.accessToken.update({
177190
where: { id, userId },
178191
data: {
@@ -181,6 +194,7 @@ export class AccessTokenService {
181194
scopes: JSON.stringify(scopes),
182195
spaceIds: spaceIds === null ? null : JSON.stringify(spaceIds),
183196
baseIds: baseIds === null ? null : JSON.stringify(baseIds),
197+
hasFullAccess,
184198
},
185199
select: {
186200
id: true,
@@ -189,6 +203,7 @@ export class AccessTokenService {
189203
scopes: true,
190204
spaceIds: true,
191205
baseIds: true,
206+
hasFullAccess: true,
192207
},
193208
});
194209
return this.transformAccessTokenEntity(accessTokenEntity);
@@ -208,6 +223,7 @@ export class AccessTokenService {
208223
createdTime: true,
209224
expiredTime: true,
210225
lastUsedTime: true,
226+
hasFullAccess: true,
211227
},
212228
});
213229
const res = this.transformAccessTokenEntity(item);

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ export class PermissionGuard {
5757
return true;
5858
}
5959

60+
private async permissionSpaceRead() {
61+
const accessTokenId = this.cls.get('accessTokenId');
62+
if (accessTokenId) {
63+
const { scopes } = await this.permissionService.getAccessToken(accessTokenId);
64+
return scopes.includes('space|read');
65+
}
66+
return true;
67+
}
68+
6069
protected async resourcePermission(resourceId: string | undefined, permissions: Action[]) {
6170
if (!resourceId) {
6271
console.log('permissions', permissions);
@@ -122,9 +131,13 @@ export class PermissionGuard {
122131
if (permissions?.includes('base|read_all')) {
123132
return await this.permissionBaseReadAll();
124133
}
134+
const resourceId = this.getResourceId(context);
135+
if (!resourceId && permissions?.includes('space|read')) {
136+
return await this.permissionSpaceRead();
137+
}
125138

126139
// resource permission check
127-
return await this.resourcePermission(this.getResourceId(context), permissions);
140+
return await this.resourcePermission(resourceId, permissions);
128141
}
129142

130143
/**

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ describe('PermissionService', () => {
224224
scopes,
225225
spaceIds,
226226
baseIds: undefined,
227+
hasFullAccess: undefined,
227228
});
228229

229230
const result = await service.getPermissionsByAccessToken(resourceId, accessTokenId);
@@ -240,6 +241,7 @@ describe('PermissionService', () => {
240241
scopes: ['table|update'],
241242
spaceIds,
242243
baseIds: undefined,
244+
hasFullAccess: undefined,
243245
});
244246

245247
await expect(
@@ -256,6 +258,7 @@ describe('PermissionService', () => {
256258
scopes: ['table|read'],
257259
baseIds,
258260
spaceIds: undefined,
261+
hasFullAccess: undefined,
259262
});
260263

261264
vi.spyOn(service as any, 'isBaseIdAllowedForResource').mockResolvedValueOnce(false);

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,17 @@ export class PermissionService {
8484
baseIds,
8585
clientId,
8686
userId,
87+
hasFullAccess,
8788
} = await this.prismaService.accessToken.findFirstOrThrow({
8889
where: { id: accessTokenId },
89-
select: { scopes: true, spaceIds: true, baseIds: true, clientId: true, userId: true },
90+
select: {
91+
scopes: true,
92+
spaceIds: true,
93+
baseIds: true,
94+
clientId: true,
95+
userId: true,
96+
hasFullAccess: true,
97+
},
9098
});
9199
const scopes = JSON.parse(stringifyScopes) as Action[];
92100
if (clientId && clientId.startsWith(IdPrefix.OAuthClient)) {
@@ -102,6 +110,7 @@ export class PermissionService {
102110
scopes,
103111
spaceIds: spaceIds ? JSON.parse(spaceIds) : undefined,
104112
baseIds: baseIds ? JSON.parse(baseIds) : undefined,
113+
hasFullAccess: hasFullAccess ?? undefined,
105114
};
106115
}
107116

@@ -170,7 +179,11 @@ export class PermissionService {
170179
accessTokenId: string,
171180
includeInactiveResource?: boolean
172181
) {
173-
const { scopes, spaceIds, baseIds } = await this.getAccessToken(accessTokenId);
182+
const { scopes, spaceIds, baseIds, hasFullAccess } = await this.getAccessToken(accessTokenId);
183+
184+
if (hasFullAccess) {
185+
return scopes;
186+
}
174187

175188
if (
176189
!resourceId.startsWith(IdPrefix.Space) &&

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

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -110,54 +110,6 @@ export class BaseService {
110110
});
111111
}
112112

113-
async getAccessBaseList() {
114-
const userId = this.cls.get('user.id');
115-
const accessTokenId = this.cls.get('accessTokenId');
116-
const { spaceIds, baseIds } =
117-
await this.collaboratorService.getCurrentUserCollaboratorsBaseAndSpaceArray();
118-
119-
if (accessTokenId) {
120-
const access = await this.prismaService.accessToken.findFirst({
121-
select: {
122-
baseIds: true,
123-
spaceIds: true,
124-
},
125-
where: {
126-
id: accessTokenId,
127-
userId,
128-
},
129-
});
130-
if (!access) {
131-
return [];
132-
}
133-
spaceIds.push(...(access.spaceIds || []));
134-
baseIds.push(...(access.baseIds || []));
135-
}
136-
137-
return this.prismaService.base.findMany({
138-
select: {
139-
id: true,
140-
name: true,
141-
},
142-
where: {
143-
deletedTime: null,
144-
OR: [
145-
{
146-
id: {
147-
in: baseIds,
148-
},
149-
},
150-
{
151-
spaceId: {
152-
in: spaceIds,
153-
},
154-
},
155-
],
156-
},
157-
orderBy: [{ spaceId: 'asc' }, { order: 'asc' }],
158-
});
159-
}
160-
161113
private async getMaxOrder(spaceId: string) {
162114
const spaceAggregate = await this.prismaService.base.aggregate({
163115
where: { spaceId, deletedTime: null },

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export class SpaceController {
8989
return await this.spaceService.getSpaceById(spaceId);
9090
}
9191

92+
@Permissions('space|read')
9293
@Get()
9394
async getSpaceList(): Promise<IGetSpaceVo[]> {
9495
return await this.spaceService.getSpaceList();

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ export class SpaceService {
8383
};
8484
}
8585

86+
async filterSpaceListWithAccessToken(spaceList: { id: string; name: string }[]) {
87+
const accessTokenId = this.cls.get('accessTokenId');
88+
if (!accessTokenId) {
89+
return spaceList;
90+
}
91+
const accessToken = await this.permissionService.getAccessToken(accessTokenId);
92+
if (accessToken.hasFullAccess) {
93+
return spaceList;
94+
}
95+
if (!accessToken.spaceIds?.length) {
96+
return [];
97+
}
98+
return spaceList.filter((space) => accessToken.spaceIds.includes(space.id));
99+
}
100+
86101
async getSpaceList() {
87102
const userId = this.cls.get('user.id');
88103
const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
@@ -118,7 +133,8 @@ export class SpaceService {
118133
},
119134
{} as Record<string, { roleName: string; resourceId: string }>
120135
);
121-
return spaceList.map((space) => ({
136+
const filteredSpaceList = await this.filterSpaceListWithAccessToken(spaceList);
137+
return filteredSpaceList.map((space) => ({
122138
...space,
123139
role: roleMap[space.id].roleName as IRole,
124140
}));

apps/nestjs-backend/test/access-token.e2e-spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
CreateAccessTokenRo,
66
CreateAccessTokenVo,
77
ICreateSpaceVo,
8+
IGetSpaceVo,
89
ITableFullVo,
910
UpdateAccessTokenRo,
1011
} from '@teable/openapi';
@@ -31,6 +32,7 @@ import {
3132
deleteBase,
3233
getAccessToken,
3334
GET_BASE_ALL,
35+
GET_SPACE_LIST,
3436
} from '@teable/openapi';
3537
import dayjs from 'dayjs';
3638
import { createNewUserAxios } from './utils/axios-instance/new-user';
@@ -151,6 +153,7 @@ describe('OpenAPI AccessTokenController (e2e)', () => {
151153
let tableReadToken: string;
152154
let recordReadToken: string;
153155
let baseReadAllToken: string;
156+
let spaceReadToken: string;
154157
const axios = createAxios();
155158

156159
beforeAll(async () => {
@@ -173,6 +176,13 @@ describe('OpenAPI AccessTokenController (e2e)', () => {
173176
});
174177
baseReadAllToken = baseReadAllTokenData.token;
175178
axios.defaults.baseURL = defaultAxios.defaults.baseURL;
179+
180+
const { data: spaceReadTokenData } = await createAccessToken({
181+
...defaultCreateRo,
182+
name: 'space read token',
183+
scopes: ['space|read'],
184+
});
185+
spaceReadToken = spaceReadTokenData.token;
176186
});
177187

178188
it('get table list has table|read permission', async () => {
@@ -272,5 +282,58 @@ describe('OpenAPI AccessTokenController (e2e)', () => {
272282
expect(error?.status).toEqual(403);
273283
await newUserAxios.delete(urlBuilder(DELETE_SPACE, { spaceId }));
274284
});
285+
286+
it('get space list has space|read permission', async () => {
287+
const res = await axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST), {
288+
headers: {
289+
Authorization: `Bearer ${spaceReadToken}`,
290+
},
291+
});
292+
expect(res.status).toEqual(200);
293+
expect(res.data.map(({ id }) => id)).toEqual([spaceId]);
294+
});
295+
296+
it('get space list has not space|read permission', async () => {
297+
const error = await getError(() =>
298+
axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST), {
299+
headers: {
300+
Authorization: `Bearer ${tableReadToken}`,
301+
},
302+
})
303+
);
304+
expect(error?.status).toEqual(403);
305+
});
306+
307+
it('hasFullAccess', async () => {
308+
const space = await createSpace({ name: 'has full access space' }).then((res) => res.data);
309+
const { data: newAccessToken } = await createAccessToken({
310+
...defaultCreateRo,
311+
name: 'has full access token',
312+
scopes: ['space|read'],
313+
});
314+
const { data: fullAccessToken } = await createAccessToken({
315+
...defaultCreateRo,
316+
name: 'has full access token',
317+
scopes: ['space|read'],
318+
hasFullAccess: true,
319+
});
320+
const newAccessTokenRes = await axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST), {
321+
headers: {
322+
Authorization: `Bearer ${newAccessToken.token}`,
323+
},
324+
});
325+
const fullAccessTokenRes = await axios.get<IGetSpaceVo[]>(urlBuilder(GET_SPACE_LIST), {
326+
headers: {
327+
Authorization: `Bearer ${fullAccessToken.token}`,
328+
},
329+
});
330+
await permanentDeleteSpace(space.id);
331+
expect(newAccessTokenRes.status).toEqual(200);
332+
expect(newAccessTokenRes.data.map(({ id }) => id)).toEqual([spaceId]);
333+
expect(fullAccessTokenRes.status).toEqual(200);
334+
expect(fullAccessTokenRes.data.map(({ id }) => id)).toEqual(
335+
expect.arrayContaining([spaceId, space.id])
336+
);
337+
});
275338
});
276339
});

0 commit comments

Comments
 (0)