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

Merged
merged 35 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
fd34108
feat: organzaition memeber selectors
boris-w Dec 23, 2024
a6a3b9f
feat: update collaborator migration
boris-w Dec 23, 2024
547e18d
fix: update mirgration
boris-w Dec 23, 2024
24880ef
fix: member selector
boris-w Dec 24, 2024
42387ef
refactor: search and pagination in collaborator endpoint
boris-w Dec 26, 2024
94665ab
feat: remove collaborator foreign key user
boris-w Dec 27, 2024
ff6952d
feat: add collaborator endpoint
boris-w Dec 27, 2024
b80eca4
fix: user cell convert
boris-w Dec 27, 2024
63caa1a
fix: member selector users list
boris-w Dec 30, 2024
835f58a
feat: comment and user editor support adapting to new interface
boris-w Dec 31, 2024
37a91bf
refactor: department list remove unnecessary merge tree node
boris-w Dec 31, 2024
e9292e0
fix: share e2e
boris-w Jan 2, 2025
6e289b9
fix: sheet view coll query type
boris-w Jan 2, 2025
20b9980
fix: some typescript check
boris-w Jan 2, 2025
308a262
fix: typescript error
boris-w Jan 2, 2025
aac26e9
feat: search compatible sqlite
boris-w Jan 2, 2025
50dab3e
fix: comment reaction
boris-w Jan 2, 2025
c920176
feat: collaborator include department
boris-w Jan 3, 2025
04ee0f6
fix: collabortors resource
boris-w Jan 3, 2025
f6690b3
fix: department collabortors update and delete
boris-w Jan 3, 2025
ba2c52d
feat: add collaborator entity validation before assistance
boris-w Jan 3, 2025
7e97ed7
feat: add support for encoded URI paths as object keys
boris-w Jan 3, 2025
3e2a65b
fix: comment full image url
boris-w Jan 3, 2025
c48d849
fix: create space collaborator
boris-w Jan 3, 2025
4fbf5b7
fix: invitation service unit test
boris-w Jan 3, 2025
9886a8a
feat: custom member selector dialog header
boris-w Jan 7, 2025
84e6980
chore: remove useless code
boris-w Jan 7, 2025
8f0b89f
fix: import date timezone
boris-w Jan 7, 2025
6dbd80b
test: wating event apply
boris-w Jan 7, 2025
7a2052a
fix: permission service unit test
boris-w Jan 7, 2025
235bd7b
fix: departments collaborators permisison
boris-w Jan 8, 2025
42ce460
chore: remove useless import code
boris-w Jan 8, 2025
4748883
feat: the first registered user will be the admin
boris-w Jan 8, 2025
f21f1ed
feat: update organization panel icons
boris-w Jan 9, 2025
996c93e
chore: update empty state text for department selector
boris-w Jan 10, 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);
}
}
33 changes: 16 additions & 17 deletions apps/nestjs-backend/src/features/base/base.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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';
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,29 @@ 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 || [])] },
},
});

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 +111,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