Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: organization permission #1208

Open
wants to merge 30 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bb1fcbc
feat: organzaition memeber selectors
boris-w Dec 23, 2024
f006198
feat: update collaborator migration
boris-w Dec 23, 2024
3167bc8
fix: update mirgration
boris-w Dec 23, 2024
569c39f
fix: member selector
boris-w Dec 24, 2024
b61aebd
refactor: search and pagination in collaborator endpoint
boris-w Dec 26, 2024
73c270a
feat: remove collaborator foreign key user
boris-w Dec 27, 2024
8471d3a
feat: add collaborator endpoint
boris-w Dec 27, 2024
b079f34
fix: user cell convert
boris-w Dec 27, 2024
626fd22
fix: member selector users list
boris-w Dec 30, 2024
d138708
feat: comment and user editor support adapting to new interface
boris-w Dec 31, 2024
3cbfe6f
refactor: department list remove unnecessary merge tree node
boris-w Dec 31, 2024
737ea27
fix: share e2e
boris-w Jan 2, 2025
c40dbb0
fix: sheet view coll query type
boris-w Jan 2, 2025
b210bf3
fix: some typescript check
boris-w Jan 2, 2025
4eb8513
fix: typescript error
boris-w Jan 2, 2025
bff443b
feat: search compatible sqlite
boris-w Jan 2, 2025
fa95d35
fix: comment reaction
boris-w Jan 2, 2025
f827876
feat: collaborator include department
boris-w Jan 3, 2025
c99437a
fix: collabortors resource
boris-w Jan 3, 2025
4938be3
fix: department collabortors update and delete
boris-w Jan 3, 2025
7483f53
feat: add collaborator entity validation before assistance
boris-w Jan 3, 2025
55aa43d
feat: add support for encoded URI paths as object keys
boris-w Jan 3, 2025
86c3d96
fix: comment full image url
boris-w Jan 3, 2025
3736055
fix: create space collaborator
boris-w Jan 3, 2025
5d9e192
fix: invitation service unit test
boris-w Jan 3, 2025
df42602
feat: custom member selector dialog header
boris-w Jan 7, 2025
b09fe4a
chore: remove useless code
boris-w Jan 7, 2025
4a1084e
fix: import date timezone
boris-w Jan 7, 2025
19b3708
test: wating event apply
boris-w Jan 7, 2025
0dafdfa
fix: permission service unit test
boris-w Jan 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/db-provider/db.provider.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,6 @@ export interface IDbProvider {
lookupOptionsQuery(optionsKey: keyof ILookupOptionsVo, value: string): string;

optionsQuery(type: FieldType, optionsKey: string, value: string): string;

searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder;
}
1 change: 0 additions & 1 deletion apps/nestjs-backend/src/db-provider/db.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export const DbProvider: Provider = {
provide: DB_PROVIDER_SYMBOL,
useFactory: (knex: Knex) => {
const driverClient = getDriverName(knex);

switch (driverClient) {
case DriverClient.Sqlite:
return new SqliteProvider(knex);
Expand Down
8 changes: 8 additions & 0 deletions apps/nestjs-backend/src/db-provider/postgres.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,4 +460,12 @@ export class PostgresProvider implements IDbProvider {
.where('type', type)
.toQuery();
}

searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder {
return qb.where((builder) => {
search.forEach(([field, value]) => {
builder.orWhere(field, 'ilike', `%${value}%`);
});
});
}
}
8 changes: 8 additions & 0 deletions apps/nestjs-backend/src/db-provider/sqlite.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,12 @@ export class SqliteProvider implements IDbProvider {
.whereRaw(`json_extract(options, '$."${optionsKey}"') = ?`, [value])
.toQuery();
}

searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder {
return qb.where((builder) => {
search.forEach(([field, value]) => {
builder.orWhereRaw('LOWER(??) LIKE LOWER(?)', [field, `%${value}%`]);
});
});
}
}
10 changes: 8 additions & 2 deletions apps/nestjs-backend/src/features/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import type { IUserMeVo } from '@teable/openapi';
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { AUTH_SESSION_COOKIE_NAME } from '../../const';
import type { IClsStore } from '../../types/cls';
import { AuthService } from './auth.service';
import { TokenAccess } from './decorators/token.decorator';
import { SessionService } from './session/session.service';
Expand All @@ -10,7 +12,8 @@ import { SessionService } from './session/session.service';
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly sessionService: SessionService
private readonly sessionService: SessionService,
private readonly cls: ClsService<IClsStore>
) {}

@Post('signout')
Expand All @@ -22,7 +25,10 @@ export class AuthController {

@Get('/user/me')
async me(@Req() request: Express.Request) {
return request.user;
return {
...request.user,
organization: this.cls.get('organization'),
};
}

@Get('/user')
Expand Down
13 changes: 6 additions & 7 deletions apps/nestjs-backend/src/features/auth/permission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,32 +43,31 @@ describe('PermissionService', () => {
it('should return a SpaceRole', async () => {
const spaceId = 'space-id';
const roleName = 'space-role';
prismaServiceMock.collaborator.findFirst.mockResolvedValue({ roleName } as any);
prismaServiceMock.collaborator.findMany.mockResolvedValue([{ roleName } as any]);
const result = await service['getRoleBySpaceId'](spaceId);
expect(result).toBe(roleName);
});

it('should throw a ForbiddenException if collaborator is not found', async () => {
const spaceId = 'space-id';
prismaServiceMock.collaborator.findFirst.mockResolvedValue(null);
await expect(service['getRoleBySpaceId'](spaceId)).rejects.toThrowError(
new ForbiddenException(`you have no permission to access this space`)
);
prismaServiceMock.collaborator.findMany.mockResolvedValue([]);
const res = await service['getRoleBySpaceId'](spaceId);
expect(res).toBeNull();
});
});

describe('getRoleByBaseId', () => {
it('should return a BaseRole', async () => {
const baseId = 'base-id';
const roleName = 'base-role';
prismaServiceMock.collaborator.findFirst.mockResolvedValue({ roleName } as any);
prismaServiceMock.collaborator.findMany.mockResolvedValue([{ roleName } as any]);
const result = await service['getRoleByBaseId'](baseId);
expect(result).toBe(roleName);
});

it('should return null if collaborator is not found', async () => {
const baseId = 'base-id';
prismaServiceMock.collaborator.findFirst.mockResolvedValue(null);
prismaServiceMock.collaborator.findMany.mockResolvedValue([]);
const result = await service['getRoleByBaseId'](baseId);
expect(result).toBeNull();
});
Expand Down
54 changes: 35 additions & 19 deletions apps/nestjs-backend/src/features/auth/permission.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ForbiddenException, NotFoundException, Injectable } from '@nestjs/common';
import type { IBaseRole, Action, IRole } from '@teable/core';
import type { IBaseRole, Action } from '@teable/core';
import { IdPrefix, getPermissions } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { CollaboratorType } from '@teable/openapi';
import { intersection } from 'lodash';
import { intersection, union } from 'lodash';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../types/cls';
import { getMaxLevelRole } from '../../utils/get-max-level-role';

@Injectable()
export class PermissionService {
Expand All @@ -14,51 +15,58 @@ export class PermissionService {
private readonly cls: ClsService<IClsStore>
) {}

private getDepartmentIds() {
const departments = this.cls.get('organization.departments');
return departments?.map((department) => department.id) || [];
}

async getRoleBySpaceId(spaceId: string) {
const userId = this.cls.get('user.id');

const collaborator = await this.prismaService.collaborator.findFirst({
const departmentIds = this.getDepartmentIds();
const collaborators = await this.prismaService.collaborator.findMany({
where: {
userId,
principalId: { in: [...departmentIds, userId] },
resourceId: spaceId,
resourceType: CollaboratorType.Space,
},
select: { roleName: true },
});
if (!collaborator) {
throw new ForbiddenException(`you have no permission to access this space`);
if (!collaborators.length) {
return null;
}
return collaborator.roleName as IRole;
return getMaxLevelRole(collaborators);
}

async getRoleByBaseId(baseId: string) {
const departmentIds = this.getDepartmentIds();
const userId = this.cls.get('user.id');

const collaborator = await this.prismaService.collaborator.findFirst({
const collaborators = await this.prismaService.collaborator.findMany({
where: {
userId,
principalId: { in: [...departmentIds, userId] },
resourceId: baseId,
resourceType: CollaboratorType.Base,
},
select: { roleName: true },
});
if (!collaborator) {
if (!collaborators.length) {
return null;
}
return collaborator.roleName as IBaseRole;
return getMaxLevelRole(collaborators) as IBaseRole;
}

async getOAuthAccessBy(userId: string) {
const collaborator = await this.prismaService.txClient().collaborator.findMany({
const departmentIds = this.getDepartmentIds();
const collaborators = await this.prismaService.txClient().collaborator.findMany({
where: {
userId,
principalId: { in: [...departmentIds, userId] },
},
select: { roleName: true, resourceId: true, resourceType: true },
});

const spaceIds: string[] = [];
const baseIds: string[] = [];
collaborator.forEach(({ resourceId, resourceType }) => {
collaborators.forEach(({ resourceId, resourceType }) => {
if (resourceType === CollaboratorType.Base) {
baseIds.push(resourceId);
} else if (resourceType === CollaboratorType.Space) {
Expand Down Expand Up @@ -205,17 +213,25 @@ export class PermissionService {

private async getPermissionBySpaceId(spaceId: string) {
const role = await this.getRoleBySpaceId(spaceId);
if (!role) {
throw new ForbiddenException(`you have no permission to access this space`);
}
return getPermissions(role);
}

private async getPermissionByBaseId(baseId: string, includeInactiveResource?: boolean) {
const role = await this.getRoleByBaseId(baseId);
if (role) {
return getPermissions(role);
}
return this.getPermissionBySpaceId(
const spaceRole = await this.getRoleBySpaceId(
(await this.getUpperIdByBaseId(baseId, includeInactiveResource)).spaceId
);
if (!role && !spaceRole) {
throw new ForbiddenException(`you have no permission to access this base`);
}
const basePermissions = role ? getPermissions(role) : [];
const spacePermissions = spaceRole ? getPermissions(spaceRole) : [];
// In the presence of an organization, a user can have concurrent permissions at both space and base levels,
// requiring a merge operation to determine the highest applicable permission level
return union(basePermissions, spacePermissions);
}

private async getPermissionByTableId(tableId: string, includeInactiveResource?: boolean) {
Expand Down
26 changes: 21 additions & 5 deletions apps/nestjs-backend/src/features/base/base.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
CollaboratorType,
listBaseCollaboratorRoSchema,
ListBaseCollaboratorRo,
deleteBaseCollaboratorRoSchema,
DeleteBaseCollaboratorRo,
addBaseCollaboratorRoSchema,
AddBaseCollaboratorRo,
} from '@teable/openapi';
import type {
CreateBaseInvitationLinkVo,
Expand Down Expand Up @@ -163,7 +167,10 @@ export class BaseController {
@Param('baseId') baseId: string,
@Query(new ZodValidationPipe(listBaseCollaboratorRoSchema)) options: ListBaseCollaboratorRo
): Promise<ListBaseCollaboratorVo> {
return await this.collaboratorService.getListByBase(baseId, options);
return {
collaborators: await this.collaboratorService.getListByBase(baseId, options),
total: await this.collaboratorService.getTotalBase(baseId, options),
};
}

@Permissions('base|read')
Expand Down Expand Up @@ -259,25 +266,34 @@ export class BaseController {
await this.collaboratorService.updateCollaborator({
resourceId: baseId,
resourceType: CollaboratorType.Base,
userId: updateBaseCollaborateRo.userId,
role: updateBaseCollaborateRo.role,
...updateBaseCollaborateRo,
});
}

@Delete(':baseId/collaborators')
async deleteCollaborator(
@Param('baseId') baseId: string,
@Query('userId') userId: string
@Query(new ZodValidationPipe(deleteBaseCollaboratorRoSchema))
deleteBaseCollaboratorRo: DeleteBaseCollaboratorRo
): Promise<void> {
await this.collaboratorService.deleteCollaborator({
resourceId: baseId,
resourceType: CollaboratorType.Base,
userId,
...deleteBaseCollaboratorRo,
});
}

@Delete(':baseId/permanent')
async permanentDeleteBase(@Param('baseId') baseId: string) {
return await this.baseService.permanentDeleteBase(baseId);
}

@Post(':baseId/collaborator')
async addCollaborators(
@Param('baseId') baseId: string,
@Body(new ZodValidationPipe(addBaseCollaboratorRoSchema))
addBaseCollaboratorRo: AddBaseCollaboratorRo
) {
return await this.collaboratorService.addBaseCollaborators(baseId, addBaseCollaboratorRo);
}
}
36 changes: 18 additions & 18 deletions apps/nestjs-backend/src/features/base/base.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { IRole } from '@teable/core';
import { ActionPrefix, actionPrefixMap, generateBaseId, isUnrestrictedRole } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { CollaboratorType, ResourceType } from '@teable/openapi';
import { CollaboratorType, PrincipalType, ResourceType } from '@teable/openapi';
import type {
ICreateBaseFromTemplateRo,
ICreateBaseRo,
Expand All @@ -16,6 +15,7 @@ import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.confi
import { InjectDbProvider } from '../../db-provider/db.provider';
import { IDbProvider } from '../../db-provider/db.provider.interface';
import type { IClsStore } from '../../types/cls';
import { getMaxLevelRole } from '../../utils/get-max-level-role';
import { updateOrder } from '../../utils/update-order';
import { PermissionService } from '../auth/permission.service';
import { CollaboratorService } from '../collaborator/collaborator.service';
Expand All @@ -39,7 +39,7 @@ export class BaseService {

async getBaseById(baseId: string) {
const userId = this.cls.get('user.id');

const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
const base = await this.prismaService.base
.findFirstOrThrow({
select: {
Expand All @@ -56,30 +56,30 @@ export class BaseService {
.catch(() => {
throw new NotFoundException('Base not found');
});
const collaborator = await this.prismaService.collaborator
.findFirstOrThrow({
where: {
resourceId: { in: [baseId, base.spaceId] },
userId,
},
})
.catch(() => {
throw new ForbiddenException('cannot access base');
});
const collaborators = await this.prismaService.collaborator.findMany({
where: {
resourceId: { in: [baseId, base.spaceId] },
principalId: { in: [userId, ...(departmentIds || [])] },
principalType: PrincipalType.User,
},
});

const role = collaborator.roleName as IRole;
if (!collaborators.length) {
throw new ForbiddenException('cannot access base');
}
const role = getMaxLevelRole(collaborators);
const collaborator = collaborators.find((c) => c.roleName === role);
return {
...base,
role: role,
collaboratorType: collaborator.resourceType as CollaboratorType,
collaboratorType: collaborator?.resourceType as CollaboratorType,
isUnrestricted: isUnrestrictedRole(role),
};
}

async getAllBaseList() {
const userId = this.cls.get('user.id');
const { spaceIds, baseIds, roleMap } =
await this.collaboratorService.getCollaboratorsBaseAndSpaceArray(userId);
await this.collaboratorService.getCurrentUserCollaboratorsBaseAndSpaceArray();
const baseList = await this.prismaService.base.findMany({
select: {
id: true,
Expand Down Expand Up @@ -112,7 +112,7 @@ export class BaseService {
const userId = this.cls.get('user.id');
const accessTokenId = this.cls.get('accessTokenId');
const { spaceIds, baseIds } =
await this.collaboratorService.getCollaboratorsBaseAndSpaceArray(userId);
await this.collaboratorService.getCurrentUserCollaboratorsBaseAndSpaceArray();

if (accessTokenId) {
const access = await this.prismaService.accessToken.findFirst({
Expand Down
Loading
Loading