diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 47d8edde1c8d5..948bb89194caf 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -141,7 +141,7 @@ model WorkspaceUserPermission { id String @id @default(uuid()) @db.VarChar workspaceId String @map("workspace_id") @db.VarChar userId String @map("user_id") @db.VarChar - // Read/Write + // Workspace Role, Owner/Admin/Collaborator/External type Int @db.SmallInt /// @deprecated Whether the permission invitation is accepted by the user accepted Boolean @default(false) @@ -165,7 +165,7 @@ model WorkspacePageUserPermission { workspaceId String @map("workspace_id") @db.VarChar pageId String @map("page_id") @db.VarChar userId String @map("user_id") @db.VarChar - // Read/Write + // External/Reader/Editor/Manager/Owner type Int @db.SmallInt /// Whether the permission invitation is accepted by the user accepted Boolean @default(false) diff --git a/packages/backend/server/src/__tests__/models/page.spec.ts b/packages/backend/server/src/__tests__/models/page.spec.ts index 1d3a35da5f697..5974c86facdcc 100644 --- a/packages/backend/server/src/__tests__/models/page.spec.ts +++ b/packages/backend/server/src/__tests__/models/page.spec.ts @@ -3,7 +3,8 @@ import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; import { Config } from '../../base/config'; -import { Permission, PublicPageMode } from '../../models/common'; +import { WorkspaceRole } from '../../core/permission'; +import { PublicPageMode } from '../../models/common'; import { PageModel } from '../../models/page'; import { type User, UserModel } from '../../models/user'; import { type Workspace, WorkspaceModel } from '../../models/workspace'; @@ -131,7 +132,7 @@ test('should grant a member to access a page', async t => { workspace.id, 'page1', user.id, - Permission.Write + WorkspaceRole.Collaborator ); t.false(hasAccess); // grant write permission @@ -139,20 +140,20 @@ test('should grant a member to access a page', async t => { workspace.id, 'page1', user.id, - Permission.Write + WorkspaceRole.Collaborator ); hasAccess = await t.context.page.isMember( workspace.id, 'page1', user.id, - Permission.Write + WorkspaceRole.Collaborator ); t.true(hasAccess); hasAccess = await t.context.page.isMember( workspace.id, 'page1', user.id, - Permission.Read + WorkspaceRole.Collaborator ); t.true(hasAccess); // delete member @@ -174,14 +175,14 @@ test('should change the page owner', async t => { workspace.id, 'page1', user.id, - Permission.Owner + WorkspaceRole.Owner ); t.true( await t.context.page.isMember( workspace.id, 'page1', user.id, - Permission.Owner + WorkspaceRole.Owner ) ); @@ -193,14 +194,14 @@ test('should change the page owner', async t => { workspace.id, 'page1', otherUser.id, - Permission.Owner + WorkspaceRole.Owner ); t.true( await t.context.page.isMember( workspace.id, 'page1', otherUser.id, - Permission.Owner + WorkspaceRole.Owner ) ); t.false( @@ -208,7 +209,7 @@ test('should change the page owner', async t => { workspace.id, 'page1', user.id, - Permission.Owner + WorkspaceRole.Owner ) ); }); @@ -221,7 +222,7 @@ test('should not delete owner from page', async t => { workspace.id, 'page1', user.id, - Permission.Owner + WorkspaceRole.Owner ); const count = await t.context.page.deleteMember( workspace.id, diff --git a/packages/backend/server/src/__tests__/models/user.spec.ts b/packages/backend/server/src/__tests__/models/user.spec.ts index a12d5f0641473..0947ce40412a8 100644 --- a/packages/backend/server/src/__tests__/models/user.spec.ts +++ b/packages/backend/server/src/__tests__/models/user.spec.ts @@ -4,7 +4,7 @@ import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; import { EmailAlreadyUsed, EventBus } from '../../base'; -import { Permission } from '../../models/common'; +import { WorkspaceRole } from '../../core/permission'; import { UserModel } from '../../models/user'; import { WorkspaceMemberStatus } from '../../models/workspace'; import { createTestingModule, initTestingDB } from '../utils'; @@ -263,7 +263,7 @@ test('should trigger user.deleted event', async t => { public: false, }, }, - type: Permission.Owner, + type: WorkspaceRole.Owner, status: WorkspaceMemberStatus.Accepted, }, }, diff --git a/packages/backend/server/src/__tests__/models/workspace.spec.ts b/packages/backend/server/src/__tests__/models/workspace.spec.ts index fccb5acf0afdd..32ae94637f3d4 100644 --- a/packages/backend/server/src/__tests__/models/workspace.spec.ts +++ b/packages/backend/server/src/__tests__/models/workspace.spec.ts @@ -4,7 +4,7 @@ import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; import { Config, EventBus } from '../../base'; -import { Permission } from '../../models/common'; +import { WorkspaceRole } from '../../core/permission'; import { UserModel } from '../../models/user'; import { WorkspaceModel } from '../../models/workspace'; import { createTestingModule, initTestingDB } from '../utils'; @@ -92,25 +92,25 @@ test('should workspace owner has all permissions', async t => { let allowed = await t.context.workspace.isMember( workspace.id, user.id, - Permission.Owner + WorkspaceRole.Owner ); t.is(allowed, true); allowed = await t.context.workspace.isMember( workspace.id, user.id, - Permission.Admin + WorkspaceRole.Admin ); t.is(allowed, true); allowed = await t.context.workspace.isMember( workspace.id, user.id, - Permission.Write + WorkspaceRole.Collaborator ); t.is(allowed, true); allowed = await t.context.workspace.isMember( workspace.id, user.id, - Permission.Read + WorkspaceRole.Collaborator ); t.is(allowed, true); }); @@ -127,32 +127,32 @@ test('should workspace admin has all permissions except owner', async t => { data: { workspaceId: workspace.id, userId: otherUser.id, - type: Permission.Admin, + type: WorkspaceRole.Admin, status: WorkspaceMemberStatus.Accepted, }, }); let allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Owner + WorkspaceRole.Owner ); t.is(allowed, false); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Admin + WorkspaceRole.Admin ); t.is(allowed, true); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Write + WorkspaceRole.Collaborator ); t.is(allowed, true); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Read + WorkspaceRole.Collaborator ); t.is(allowed, true); }); @@ -169,32 +169,32 @@ test('should workspace write has write and read permissions', async t => { data: { workspaceId: workspace.id, userId: otherUser.id, - type: Permission.Write, + type: WorkspaceRole.Collaborator, status: WorkspaceMemberStatus.Accepted, }, }); let allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Owner + WorkspaceRole.Owner ); t.is(allowed, false); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Admin + WorkspaceRole.Admin ); t.is(allowed, false); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Write + WorkspaceRole.Collaborator ); t.is(allowed, true); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Read + WorkspaceRole.Collaborator ); t.is(allowed, true); }); @@ -211,32 +211,26 @@ test('should workspace read has read permission only', async t => { data: { workspaceId: workspace.id, userId: otherUser.id, - type: Permission.Read, + type: WorkspaceRole.Collaborator, status: WorkspaceMemberStatus.Accepted, }, }); let allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Owner + WorkspaceRole.Owner ); t.is(allowed, false); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Admin + WorkspaceRole.Admin ); t.is(allowed, false); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Write - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - Permission.Read + WorkspaceRole.Collaborator ); t.is(allowed, true); }); @@ -252,25 +246,25 @@ test('should user not in workspace has no permissions', async t => { let allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Owner + WorkspaceRole.Owner ); t.is(allowed, false); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Admin + WorkspaceRole.Admin ); t.is(allowed, false); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Write + WorkspaceRole.Collaborator ); t.is(allowed, false); allowed = await t.context.workspace.isMember( workspace.id, otherUser.id, - Permission.Read + WorkspaceRole.Collaborator ); t.is(allowed, false); }); @@ -313,7 +307,7 @@ test('should grant member with read permission and Pending status by default', a ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.Pending); // grant again should do nothing @@ -344,18 +338,18 @@ test('should grant Pending status member to Accepted status', async t => { ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.Pending); const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); t.is(member2.workspaceId, workspace.id); t.is(member2.userId, otherUser.id); - t.is(member2.type, Permission.Read); + t.is(member2.type, WorkspaceRole.Collaborator); t.is(member2.status, WorkspaceMemberStatus.Accepted); }); @@ -370,27 +364,27 @@ test('should grant new owner and change exists owner to admin', async t => { const member1 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.Accepted); const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Owner, + WorkspaceRole.Owner, WorkspaceMemberStatus.Accepted ); t.is(member2.workspaceId, workspace.id); t.is(member2.userId, otherUser.id); - t.is(member2.type, Permission.Owner); + t.is(member2.type, WorkspaceRole.Owner); t.is(member2.status, WorkspaceMemberStatus.Accepted); // check old owner const owner = await t.context.workspace.getMember(workspace.id, user.id); - t.is(owner!.type, Permission.Admin); + t.is(owner!.type, WorkspaceRole.Admin); t.is(owner!.status, WorkspaceMemberStatus.Accepted); }); @@ -405,23 +399,23 @@ test('should grant write permission on exists member', async t => { const member1 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.Accepted); const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Write, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); t.is(member2.workspaceId, workspace.id); t.is(member2.userId, otherUser.id); - t.is(member2.type, Permission.Write); + t.is(member2.type, WorkspaceRole.Collaborator); t.is(member2.status, WorkspaceMemberStatus.Accepted); }); @@ -436,23 +430,23 @@ test('should grant UnderReview status member to Accepted status', async t => { const member1 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.UnderReview ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.UnderReview); const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); t.is(member2.workspaceId, workspace.id); t.is(member2.userId, otherUser.id); - t.is(member2.type, Permission.Read); + t.is(member2.type, WorkspaceRole.Collaborator); t.is(member2.status, WorkspaceMemberStatus.Accepted); }); @@ -467,23 +461,23 @@ test('should grant NeedMoreSeat status member to Pending status', async t => { const member1 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.NeedMoreSeat ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeat); const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Pending ); t.is(member2.workspaceId, workspace.id); t.is(member2.userId, otherUser.id); - t.is(member2.type, Permission.Read); + t.is(member2.type, WorkspaceRole.Collaborator); t.is(member2.status, WorkspaceMemberStatus.Pending); }); @@ -498,23 +492,23 @@ test('should grant NeedMoreSeatAndReview status member to UnderReview status', a const member1 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.NeedMoreSeatAndReview ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeatAndReview); const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.UnderReview ); t.is(member2.workspaceId, workspace.id); t.is(member2.userId, otherUser.id); - t.is(member2.type, Permission.Read); + t.is(member2.type, WorkspaceRole.Collaborator); t.is(member2.status, WorkspaceMemberStatus.UnderReview); }); @@ -532,19 +526,19 @@ test('should grant Pending status member to write permission and Accepted status ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.Pending); const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Write, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); t.is(member2.workspaceId, workspace.id); t.is(member2.userId, otherUser.id); // TODO(fengmk2): fix this - // t.is(member2.type, Permission.Write); + // t.is(member2.type, WorkspaceRole.Collaborator); t.is(member2.status, WorkspaceMemberStatus.Accepted); }); @@ -559,23 +553,23 @@ test('should grant no thing on invalid status', async t => { const member1 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.NeedMoreSeat ); t.is(member1.workspaceId, workspace.id); t.is(member1.userId, otherUser.id); - t.is(member1.type, Permission.Read); + t.is(member1.type, WorkspaceRole.Collaborator); t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeat); const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); t.is(member2.workspaceId, workspace.id); t.is(member2.userId, otherUser.id); - t.is(member2.type, Permission.Read); + t.is(member2.type, WorkspaceRole.Collaborator); t.is(member2.status, WorkspaceMemberStatus.NeedMoreSeat); }); @@ -590,7 +584,7 @@ test('should get the accepted status workspace member', async t => { await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); const member = await t.context.workspace.getMember( @@ -599,7 +593,7 @@ test('should get the accepted status workspace member', async t => { ); t.is(member!.workspaceId, workspace.id); t.is(member!.userId, otherUser.id); - t.is(member!.type, Permission.Read); + t.is(member!.type, WorkspaceRole.Collaborator); t.is(member!.status, WorkspaceMemberStatus.Accepted); }); @@ -614,7 +608,7 @@ test('should get any status workspace member, including pending and accepted', a await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Pending ); const member = await t.context.workspace.getMemberInAnyStatus( @@ -623,7 +617,7 @@ test('should get any status workspace member, including pending and accepted', a ); t.is(member!.workspaceId, workspace.id); t.is(member!.userId, otherUser.id); - t.is(member!.type, Permission.Read); + t.is(member!.type, WorkspaceRole.Collaborator); t.is(member!.status, WorkspaceMemberStatus.Pending); }); @@ -635,7 +629,7 @@ test('should get workspace owner by workspace id', async t => { const owner = await t.context.workspace.getOwner(workspace.id); t.is(owner!.workspaceId, workspace.id); t.is(owner!.userId, user.id); - t.is(owner!.type, Permission.Owner); + t.is(owner!.type, WorkspaceRole.Owner); t.is(owner!.status, WorkspaceMemberStatus.Accepted); t.truthy(owner!.user); t.deepEqual(owner!.user, user); @@ -658,27 +652,27 @@ test('should find workspace admin by workspace id', async t => { await t.context.workspace.grantMember( workspace.id, otherUser1.id, - Permission.Admin, + WorkspaceRole.Admin, WorkspaceMemberStatus.Accepted ); await t.context.workspace.grantMember( workspace.id, otherUser2.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); // pending member should not be admin await t.context.workspace.grantMember( workspace.id, otherUser3.id, - Permission.Admin, + WorkspaceRole.Admin, WorkspaceMemberStatus.Pending ); const members = await t.context.workspace.findAdmins(workspace.id); t.is(members.length, 1); t.is(members[0].workspaceId, workspace.id); t.is(members[0].userId, otherUser1.id); - t.is(members[0].type, Permission.Admin); + t.is(members[0].type, WorkspaceRole.Admin); t.is(members[0].status, WorkspaceMemberStatus.Accepted); }); @@ -710,13 +704,13 @@ test('should the workspace member total count, including pending and accepted', await t.context.workspace.grantMember( workspace.id, otherUser1.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Pending ); await t.context.workspace.grantMember( workspace.id, otherUser2.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); const count = await t.context.workspace.getMemberTotalCount(workspace.id); @@ -737,13 +731,13 @@ test('should the workspace member used count, only count the accepted member', a await t.context.workspace.grantMember( workspace.id, otherUser1.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Pending ); await t.context.workspace.grantMember( workspace.id, otherUser2.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); const count = await t.context.workspace.getMemberUsedCount(workspace.id); @@ -855,7 +849,7 @@ test('should delete workspace member in Pending, Accepted status', async t => { const member2 = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); t.is(member2.status, WorkspaceMemberStatus.Accepted); @@ -874,7 +868,7 @@ test('should trigger workspace.members.requestDeclined event when delete workspa const member = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.UnderReview ); t.is(member.status, WorkspaceMemberStatus.UnderReview); @@ -919,7 +913,7 @@ test('should trigger workspace.members.requestDeclined event when delete workspa const member = await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.NeedMoreSeatAndReview ); t.is(member.status, WorkspaceMemberStatus.NeedMoreSeatAndReview); @@ -970,19 +964,19 @@ test('should refresh member seat status', async t => { await t.context.workspace.grantMember( workspace.id, otherUser1.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.NeedMoreSeatAndReview ); await t.context.workspace.grantMember( workspace.id, otherUser2.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Pending ); await t.context.workspace.grantMember( workspace.id, otherUser3.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.NeedMoreSeat ); let count = await t.context.db.workspaceUserPermission.count({ @@ -1043,30 +1037,30 @@ test('should find the workspace members order by type:desc and createdAt:asc', a await t.context.workspace.grantMember( workspace.id, otherUser.id, - Permission.Read, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); } let members = await t.context.workspace.findMembers(workspace.id); t.is(members.length, 8); - t.is(members[0].type, Permission.Owner); + t.is(members[0].type, WorkspaceRole.Owner); t.is(members[0].status, WorkspaceMemberStatus.Accepted); for (let i = 1; i < 8; i++) { - t.is(members[i].type, Permission.Read); + t.is(members[i].type, WorkspaceRole.Collaborator); t.is(members[i].status, WorkspaceMemberStatus.Accepted); } members = await t.context.workspace.findMembers(workspace.id, { take: 100 }); t.is(members.length, 11); - t.is(members[0].type, Permission.Owner); + t.is(members[0].type, WorkspaceRole.Owner); t.is(members[0].status, WorkspaceMemberStatus.Accepted); for (let i = 1; i < 11; i++) { - t.is(members[i].type, Permission.Read); + t.is(members[i].type, WorkspaceRole.Collaborator); t.is(members[i].status, WorkspaceMemberStatus.Accepted); } // skip should work members = await t.context.workspace.findMembers(workspace.id, { skip: 5 }); t.is(members.length, 6); - t.is(members[0].type, Permission.Read); + t.is(members[0].type, WorkspaceRole.Collaborator); }); test('should get the workspace member invitation', async t => { diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts index 87c0c68c226ca..1cad17c64e13e 100644 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ b/packages/backend/server/src/__tests__/team.e2e.ts @@ -13,7 +13,7 @@ import { AppModule } from '../app.module'; import { EventBus } from '../base'; import { AuthService } from '../core/auth'; import { DocContentService } from '../core/doc-renderer'; -import { Permission, PermissionService } from '../core/permission'; +import { PermissionService, WorkspaceRole } from '../core/permission'; import { QuotaManagementService, QuotaService, QuotaType } from '../core/quota'; import { WorkspaceType } from '../core/workspaces'; import { @@ -105,7 +105,7 @@ const init = async ( const invite = async ( email: string, - permission: PermissionEnum = 'Write', + permission: PermissionEnum = 'Collaborator', shouldSendEmail: boolean = false ) => { const member = await signUp(app, email.split('@')[0], email, '123456'); @@ -195,7 +195,7 @@ const init = async ( const admin = await invite(`${prefix}admin@affine.pro`, 'Admin'); const write = await invite(`${prefix}write@affine.pro`); - const read = await invite(`${prefix}read@affine.pro`, 'Read'); + const read = await invite(`${prefix}read@affine.pro`, 'Collaborator'); return { invite, @@ -268,7 +268,7 @@ test('should be able to check seat limit', async t => { { // invite await t.throwsAsync( - invite('member3@affine.pro', 'Read'), + invite('member3@affine.pro', 'Collaborator'), { message: 'You have exceeded your workspace member quota.' }, 'should throw error if exceed member limit' ); @@ -276,7 +276,7 @@ test('should be able to check seat limit', async t => { memberLimit: 5, }); await t.notThrowsAsync( - invite('member4@affine.pro', 'Read'), + invite('member4@affine.pro', 'Collaborator'), 'should not throw error if not exceed member limit' ); } @@ -324,17 +324,17 @@ test('should be able to grant team member permission', async t => { const { owner, teamWorkspace: ws, admin, write, read } = await init(app); await t.throwsAsync( - grantMember(app, read.token.token, ws.id, write.id, 'Write'), + grantMember(app, read.token.token, ws.id, write.id, 'Collaborator'), { instanceOf: Error }, 'should throw error if not owner' ); await t.throwsAsync( - grantMember(app, write.token.token, ws.id, read.id, 'Write'), + grantMember(app, write.token.token, ws.id, read.id, 'Collaborator'), { instanceOf: Error }, 'should throw error if not owner' ); await t.throwsAsync( - grantMember(app, admin.token.token, ws.id, read.id, 'Write'), + grantMember(app, admin.token.token, ws.id, read.id, 'Collaborator'), { instanceOf: Error }, 'should throw error if not owner' ); @@ -342,7 +342,11 @@ test('should be able to grant team member permission', async t => { { // owner should be able to grant permission t.true( - await permissions.tryCheckWorkspaceIs(ws.id, read.id, Permission.Read), + await permissions.tryCheckWorkspaceIs( + ws.id, + read.id, + WorkspaceRole.Collaborator + ), 'should be able to check permission' ); t.truthy( @@ -350,7 +354,11 @@ test('should be able to grant team member permission', async t => { 'should be able to grant permission' ); t.true( - await permissions.tryCheckWorkspaceIs(ws.id, read.id, Permission.Admin), + await permissions.tryCheckWorkspaceIs( + ws.id, + read.id, + WorkspaceRole.Admin + ), 'should be able to check permission' ); } @@ -697,7 +705,11 @@ test('should be able to emit events', async t => { event.emit.lastCall.args, [ 'workspace.members.roleChanged', - { userId: read.id, workspaceId: tws.id, permission: Permission.Admin }, + { + userId: read.id, + workspaceId: tws.id, + permission: WorkspaceRole.Admin, + }, ], 'should emit role changed event' ); diff --git a/packages/backend/server/src/__tests__/utils/feature.ts b/packages/backend/server/src/__tests__/utils/feature.ts index af728f6df075b..c345a9df8eb21 100644 --- a/packages/backend/server/src/__tests__/utils/feature.ts +++ b/packages/backend/server/src/__tests__/utils/feature.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; -import { Permission } from '../../core/permission'; +import { WorkspaceRole } from '../../core/permission'; import { UserType } from '../../core/user/types'; @Injectable() @@ -14,7 +14,7 @@ export class WorkspaceResolverMock { public: false, permissions: { create: { - type: Permission.Owner, + type: WorkspaceRole.Owner, userId: user.id, accepted: true, status: WorkspaceMemberStatus.Accepted, diff --git a/packages/backend/server/src/__tests__/utils/utils.ts b/packages/backend/server/src/__tests__/utils/utils.ts index d1261af230bfd..40903abf88026 100644 --- a/packages/backend/server/src/__tests__/utils/utils.ts +++ b/packages/backend/server/src/__tests__/utils/utils.ts @@ -15,7 +15,7 @@ import { AuthGuard, AuthModule } from '../../core/auth'; import { UserFeaturesInit1698652531198 } from '../../data/migrations/1698652531198-user-features-init'; import { ModelsModule } from '../../models'; -export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read'; +export type PermissionEnum = 'Owner' | 'Admin' | 'Collaborator' | 'External'; async function flushDB(client: PrismaClient) { const result: { tablename: string }[] = diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 4ad588e6d0d5a..2c1b85e3ea012 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -363,6 +363,11 @@ export const USER_FRIENDLY_ERRORS = { }, // Workspace & Userspace & Doc & Sync errors + workspace_permission_not_found: { + type: 'internal_server_error', + args: { spaceId: 'string' }, + message: ({ spaceId }) => `Space ${spaceId} permission not found.`, + }, space_not_found: { type: 'resource_not_found', args: { spaceId: 'string' }, @@ -395,6 +400,11 @@ export const USER_FRIENDLY_ERRORS = { args: { spaceId: 'string' }, message: ({ spaceId }) => `Owner of Space ${spaceId} not found.`, }, + space_should_have_only_one_owner: { + type: 'invalid_input', + args: { spaceId: 'string' }, + message: 'Space should have only one owner.', + }, doc_not_found: { type: 'resource_not_found', args: { spaceId: 'string', docId: 'string' }, @@ -438,6 +448,24 @@ export const USER_FRIENDLY_ERRORS = { type: 'invalid_input', message: 'Expected to revoke a public page, not a Space.', }, + expect_to_grant_doc_user_roles: { + type: 'invalid_input', + args: { spaceId: 'string', docId: 'string' }, + message: ({ spaceId, docId }) => + `Expect grant roles on doc ${docId} under Space ${spaceId}, not a Space.`, + }, + expect_to_revoke_doc_user_roles: { + type: 'invalid_input', + args: { spaceId: 'string', docId: 'string' }, + message: ({ spaceId, docId }) => + `Expect revoke roles on doc ${docId} under Space ${spaceId}, not a Space.`, + }, + expect_to_update_doc_user_role: { + type: 'invalid_input', + args: { spaceId: 'string', docId: 'string' }, + message: ({ spaceId, docId }) => + `Expect update roles on doc ${docId} under Space ${spaceId}, not a Space.`, + }, page_is_not_public: { type: 'bad_request', message: 'Page is not public.', diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index 2d141f77abf61..e578cd9fc8151 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -191,6 +191,16 @@ export class EmailVerificationRequired extends UserFriendlyError { } } @ObjectType() +class WorkspacePermissionNotFoundDataType { + @Field() spaceId!: string +} + +export class WorkspacePermissionNotFound extends UserFriendlyError { + constructor(args: WorkspacePermissionNotFoundDataType, message?: string | ((args: WorkspacePermissionNotFoundDataType) => string)) { + super('internal_server_error', 'workspace_permission_not_found', message, args); + } +} +@ObjectType() class SpaceNotFoundDataType { @Field() spaceId!: string } @@ -251,6 +261,16 @@ export class SpaceOwnerNotFound extends UserFriendlyError { } } @ObjectType() +class SpaceShouldHaveOnlyOneOwnerDataType { + @Field() spaceId!: string +} + +export class SpaceShouldHaveOnlyOneOwner extends UserFriendlyError { + constructor(args: SpaceShouldHaveOnlyOneOwnerDataType, message?: string | ((args: SpaceShouldHaveOnlyOneOwnerDataType) => string)) { + super('invalid_input', 'space_should_have_only_one_owner', message, args); + } +} +@ObjectType() class DocNotFoundDataType { @Field() spaceId!: string @Field() docId!: string @@ -328,6 +348,39 @@ export class ExpectToRevokePublicPage extends UserFriendlyError { super('invalid_input', 'expect_to_revoke_public_page', message); } } +@ObjectType() +class ExpectToGrantDocUserRolesDataType { + @Field() spaceId!: string + @Field() docId!: string +} + +export class ExpectToGrantDocUserRoles extends UserFriendlyError { + constructor(args: ExpectToGrantDocUserRolesDataType, message?: string | ((args: ExpectToGrantDocUserRolesDataType) => string)) { + super('invalid_input', 'expect_to_grant_doc_user_roles', message, args); + } +} +@ObjectType() +class ExpectToRevokeDocUserRolesDataType { + @Field() spaceId!: string + @Field() docId!: string +} + +export class ExpectToRevokeDocUserRoles extends UserFriendlyError { + constructor(args: ExpectToRevokeDocUserRolesDataType, message?: string | ((args: ExpectToRevokeDocUserRolesDataType) => string)) { + super('invalid_input', 'expect_to_revoke_doc_user_roles', message, args); + } +} +@ObjectType() +class ExpectToUpdateDocUserRoleDataType { + @Field() spaceId!: string + @Field() docId!: string +} + +export class ExpectToUpdateDocUserRole extends UserFriendlyError { + constructor(args: ExpectToUpdateDocUserRoleDataType, message?: string | ((args: ExpectToUpdateDocUserRoleDataType) => string)) { + super('invalid_input', 'expect_to_update_doc_user_role', message, args); + } +} export class PageIsNotPublic extends UserFriendlyError { constructor(message?: string) { @@ -679,12 +732,14 @@ export enum ErrorNames { ACTION_FORBIDDEN, ACCESS_DENIED, EMAIL_VERIFICATION_REQUIRED, + WORKSPACE_PERMISSION_NOT_FOUND, SPACE_NOT_FOUND, MEMBER_NOT_FOUND_IN_SPACE, NOT_IN_SPACE, ALREADY_IN_SPACE, SPACE_ACCESS_DENIED, SPACE_OWNER_NOT_FOUND, + SPACE_SHOULD_HAVE_ONLY_ONE_OWNER, DOC_NOT_FOUND, DOC_ACCESS_DENIED, VERSION_REJECTED, @@ -693,6 +748,9 @@ export enum ErrorNames { BLOB_NOT_FOUND, EXPECT_TO_PUBLISH_PAGE, EXPECT_TO_REVOKE_PUBLIC_PAGE, + EXPECT_TO_GRANT_DOC_USER_ROLES, + EXPECT_TO_REVOKE_DOC_USER_ROLES, + EXPECT_TO_UPDATE_DOC_USER_ROLE, PAGE_IS_NOT_PUBLIC, FAILED_TO_SAVE_UPDATES, FAILED_TO_UPSERT_SNAPSHOT, @@ -746,5 +804,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType] as const, + [QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType] as const, }); diff --git a/packages/backend/server/src/core/features/service.ts b/packages/backend/server/src/core/features/service.ts index c6e961987debb..a5a9fec2ef1f1 100644 --- a/packages/backend/server/src/core/features/service.ts +++ b/packages/backend/server/src/core/features/service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { CannotDeleteAllAdminAccount } from '../../base'; -import { WorkspaceType } from '../workspaces/types'; +import { WorkspaceFeatureType } from '../workspaces/types'; import { FeatureConfigType, getFeature } from './feature'; import { FeatureKind, FeatureType } from './types'; @@ -20,7 +20,7 @@ export class FeatureService { if (data) { return getFeature(this.prisma, data.id) as Promise>; } - return undefined; + return; } // ======== User Features ======== @@ -315,7 +315,7 @@ export class FeatureService { async listWorkspacesByFeature( feature: FeatureType - ): Promise { + ): Promise { return this.prisma.workspaceFeature .findMany({ where: { @@ -335,7 +335,7 @@ export class FeatureService { }, }, }) - .then(wss => wss.map(ws => ws.workspace as WorkspaceType)); + .then(wss => wss.map(ws => ws.workspace)); } async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) { diff --git a/packages/backend/server/src/core/permission/__tests__/__snapshots__/role.spec.ts.md b/packages/backend/server/src/core/permission/__tests__/__snapshots__/role.spec.ts.md new file mode 100644 index 0000000000000..de420b5ae38e6 --- /dev/null +++ b/packages/backend/server/src/core/permission/__tests__/__snapshots__/role.spec.ts.md @@ -0,0 +1,665 @@ +# Snapshot report for `src/core/permission/__tests__/role.spec.ts` + +The actual snapshot is saved in `role.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should be able to get correct permissions from WorkspaceRole: External and DocRole: External + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: false, + Doc_Duplicate: false, + Doc_Properties_Read: true, + Doc_Properties_Update: false, + Doc_Publish: false, + Doc_Read: true, + Doc_Restore: false, + Doc_TransferOwner: false, + Doc_Trash: false, + Doc_Update: false, + Doc_Users_Manage: false, + Doc_Users_Read: false, + Workspace_CreateDoc: false, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: false, + Workspace_Properties_Update: false, + Workspace_Settings_Read: false, + Workspace_Settings_Update: false, + Workspace_Sync: false, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: false, + } + +## should be able to get correct permissions from WorkspaceRole: External and DocRole: Reader + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: false, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: false, + Doc_Publish: false, + Doc_Read: true, + Doc_Restore: false, + Doc_TransferOwner: false, + Doc_Trash: false, + Doc_Update: false, + Doc_Users_Manage: false, + Doc_Users_Read: true, + Workspace_CreateDoc: false, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: false, + Workspace_Properties_Update: false, + Workspace_Settings_Read: false, + Workspace_Settings_Update: false, + Workspace_Sync: false, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: false, + } + +## should be able to get correct permissions from WorkspaceRole: External and DocRole: Editor + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: false, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: false, + Doc_Users_Read: true, + Workspace_CreateDoc: false, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: false, + Workspace_Properties_Update: false, + Workspace_Settings_Read: false, + Workspace_Settings_Update: false, + Workspace_Sync: false, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: false, + } + +## should be able to get correct permissions from WorkspaceRole: External and DocRole: Manager + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: false, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: false, + Workspace_Properties_Update: false, + Workspace_Settings_Read: false, + Workspace_Settings_Update: false, + Workspace_Sync: false, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: false, + } + +## should be able to get correct permissions from WorkspaceRole: External and DocRole: Owner + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: true, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: false, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: false, + Workspace_Properties_Update: false, + Workspace_Settings_Read: false, + Workspace_Settings_Update: false, + Workspace_Sync: false, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: false, + } + +## should be able to get correct permissions from WorkspaceRole: Collaborator and DocRole: External + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: false, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: false, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: true, + Workspace_Properties_Update: false, + Workspace_Settings_Read: true, + Workspace_Settings_Update: false, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Collaborator and DocRole: Reader + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: false, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: false, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: true, + Workspace_Properties_Update: false, + Workspace_Settings_Read: true, + Workspace_Settings_Update: false, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Collaborator and DocRole: Editor + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: false, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: false, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: true, + Workspace_Properties_Update: false, + Workspace_Settings_Read: true, + Workspace_Settings_Update: false, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Collaborator and DocRole: Manager + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: true, + Workspace_Properties_Update: false, + Workspace_Settings_Read: true, + Workspace_Settings_Update: false, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Collaborator and DocRole: Owner + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: true, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: false, + Workspace_Properties_Delete: false, + Workspace_Properties_Read: true, + Workspace_Properties_Update: false, + Workspace_Settings_Read: true, + Workspace_Settings_Update: false, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: false, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Admin and DocRole: External + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Admin and DocRole: Reader + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Admin and DocRole: Editor + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Admin and DocRole: Manager + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Admin and DocRole: Owner + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: true, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: false, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: false, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Owner and DocRole: External + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: true, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: true, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Owner and DocRole: Reader + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: true, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: true, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Owner and DocRole: Editor + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: true, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: true, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Owner and DocRole: Manager + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: false, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: true, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: true, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } + +## should be able to get correct permissions from WorkspaceRole: Owner and DocRole: Owner + +> Snapshot 1 + + { + Doc_Copy: true, + Doc_Delete: true, + Doc_Duplicate: true, + Doc_Properties_Read: true, + Doc_Properties_Update: true, + Doc_Publish: true, + Doc_Read: true, + Doc_Restore: true, + Doc_TransferOwner: true, + Doc_Trash: true, + Doc_Update: true, + Doc_Users_Manage: true, + Doc_Users_Read: true, + Workspace_CreateDoc: true, + Workspace_Delete: true, + Workspace_Organize_Read: true, + Workspace_Properties_Create: true, + Workspace_Properties_Delete: true, + Workspace_Properties_Read: true, + Workspace_Properties_Update: true, + Workspace_Settings_Read: true, + Workspace_Settings_Update: true, + Workspace_Sync: true, + Workspace_TransferOwner: true, + Workspace_Users_Manage: true, + Workspace_Users_Read: true, + } diff --git a/packages/backend/server/src/core/permission/__tests__/__snapshots__/role.spec.ts.snap b/packages/backend/server/src/core/permission/__tests__/__snapshots__/role.spec.ts.snap new file mode 100644 index 0000000000000..78c71e36c5e42 Binary files /dev/null and b/packages/backend/server/src/core/permission/__tests__/__snapshots__/role.spec.ts.snap differ diff --git a/packages/backend/server/src/core/permission/__tests__/role.spec.ts b/packages/backend/server/src/core/permission/__tests__/role.spec.ts new file mode 100644 index 0000000000000..88f0b837da6fd --- /dev/null +++ b/packages/backend/server/src/core/permission/__tests__/role.spec.ts @@ -0,0 +1,36 @@ +import test from 'ava'; + +import { DocRole, WorkspaceRole } from '../index'; +import { Actions, ActionsKeys, mapRoleToActions } from '../types'; + +// create a matrix representing the all possible permission of WorkspaceRole and DocRole +const matrix = Object.values(WorkspaceRole) + .filter(r => typeof r !== 'string') + .flatMap(workspaceRole => + Object.values(DocRole) + .filter(r => typeof r !== 'string') + .map(docRole => ({ + workspaceRole, + docRole, + })) + ); + +for (const { workspaceRole, docRole } of matrix) { + const permission = mapRoleToActions(workspaceRole, docRole); + test(`should be able to get correct permissions from WorkspaceRole: ${WorkspaceRole[workspaceRole]} and DocRole: ${DocRole[docRole]}`, t => { + t.snapshot(permission); + }); +} + +test('ActionsKeys value should be the same order of the Actions objects', t => { + for (const [index, value] of ActionsKeys.entries()) { + const [k, k1, k2] = value.split('.'); + if (k2) { + // @ts-expect-error + t.is(Actions[k][k1][k2], index); + } else { + // @ts-expect-error + t.is(Actions[k][k1], index); + } + } +}); diff --git a/packages/backend/server/src/core/permission/index.ts b/packages/backend/server/src/core/permission/index.ts index 01f47d7c330c4..5747358780df2 100644 --- a/packages/backend/server/src/core/permission/index.ts +++ b/packages/backend/server/src/core/permission/index.ts @@ -9,4 +9,4 @@ import { PermissionService } from './service'; export class PermissionModule {} export { PermissionService } from './service'; -export { Permission, PublicPageMode } from './types'; +export { DocRole, PublicPageMode, WorkspaceRole } from './types'; diff --git a/packages/backend/server/src/core/permission/service.ts b/packages/backend/server/src/core/permission/service.ts index 489453c8776ee..8bc64cf5dd7c0 100644 --- a/packages/backend/server/src/core/permission/service.ts +++ b/packages/backend/server/src/core/permission/service.ts @@ -8,8 +8,10 @@ import { EventBus, SpaceAccessDenied, SpaceOwnerNotFound, + SpaceShouldHaveOnlyOneOwner, + WorkspacePermissionNotFound, } from '../../base'; -import { Permission, PublicPageMode } from './types'; +import { DocRole, PublicPageMode, WorkspaceRole } from './types'; @Injectable() export class PermissionService { @@ -30,7 +32,7 @@ export class PermissionService { } /// Start regin: workspace permission - async get(ws: string, user: string) { + async get(ws: string, user: string): Promise { const data = await this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId: ws, @@ -39,7 +41,11 @@ export class PermissionService { }, }); - return data?.type as Permission; + if (!data) { + throw new WorkspacePermissionNotFound({ spaceId: ws }); + } + + return data.type; } /** @@ -63,7 +69,7 @@ export class PermissionService { .findMany({ where: { userId, - type: Permission.Owner, + type: WorkspaceRole.Owner, OR: this.acceptedCondition, }, select: { @@ -77,7 +83,7 @@ export class PermissionService { const owner = await this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId, - type: Permission.Owner, + type: WorkspaceRole.Owner, }, include: { user: true, @@ -95,7 +101,7 @@ export class PermissionService { const admin = await this.prisma.workspaceUserPermission.findMany({ where: { workspaceId, - type: Permission.Admin, + type: WorkspaceRole.Admin, }, include: { user: true, @@ -117,7 +123,7 @@ export class PermissionService { return this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId, - type: Permission.Owner, + type: WorkspaceRole.Owner, }, include: { user: true, @@ -136,7 +142,7 @@ export class PermissionService { if (ws === id) { // if workspace is public or have any public page, then allow to access const [isPublicWorkspace, publicPages] = await Promise.all([ - this.tryCheckWorkspace(ws, user, Permission.Read), + this.tryCheckWorkspace(ws, user, WorkspaceRole.Collaborator), this.prisma.workspacePage.count({ where: { workspaceId: ws, @@ -168,7 +174,7 @@ export class PermissionService { async isWorkspaceMember( ws: string, user: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ): Promise { const count = await this.prisma.workspaceUserPermission.count({ where: { @@ -193,7 +199,7 @@ export class PermissionService { async checkCloudWorkspace( workspaceId: string, userId?: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ) { const hasWorkspace = await this.hasWorkspace(workspaceId); if (hasWorkspace) { @@ -204,7 +210,7 @@ export class PermissionService { async checkWorkspace( ws: string, user?: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ) { if (!(await this.tryCheckWorkspace(ws, user, permission))) { throw new SpaceAccessDenied({ spaceId: ws }); @@ -214,10 +220,10 @@ export class PermissionService { async tryCheckWorkspace( ws: string, user?: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ) { // If the permission is read, we should check if the workspace is public - if (permission === Permission.Read) { + if (permission === WorkspaceRole.Collaborator) { const count = await this.prisma.workspace.count({ where: { id: ws, public: true }, }); @@ -253,7 +259,7 @@ export class PermissionService { async checkWorkspaceIs( ws: string, user: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ) { if (!(await this.tryCheckWorkspaceIs(ws, user, permission))) { throw new SpaceAccessDenied({ spaceId: ws }); @@ -263,7 +269,7 @@ export class PermissionService { async tryCheckWorkspaceIs( ws: string, user: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ) { const count = await this.prisma.workspaceUserPermission.count({ where: { @@ -307,7 +313,7 @@ export class PermissionService { async grant( ws: string, user: string, - permission: Permission = Permission.Read, + permission: WorkspaceRole = WorkspaceRole.Collaborator, status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending ): Promise { const data = await this.prisma.workspaceUserPermission.findFirst({ @@ -315,7 +321,7 @@ export class PermissionService { }); if (data) { - const toBeOwner = permission === Permission.Owner; + const toBeOwner = permission === WorkspaceRole.Owner; if (data.accepted && data.status === WorkspaceMemberStatus.Accepted) { const [p] = await this.prisma.$transaction( [ @@ -331,10 +337,10 @@ export class PermissionService { ? this.prisma.workspaceUserPermission.updateMany({ where: { workspaceId: ws, - type: Permission.Owner, + type: WorkspaceRole.Owner, userId: { not: user }, }, - data: { type: Permission.Admin }, + data: { type: WorkspaceRole.Admin }, }) : null, ].filter(Boolean) as Prisma.PrismaPromise[] @@ -441,7 +447,7 @@ export class PermissionService { // We shouldn't revoke owner permission // should auto deleted by workspace/user delete cascading - if (!permission || permission.type === Permission.Owner) { + if (!permission || permission.type === WorkspaceRole.Owner) { return false; } @@ -491,7 +497,7 @@ export class PermissionService { workspaceId: string, pageId: string, userId?: string, - permission = Permission.Read + permission = WorkspaceRole.Collaborator ) { const hasWorkspace = await this.hasWorkspace(workspaceId); if (hasWorkspace) { @@ -503,7 +509,7 @@ export class PermissionService { ws: string, page: string, user?: string, - permission = Permission.Read + permission = WorkspaceRole.Collaborator ) { if (!(await this.tryCheckPage(ws, page, user, permission))) { throw new DocAccessDenied({ spaceId: ws, docId: page }); @@ -514,10 +520,10 @@ export class PermissionService { ws: string, page: string, user?: string, - permission = Permission.Read + permission = WorkspaceRole.Collaborator ) { // check whether page is public - if (permission === Permission.Read) { + if (permission === WorkspaceRole.Collaborator) { const count = await this.prisma.workspacePage.count({ where: { workspaceId: ws, @@ -613,8 +619,8 @@ export class PermissionService { ws: string, page: string, user: string, - permission: Permission = Permission.Read - ) { + permission: DocRole + ): Promise { const data = await this.prisma.workspacePageUserPermission.findFirst({ where: { workspaceId: ws, @@ -637,18 +643,18 @@ export class PermissionService { }), // If the new permission is owner, we need to revoke old owner - permission === Permission.Owner + permission === DocRole.Owner ? this.prisma.workspacePageUserPermission.updateMany({ where: { workspaceId: ws, pageId: page, - type: Permission.Owner, + type: DocRole.Owner, userId: { not: user, }, }, data: { - type: Permission.Admin, + type: DocRole.Manager, }, }) : null, @@ -670,20 +676,93 @@ export class PermissionService { .then(p => p.id); } - async revokePage(ws: string, page: string, user: string) { + async revokePage(ws: string, page: string, users: string[]) { const result = await this.prisma.workspacePageUserPermission.deleteMany({ where: { workspaceId: ws, pageId: page, - userId: user, + userId: { + in: users, + }, type: { // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading - not: Permission.Owner, + not: DocRole.Owner, }, }, }); return result.count > 0; } - /// End regin: page permission + + async grantPagePermission( + workspaceId: string, + pageId: string, + userIds: string[], + role: DocRole + ) { + if (userIds.length === 0) { + return []; + } + if (role === DocRole.Owner) { + if (userIds.length > 1) { + throw new SpaceShouldHaveOnlyOneOwner({ spaceId: workspaceId }); + } + return [await this.grantPage(workspaceId, pageId, userIds[0], role)]; + } + + const ret = await this.prisma.$transaction(async tx => + Promise.all( + userIds.map(id => + tx.workspacePageUserPermission.upsert({ + where: { + workspaceId_pageId_userId: { + workspaceId, + pageId, + userId: id, + }, + }, + create: { + workspaceId, + pageId, + userId: id, + type: role, + }, + update: { + type: role, + }, + }) + ) + ) + ); + return ret.map(p => p.id); + } + + async updatePagePermission( + workspaceId: string, + pageId: string, + userId: string, + role: DocRole + ) { + const permission = await this.prisma.workspacePageUserPermission.findFirst({ + where: { + workspaceId, + pageId, + userId, + }, + }); + + if (!permission) { + return this.grantPage(workspaceId, pageId, userId, role); + } + + const { id } = await this.prisma.workspacePageUserPermission.update({ + where: { + id: permission.id, + }, + data: { + type: role, + }, + }); + return id; + } } diff --git a/packages/backend/server/src/core/permission/types.ts b/packages/backend/server/src/core/permission/types.ts index 05ec00c2b524f..a967c8813fe15 100644 --- a/packages/backend/server/src/core/permission/types.ts +++ b/packages/backend/server/src/core/permission/types.ts @@ -1,11 +1,287 @@ -export enum Permission { - Read = 0, - Write = 1, - Admin = 10, - Owner = 99, -} +import assert from 'node:assert'; export enum PublicPageMode { Page, Edgeless, } + +export enum DocRole { + External = 0, + Reader = 10, + Editor = 20, + Manager = 30, + Owner = 99, +} + +export enum WorkspaceRole { + External = -99, + Collaborator = 1, + Admin = 10, + Owner = 99, +} + +export const Actions = { + Workspace: { + Sync: 1, + CreateDoc: 2, + Delete: 11, + TransferOwner: 12, + Organize: { + Read: 0, + }, + Users: { + Read: 3, + Manage: 6, + }, + Properties: { + Read: 4, + Create: 8, + Update: 9, + Delete: 10, + }, + Settings: { + Read: 5, + Update: 7, + }, + }, + Doc: { + Read: 13, + Copy: 14, + Duplicate: 17, + Trash: 18, + Restore: 19, + Delete: 20, + Update: 22, + Publish: 23, + TransferOwner: 25, + Properties: { + Read: 15, + Update: 21, + }, + Users: { + Read: 16, + Manage: 24, + }, + }, +} as const; + +type ActionsKeysUnion = typeof Actions extends { + [k in infer _K extends string]: infer _V; +} + ? _V extends { + [k1 in infer _K1 extends string]: infer _V1; + } + ? _V1 extends { + [k2 in infer _K2 extends string]: number; + } + ? _K1 extends keyof (typeof Actions)[_K] + ? _K2 extends keyof (typeof Actions)[_K][_K1] + ? `${_K}.${_K1}.${_K2}` + : never + : never + : _V1 extends number + ? `${_K}.${_K1}` + : never + : never + : never; + +type ExcludeObjectKeys< + T, + Key extends keyof typeof Actions, + Split extends string, +> = T extends `${infer _K extends Key}.${infer _K1}.${infer _K2}` + ? _K1 extends keyof (typeof Actions)[_K] + ? _K2 extends keyof (typeof Actions)[_K][_K1] + ? `${_K}${Split}${_K1}${Split}${_K2}` + : never + : never + : T extends `${infer _K extends Key}.${infer _K1}` + ? _K1 extends keyof (typeof Actions)[_K] + ? (typeof Actions)[_K][_K1] extends number + ? `${_K}${Split}${_K1}` + : never + : never + : never; + +export type AllPossibleActionsKeys = ExcludeObjectKeys< + ActionsKeysUnion, + keyof typeof Actions, + '.' +>; +export type AllPossibleGraphQLWorkspaceActionsKeys = ExcludeObjectKeys< + ActionsKeysUnion, + 'Workspace', + '_' +>; +type AllPossibleGraphQLDocActionsKeys = ExcludeObjectKeys< + ActionsKeysUnion, + 'Doc', + '_' +>; + +type AllPossibleGraphQLActionsKeys = + | AllPossibleGraphQLWorkspaceActionsKeys + | AllPossibleGraphQLDocActionsKeys; + +export const ActionsKeys: AllPossibleActionsKeys[] = [ + 'Workspace.Organize.Read', + 'Workspace.Sync', + 'Workspace.CreateDoc', + 'Workspace.Users.Read', + 'Workspace.Properties.Read', + 'Workspace.Settings.Read', + 'Workspace.Users.Manage', + 'Workspace.Settings.Update', + 'Workspace.Properties.Create', + 'Workspace.Properties.Update', + 'Workspace.Properties.Delete', + 'Workspace.Delete', + 'Workspace.TransferOwner', + 'Doc.Read', + 'Doc.Copy', + 'Doc.Properties.Read', + 'Doc.Users.Read', + 'Doc.Duplicate', + 'Doc.Trash', + 'Doc.Restore', + 'Doc.Delete', + 'Doc.Properties.Update', + 'Doc.Update', + 'Doc.Publish', + 'Doc.Users.Manage', + 'Doc.TransferOwner', +] as const; + +assert( + ActionsKeys.length === Actions.Doc.TransferOwner + 1, + 'ActionsKeys length is not correct' +); + +function permissionKeyToGraphQLKey(key: string) { + const k = key.split('.'); + return k.join('_') as keyof PermissionsList; +} + +const DefaultActionsMap = Object.fromEntries( + ActionsKeys.map(key => [permissionKeyToGraphQLKey(key), false]) +) as PermissionsList; + +export type WorkspacePermissionsList = { + [k in AllPossibleGraphQLWorkspaceActionsKeys]: boolean; +}; + +export type PermissionsList = { + [key in AllPossibleGraphQLActionsKeys]: boolean; +}; + +export function mapWorkspaceRoleToWorkspaceActions( + workspaceRole: WorkspaceRole +) { + const permissionList = { ...DefaultActionsMap }; + (RoleActionsMap.WorkspaceRole[workspaceRole] ?? []).forEach(action => { + permissionList[permissionKeyToGraphQLKey(ActionsKeys[action])] = true; + }); + return Object.fromEntries( + Object.entries(permissionList).filter(([k, _]) => + k.startsWith('Workspace_') + ) + ); +} + +export function mapRoleToActions( + workspaceRole?: WorkspaceRole, + docRole?: DocRole +) { + const workspaceActions = workspaceRole + ? (RoleActionsMap.WorkspaceRole[workspaceRole] ?? []) + : []; + const docActions = (function () { + // Doc owner/manager permission can not be overridden by workspace role + if (docRole !== undefined && docRole >= DocRole.Manager) { + return RoleActionsMap.DocRole[docRole]; + } + switch (workspaceRole) { + case WorkspaceRole.Admin: + case WorkspaceRole.Owner: + return RoleActionsMap.DocRole[DocRole.Manager]; + case WorkspaceRole.Collaborator: + return RoleActionsMap.DocRole[DocRole.Editor]; + default: + return docRole !== undefined + ? (RoleActionsMap.DocRole[docRole] ?? []) + : []; + } + })(); + const permissionList = { ...DefaultActionsMap }; + [...workspaceActions, ...docActions].forEach(action => { + permissionList[permissionKeyToGraphQLKey(ActionsKeys[action])] = true; + }); + return permissionList; +} + +export const RoleActionsMap = { + WorkspaceRole: { + get [WorkspaceRole.External]() { + return [Actions.Workspace.Organize.Read]; + }, + get [WorkspaceRole.Collaborator]() { + return [ + ...this[WorkspaceRole.External], + Actions.Workspace.Sync, + Actions.Workspace.CreateDoc, + Actions.Workspace.Users.Read, + Actions.Workspace.Properties.Read, + Actions.Workspace.Settings.Read, + ]; + }, + get [WorkspaceRole.Admin]() { + return [ + ...this[WorkspaceRole.Collaborator], + Actions.Workspace.Users.Manage, + Actions.Workspace.Settings.Update, + Actions.Workspace.Properties.Create, + Actions.Workspace.Properties.Update, + Actions.Workspace.Properties.Delete, + ]; + }, + get [WorkspaceRole.Owner]() { + return [ + ...this[WorkspaceRole.Admin], + Actions.Workspace.Delete, + Actions.Workspace.TransferOwner, + ]; + }, + }, + DocRole: { + get [DocRole.External]() { + return [Actions.Doc.Read, Actions.Doc.Copy, Actions.Doc.Properties.Read]; + }, + get [DocRole.Reader]() { + return [ + ...this[DocRole.External], + Actions.Doc.Users.Read, + Actions.Doc.Duplicate, + ]; + }, + get [DocRole.Editor]() { + return [ + ...this[DocRole.Reader], + Actions.Doc.Trash, + Actions.Doc.Restore, + Actions.Doc.Delete, + Actions.Doc.Properties.Update, + Actions.Doc.Update, + ]; + }, + get [DocRole.Manager]() { + return [ + ...this[DocRole.Editor], + Actions.Doc.Publish, + Actions.Doc.Users.Manage, + ]; + }, + get [DocRole.Owner]() { + return [...this[DocRole.Manager], Actions.Doc.TransferOwner]; + }, + }, +} as const; diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index d0e47fa0856f0..d2456621b8309 100644 --- a/packages/backend/server/src/core/sync/gateway.ts +++ b/packages/backend/server/src/core/sync/gateway.ts @@ -27,7 +27,7 @@ import { PgUserspaceDocStorageAdapter, PgWorkspaceDocStorageAdapter, } from '../doc'; -import { Permission, PermissionService } from '../permission'; +import { PermissionService, WorkspaceRole } from '../permission'; import { DocID } from '../utils/doc'; const SubscribeMessage = (event: string) => @@ -615,7 +615,7 @@ abstract class SyncSocketAdapter { async join(userId: string, spaceId: string, roomType: RoomType = 'sync') { this.assertNotIn(spaceId, roomType); - await this.assertAccessible(spaceId, userId, Permission.Read); + await this.assertAccessible(spaceId, userId, WorkspaceRole.Collaborator); return this.client.join(this.room(spaceId, roomType)); } @@ -643,7 +643,7 @@ abstract class SyncSocketAdapter { abstract assertAccessible( spaceId: string, userId: string, - permission?: Permission + permission?: WorkspaceRole ): Promise; push(spaceId: string, docId: string, updates: Buffer[], editorId: string) { @@ -694,7 +694,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter { async assertAccessible( spaceId: string, userId: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ) { if ( !(await this.permission.isWorkspaceMember(spaceId, userId, permission)) @@ -712,7 +712,7 @@ class UserspaceSyncAdapter extends SyncSocketAdapter { async assertAccessible( spaceId: string, userId: string, - _permission: Permission = Permission.Read + _permission: WorkspaceRole = WorkspaceRole.Collaborator ) { if (spaceId !== userId) { throw new SpaceAccessDenied({ spaceId }); diff --git a/packages/backend/server/src/core/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts index a6a403f86ea41..4f820a03861c5 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -13,7 +13,11 @@ import { } from '../../base'; import { CurrentUser, Public } from '../auth'; import { PgWorkspaceDocStorageAdapter } from '../doc'; -import { Permission, PermissionService, PublicPageMode } from '../permission'; +import { + PermissionService, + PublicPageMode, + WorkspaceRole, +} from '../permission'; import { WorkspaceBlobStorage } from '../storage'; import { DocID } from '../utils/doc'; @@ -148,7 +152,7 @@ export class WorkspacesController { docId.workspace, docId.guid, user.id, - Permission.Write + WorkspaceRole.Collaborator ); const history = await this.workspace.getDocHistory( diff --git a/packages/backend/server/src/core/workspaces/management.ts b/packages/backend/server/src/core/workspaces/management.ts index 1c6282950fa99..95209c87b9f6e 100644 --- a/packages/backend/server/src/core/workspaces/management.ts +++ b/packages/backend/server/src/core/workspaces/management.ts @@ -13,7 +13,7 @@ import { CurrentUser } from '../auth'; import { Admin } from '../common'; import { FeatureManagementService, FeatureType } from '../features'; import { PermissionService } from '../permission'; -import { WorkspaceType } from './types'; +import { WorkspaceFeatureType, WorkspaceType } from './types'; @Resolver(() => WorkspaceType) export class WorkspaceManagementResolver { @@ -41,10 +41,10 @@ export class WorkspaceManagementResolver { } @Admin() - @Query(() => [WorkspaceType]) + @Query(() => [WorkspaceFeatureType]) async listWorkspaceFeatures( @Args('feature', { type: () => FeatureType }) feature: FeatureType - ): Promise { + ): Promise { return this.feature.listFeatureWorkspaces(feature); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index 133cc725c4835..dac1d747de333 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -15,7 +15,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import type { FileUpload } from '../../../base'; import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base'; import { CurrentUser } from '../../auth'; -import { Permission, PermissionService } from '../../permission'; +import { PermissionService, WorkspaceRole } from '../../permission'; import { QuotaManagementService } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobSizes, WorkspaceType } from '../types'; @@ -102,7 +102,7 @@ export class WorkspaceBlobResolver { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Write + WorkspaceRole.Collaborator ); const checkExceeded = @@ -174,7 +174,7 @@ export class WorkspaceBlobResolver { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Write + WorkspaceRole.Collaborator ); await this.storage.release(workspaceId); diff --git a/packages/backend/server/src/core/workspaces/resolvers/history.ts b/packages/backend/server/src/core/workspaces/resolvers/history.ts index 8812d6bb3ca81..4aefbb9b15c6d 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/history.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/history.ts @@ -13,7 +13,7 @@ import type { SnapshotHistory } from '@prisma/client'; import { CurrentUser } from '../../auth'; import { PgWorkspaceDocStorageAdapter } from '../../doc'; -import { Permission, PermissionService } from '../../permission'; +import { PermissionService, WorkspaceRole } from '../../permission'; import { DocID } from '../../utils/doc'; import { WorkspaceType } from '../types'; import { EditorType } from './workspace'; @@ -80,7 +80,7 @@ export class DocHistoryResolver { docId.workspace, docId.guid, user.id, - Permission.Write + WorkspaceRole.Collaborator ); await this.workspace.rollbackDoc( diff --git a/packages/backend/server/src/core/workspaces/resolvers/page.ts b/packages/backend/server/src/core/workspaces/resolvers/page.ts index a5497ff242187..a718f94e25b38 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/page.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/page.ts @@ -1,6 +1,9 @@ +import { Logger } from '@nestjs/common'; import { Args, Field, + InputType, + Int, Mutation, ObjectType, Parent, @@ -12,18 +15,25 @@ import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import { + ExpectToGrantDocUserRoles, ExpectToPublishPage, + ExpectToRevokeDocUserRoles, ExpectToRevokePublicPage, + ExpectToUpdateDocUserRole, PageIsNotPublic, } from '../../../base'; import { CurrentUser } from '../../auth'; import { - Permission, + DocRole, PermissionService, PublicPageMode, + WorkspaceRole, } from '../../permission'; +import { mapRoleToActions, PermissionsList } from '../../permission/types'; +import { UserType } from '../../user'; import { DocID } from '../../utils/doc'; import { WorkspaceType } from '../types'; +import { WorkspacePermissions } from './workspace'; registerEnumType(PublicPageMode, { name: 'PublicPageMode', @@ -45,8 +55,133 @@ class WorkspacePage implements Partial { public!: boolean; } +@InputType() +class GrantDocUserRolesInput { + @Field(() => String) + docId!: string; + + @Field(() => String) + workspaceId!: string; + + @Field(() => DocRole) + role!: DocRole; + + @Field(() => [String]) + userIds!: string[]; +} + +@InputType() +class PageGrantedUsersInput { + @Field(() => Int) + first!: number; + + @Field(() => Int) + offset!: number; + + @Field(() => String, { description: 'Cursor', nullable: true }) + after?: string; + + @Field(() => String, { description: 'Cursor', nullable: true }) + before?: string; +} + +@ObjectType() +class GrantedDocUserType { + @Field(() => UserType) + user!: UserType; + + @Field(() => DocRole) + role!: DocRole; +} + +@ObjectType() +class PageInfo { + @Field(() => String, { nullable: true }) + startCursor?: string; + + @Field(() => String, { nullable: true }) + endCursor?: string; + + @Field(() => Boolean) + hasNextPage!: boolean; + + @Field(() => Boolean) + hasPreviousPage!: boolean; +} + +@ObjectType() +class GrantedDocUserEdge { + @Field(() => GrantedDocUserType) + user!: GrantedDocUserType; + + @Field(() => String) + cursor!: string; +} + +@ObjectType() +class GrantedDocUsersConnection { + @Field(() => Int) + totalCount!: number; + + @Field(() => [GrantedDocUserEdge]) + edges!: GrantedDocUserEdge[]; + + @Field(() => PageInfo) + pageInfo!: PageInfo; +} + +@ObjectType() +export class RolePermissions + extends WorkspacePermissions + implements PermissionsList +{ + @Field() + Doc_Read!: boolean; + @Field() + Doc_Copy!: boolean; + @Field() + Doc_Properties_Read!: boolean; + @Field() + Doc_Users_Read!: boolean; + @Field() + Doc_Duplicate!: boolean; + @Field() + Doc_Trash!: boolean; + @Field() + Doc_Restore!: boolean; + @Field() + Doc_Delete!: boolean; + @Field() + Doc_Properties_Update!: boolean; + @Field() + Doc_Update!: boolean; + @Field() + Doc_Publish!: boolean; + @Field() + Doc_Users_Manage!: boolean; + @Field() + Doc_TransferOwner!: boolean; +} + +@ObjectType() +class DocType { + @Field(() => String) + id!: string; + + @Field(() => Boolean) + public!: boolean; + + @Field(() => DocRole) + role!: DocRole; + + @Field(() => RolePermissions) + permissions!: RolePermissions; +} + @Resolver(() => WorkspaceType) export class PagePermissionResolver { + private readonly logger = new Logger(PagePermissionResolver.name); + constructor( private readonly prisma: PrismaClient, private readonly permission: PermissionService @@ -102,6 +237,120 @@ export class PagePermissionResolver { }); } + @ResolveField(() => DocType, { + description: 'Check if current user has permission to access the page', + complexity: 2, + }) + async pagePermission( + @Parent() workspace: WorkspaceType, + @Args('pageId') pageId: string, + @CurrentUser() user: CurrentUser + ): Promise { + const page = await this.prisma.workspacePage.findFirst({ + where: { + workspaceId: workspace.id, + pageId, + }, + select: { + public: true, + }, + }); + + const [permission, workspacePermission] = await this.prisma.$transaction( + tx => + Promise.all([ + tx.workspacePageUserPermission.findFirst({ + where: { + workspaceId: workspace.id, + pageId, + userId: user.id, + }, + }), + tx.workspaceUserPermission.findFirst({ + where: { + workspaceId: workspace.id, + userId: user.id, + }, + }), + ]) + ); + return { + id: pageId, + public: page?.public ?? false, + role: permission?.type ?? DocRole.External, + permissions: mapRoleToActions( + workspacePermission?.type, + permission?.type + ), + }; + } + + @ResolveField(() => GrantedDocUsersConnection, { + description: 'Page granted users list', + complexity: 4, + }) + async pageGrantedUsersList( + @Parent() workspace: WorkspaceType, + @Args('pageId') pageId: string, + @Args('pageGrantedUsersInput') + pageGrantedUsersInput: PageGrantedUsersInput + ): Promise { + const docId = new DocID(pageId, workspace.id); + const [permissions, totalCount] = await this.prisma.$transaction(tx => { + return Promise.all([ + tx.workspacePageUserPermission.findMany({ + where: { + workspaceId: workspace.id, + pageId: docId.guid, + }, + include: { + user: true, + }, + orderBy: { + createdAt: 'desc', + }, + take: pageGrantedUsersInput.first, + skip: pageGrantedUsersInput.offset, + cursor: pageGrantedUsersInput.after + ? { + id: pageGrantedUsersInput.after, + } + : undefined, + }), + tx.workspacePageUserPermission.count({ + where: { + workspaceId: workspace.id, + pageId: docId.guid, + }, + }), + ]); + }); + + return { + totalCount, + edges: permissions.map(permission => ({ + user: { + user: { + id: permission.user.id, + name: permission.user.name, + email: permission.user.email, + avatarUrl: permission.user.avatarUrl, + emailVerified: permission.user.emailVerifiedAt !== null, + hasPassword: permission.user.password !== null, + }, + role: permission.type, + }, + cursor: permission.id, + })), + pageInfo: { + startCursor: permissions.at(0)?.id, + endCursor: permissions.at(-1)?.id, + hasNextPage: totalCount > pageGrantedUsersInput.first, + hasPreviousPage: pageGrantedUsersInput.offset > 0, + }, + }; + } + /** * @deprecated */ @@ -134,15 +383,25 @@ export class PagePermissionResolver { const docId = new DocID(pageId, workspaceId); if (docId.isWorkspace) { + this.logger.error('Expect to publish page, but it is a workspace', { + workspaceId, + pageId, + }); throw new ExpectToPublishPage(); } await this.permission.checkWorkspace( docId.workspace, user.id, - Permission.Write + WorkspaceRole.Collaborator ); + this.logger.log('Publish page', { + workspaceId, + pageId, + mode, + }); + return this.permission.publishPage(docId.workspace, docId.guid, mode); } @@ -171,13 +430,17 @@ export class PagePermissionResolver { const docId = new DocID(pageId, workspaceId); if (docId.isWorkspace) { + this.logger.error('Expect to revoke public page, but it is a workspace', { + workspaceId, + pageId, + }); throw new ExpectToRevokePublicPage('Expect page not to be workspace'); } await this.permission.checkWorkspace( docId.workspace, user.id, - Permission.Write + WorkspaceRole.Collaborator ); const isPublic = await this.permission.isPublicPage( @@ -186,9 +449,147 @@ export class PagePermissionResolver { ); if (!isPublic) { + this.logger.log('Expect to revoke public page, but it is not public', { + workspaceId, + pageId, + }); throw new PageIsNotPublic('Page is not public'); } + this.logger.log('Revoke public page', { + workspaceId, + pageId, + }); + return this.permission.revokePublicPage(docId.workspace, docId.guid); } + + @Mutation(() => Boolean) + async grantDocUserRoles( + @CurrentUser() user: CurrentUser, + @Args('input') input: GrantDocUserRolesInput + ): Promise { + const doc = new DocID(input.docId, input.workspaceId); + const pairs = { + spaceId: input.workspaceId, + docId: input.docId, + }; + if (doc.isWorkspace) { + this.logger.error( + 'Expect to grant doc user roles, but it is a workspace', + pairs + ); + throw new ExpectToGrantDocUserRoles( + pairs, + 'Expect doc not to be workspace' + ); + } + await this.permission.checkWorkspace( + doc.workspace, + user.id, + WorkspaceRole.Collaborator + ); + await this.permission.grantPagePermission( + doc.workspace, + doc.guid, + input.userIds, + input.role + ); + this.logger.log('Grant doc user roles', { + ...pairs, + userIds: input.userIds, + role: input.role, + }); + return true; + } + + @Mutation(() => Boolean) + async revokeDocUserRoles( + @CurrentUser() user: CurrentUser, + @Args('docId') docId: string, + @Args('userIds', { type: () => [String] }) userIds: string[] + ): Promise { + const doc = new DocID(docId); + const pairs = { + spaceId: doc.workspace, + docId: doc.guid, + }; + if (doc.isWorkspace) { + this.logger.error( + 'Expect to revoke doc user roles, but it is a workspace', + pairs + ); + throw new ExpectToRevokeDocUserRoles( + pairs, + 'Expect doc not to be workspace' + ); + } + await this.permission.checkWorkspace( + doc.workspace, + user.id, + WorkspaceRole.Collaborator + ); + await this.permission.revokePage(doc.workspace, doc.guid, userIds); + this.logger.log('Revoke doc user roles', { + ...pairs, + userIds: userIds, + }); + return true; + } + + @Mutation(() => Boolean) + async updateDocUserRole( + @CurrentUser() user: CurrentUser, + @Args('docId') docId: string, + @Args('userId') userId: string, + @Args('role', { type: () => DocRole }) role: DocRole + ): Promise { + const doc = new DocID(docId); + const pairs = { + spaceId: doc.workspace, + docId: doc.guid, + }; + if (doc.isWorkspace) { + this.logger.error( + 'Expect to update doc user role, but it is a workspace', + pairs + ); + throw new ExpectToUpdateDocUserRole( + pairs, + 'Expect doc not to be workspace' + ); + } + await this.permission.checkWorkspace( + doc.workspace, + user.id, + WorkspaceRole.Collaborator + ); + if (role === DocRole.Owner) { + const ret = await this.permission.grantPagePermission( + doc.workspace, + doc.guid, + [userId], + role + ); + this.logger.log('Transfer doc owner', { + ...pairs, + userId: userId, + role: role, + }); + return ret.length > 0; + } else { + await this.permission.updatePagePermission( + doc.workspace, + doc.guid, + userId, + role + ); + this.logger.log('Update doc user role', { + ...pairs, + userId: userId, + role: role, + }); + return true; + } + } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index bffb8d16f2248..043039fe57043 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -11,7 +11,7 @@ import { } from '../../../base'; import { Models } from '../../../models'; import { DocContentService } from '../../doc-renderer'; -import { Permission, PermissionService } from '../../permission'; +import { PermissionService, WorkspaceRole } from '../../permission'; import { WorkspaceBlobStorage } from '../../storage'; export const defaultWorkspaceAvatar = @@ -221,14 +221,14 @@ export class WorkspaceService { async sendRoleChangedEmail( userId: string, - ws: { id: string; role: Permission } + ws: { id: string; role: WorkspaceRole } ) { const user = await this.models.user.getPublicUser(userId); if (!user) throw new UserNotFound(); const workspace = await this.getWorkspaceInfo(ws.id); - if (ws.role === Permission.Admin) { + if (ws.role === WorkspaceRole.Admin) { await this.mailer.sendTeamBecomeAdminMail(user.email, { workspace, url: this.url.link(`/workspace/${workspace.id}`), diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index 81848fb476406..51dc10e600fe8 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -21,7 +21,7 @@ import { } from '../../../base'; import { Models } from '../../../models'; import { CurrentUser } from '../../auth'; -import { Permission, PermissionService } from '../../permission'; +import { PermissionService, WorkspaceRole } from '../../permission'; import { QuotaManagementService } from '../../quota'; import { InviteLink, @@ -71,7 +71,7 @@ export class TeamWorkspaceResolver { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Admin + WorkspaceRole.Admin ); if (emails.length > 512) { @@ -113,7 +113,7 @@ export class TeamWorkspaceResolver { ret.inviteId = await this.permissions.grant( workspaceId, target.id, - Permission.Write, + WorkspaceRole.Collaborator, needMoreSeat ? WorkspaceMemberStatus.NeedMoreSeat : WorkspaceMemberStatus.Pending @@ -159,7 +159,7 @@ export class TeamWorkspaceResolver { await this.permissions.checkWorkspace( workspace.id, user.id, - Permission.Admin + WorkspaceRole.Admin ); const cacheId = `workspace:inviteLink:${workspace.id}`; @@ -186,7 +186,7 @@ export class TeamWorkspaceResolver { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Admin + WorkspaceRole.Admin ); const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`; const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId); @@ -222,7 +222,7 @@ export class TeamWorkspaceResolver { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Admin + WorkspaceRole.Admin ); const cacheId = `workspace:inviteLink:${workspaceId}`; return await this.cache.delete(cacheId); @@ -237,7 +237,7 @@ export class TeamWorkspaceResolver { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Admin + WorkspaceRole.Admin ); try { @@ -257,7 +257,7 @@ export class TeamWorkspaceResolver { const result = await this.permissions.grant( workspaceId, userId, - Permission.Write, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.Accepted ); @@ -283,12 +283,12 @@ export class TeamWorkspaceResolver { @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('userId') userId: string, - @Args('permission', { type: () => Permission }) permission: Permission + @Args('permission', { type: () => WorkspaceRole }) permission: WorkspaceRole ) { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Owner + WorkspaceRole.Owner ); try { @@ -311,7 +311,7 @@ export class TeamWorkspaceResolver { ); if (result) { - if (permission === Permission.Owner) { + if (permission === WorkspaceRole.Owner) { this.event.emit('workspace.members.ownershipTransferred', { workspaceId, from: user.id, diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index fbac828fd5763..f0d472ef4d1cd 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -1,4 +1,3 @@ -import { Logger } from '@nestjs/common'; import { Args, Field, @@ -15,6 +14,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import type { FileUpload } from '../../../base'; import { + AFFiNELogger, AlreadyInSpace, Cache, DocNotFound, @@ -33,7 +33,11 @@ import { import { Models } from '../../../models'; import { CurrentUser, Public } from '../../auth'; import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc'; -import { Permission, PermissionService } from '../../permission'; +import { PermissionService, WorkspaceRole } from '../../permission'; +import { + mapWorkspaceRoleToWorkspaceActions, + WorkspacePermissionsList, +} from '../../permission/types'; import { QuotaManagementService, QuotaQueryType } from '../../quota'; import { UserType } from '../../user'; import { @@ -68,6 +72,45 @@ class WorkspacePageMeta { updatedBy!: EditorType | null; } +@ObjectType() +export class WorkspacePermissions implements WorkspacePermissionsList { + @Field() + Workspace_Organize_Read!: boolean; + @Field() + Workspace_Sync!: boolean; + @Field() + Workspace_CreateDoc!: boolean; + @Field() + Workspace_Users_Read!: boolean; + @Field() + Workspace_Properties_Read!: boolean; + @Field() + Workspace_Settings_Read!: boolean; + @Field() + Workspace_Users_Manage!: boolean; + @Field() + Workspace_Settings_Update!: boolean; + @Field() + Workspace_Properties_Create!: boolean; + @Field() + Workspace_Properties_Update!: boolean; + @Field() + Workspace_Properties_Delete!: boolean; + @Field() + Workspace_Delete!: boolean; + @Field() + Workspace_TransferOwner!: boolean; +} + +@ObjectType() +export class WorkspaceRolePermissions { + @Field(() => WorkspaceRole) + role!: WorkspaceRole; + + @Field(() => WorkspacePermissions) + permissions!: WorkspacePermissions; +} + /** * Workspace resolver * Public apis rate limit: 10 req/m @@ -75,8 +118,6 @@ class WorkspacePageMeta { */ @Resolver(() => WorkspaceType) export class WorkspaceResolver { - private readonly logger = new Logger(WorkspaceResolver.name); - constructor( private readonly cache: Cache, private readonly prisma: PrismaClient, @@ -86,29 +127,32 @@ export class WorkspaceResolver { private readonly event: EventBus, private readonly mutex: RequestMutex, private readonly workspaceService: WorkspaceService, - private readonly workspaceStorage: PgWorkspaceDocStorageAdapter - ) {} + private readonly workspaceStorage: PgWorkspaceDocStorageAdapter, + private readonly logger: AFFiNELogger + ) { + logger.setContext(WorkspaceResolver.name); + } - @ResolveField(() => Permission, { - description: 'Permission of current signed in user in workspace', + @ResolveField(() => WorkspaceRole, { + description: 'Role of current signed in user in workspace', complexity: 2, }) - async permission( + async role( @CurrentUser() user: CurrentUser, @Parent() workspace: WorkspaceType ) { // may applied in workspaces query - if ('permission' in workspace) { - return workspace.permission; + if ('role' in workspace) { + return workspace.role; } - const permission = await this.permissions.get(workspace.id, user.id); + const role = await this.permissions.get(workspace.id, user.id); - if (!permission) { + if (!role) { throw new SpaceAccessDenied({ spaceId: workspace.id }); } - return permission; + return role; } @ResolveField(() => Int, { @@ -249,7 +293,7 @@ export class WorkspaceResolver { return this.permissions.tryCheckWorkspaceIs( workspaceId, user.id, - Permission.Admin + WorkspaceRole.Admin ); } @@ -279,6 +323,7 @@ export class WorkspaceResolver { return { ...workspace, permission: type, + role: type, }; }); } @@ -297,6 +342,25 @@ export class WorkspaceResolver { return workspace; } + @Query(() => WorkspaceRolePermissions, { + description: 'Get workspace role permissions', + }) + async workspaceRolePermissions( + @CurrentUser() user: CurrentUser, + @Args('id') id: string + ) { + const workspace = await this.prisma.workspaceUserPermission.findFirst({ + where: { workspaceId: id, userId: user.id }, + }); + if (!workspace) { + throw new SpaceAccessDenied({ spaceId: id }); + } + return { + role: workspace.type, + permissions: mapWorkspaceRoleToWorkspaceActions(workspace.type), + }; + } + @Mutation(() => WorkspaceType, { description: 'Create a new workspace', }) @@ -312,7 +376,7 @@ export class WorkspaceResolver { public: false, permissions: { create: { - type: Permission.Owner, + type: WorkspaceRole.Owner, userId: user.id, accepted: true, status: WorkspaceMemberStatus.Accepted, @@ -323,21 +387,18 @@ export class WorkspaceResolver { if (init) { // convert stream to buffer - const buffer = await new Promise(resolve => { - const stream = init.createReadStream(); - const chunks: Uint8Array[] = []; - stream.on('data', chunk => { + const chunks: Uint8Array[] = []; + try { + for await (const chunk of init.createReadStream()) { chunks.push(chunk); - }); - stream.on('error', () => { - resolve(Buffer.from([])); - }); - stream.on('end', () => { - resolve(Buffer.concat(chunks)); - }); - }); + } + } catch (e) { + this.logger.error('Failed to get file content from upload stream', e); + chunks.length = 0; + } + const buffer = chunks.length ? Buffer.concat(chunks) : null; - if (buffer.length) { + if (buffer) { await this.prisma.snapshot.create({ data: { id: workspace.id, @@ -364,7 +425,7 @@ export class WorkspaceResolver { await this.permissions.checkWorkspace( id, user.id, - isTeam ? Permission.Owner : Permission.Admin + isTeam ? WorkspaceRole.Owner : WorkspaceRole.Admin ); return this.prisma.workspace.update({ @@ -380,7 +441,7 @@ export class WorkspaceResolver { @CurrentUser() user: CurrentUser, @Args('id') id: string ) { - await this.permissions.checkWorkspace(id, user.id, Permission.Owner); + await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Owner); await this.prisma.workspace.delete({ where: { @@ -401,16 +462,16 @@ export class WorkspaceResolver { @Args('email') email: string, @Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean, @Args('permission', { - type: () => Permission, + type: () => WorkspaceRole, nullable: true, deprecationReason: 'never used', }) - _permission?: Permission + _permission?: WorkspaceRole ) { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Admin + WorkspaceRole.Admin ); try { @@ -418,7 +479,7 @@ export class WorkspaceResolver { const lockFlag = `invite:${workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { - return new TooManyRequest(); + throw new TooManyRequest(); } // member limit check @@ -445,7 +506,7 @@ export class WorkspaceResolver { const inviteId = await this.permissions.grant( workspaceId, target.id, - Permission.Write + WorkspaceRole.Collaborator ); if (sendInviteMail) { try { @@ -474,10 +535,10 @@ export class WorkspaceResolver { } catch (e) { // pass through user friendly error if (e instanceof UserFriendlyError) { - return e; + throw e; } this.logger.error('failed to invite user', e); - return new TooManyRequest(); + throw new TooManyRequest(); } } @@ -512,20 +573,20 @@ export class WorkspaceResolver { const isAdmin = await this.permissions.tryCheckWorkspaceIs( workspaceId, userId, - Permission.Admin + WorkspaceRole.Admin ); if (isTeam && isAdmin) { // only owner can revoke team workspace admin await this.permissions.checkWorkspaceIs( workspaceId, user.id, - Permission.Owner + WorkspaceRole.Owner ); } else { await this.permissions.checkWorkspace( workspaceId, user.id, - Permission.Admin + WorkspaceRole.Admin ); } @@ -543,7 +604,7 @@ export class WorkspaceResolver { const lockFlag = `invite:${workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { - return new TooManyRequest(); + throw new TooManyRequest(); } const isTeam = await this.quota.isTeamWorkspace(workspaceId); @@ -553,7 +614,7 @@ export class WorkspaceResolver { user.id ); if (status === WorkspaceMemberStatus.Accepted) { - return new AlreadyInSpace({ spaceId: workspaceId }); + throw new AlreadyInSpace({ spaceId: workspaceId }); } // invite link @@ -568,7 +629,7 @@ export class WorkspaceResolver { await this.permissions.grant( workspaceId, user.id, - Permission.Write, + WorkspaceRole.Collaborator, WorkspaceMemberStatus.NeedMoreSeatAndReview ); const memberCount = @@ -579,7 +640,7 @@ export class WorkspaceResolver { }); return true; } else if (!status) { - return new MemberQuotaExceeded(); + throw new MemberQuotaExceeded(); } } else { const inviteId = await this.permissions.grant(workspaceId, user.id); diff --git a/packages/backend/server/src/core/workspaces/types.ts b/packages/backend/server/src/core/workspaces/types.ts index be5a1961e4884..30c84099ff342 100644 --- a/packages/backend/server/src/core/workspaces/types.ts +++ b/packages/backend/server/src/core/workspaces/types.ts @@ -8,17 +8,28 @@ import { PickType, registerEnumType, } from '@nestjs/graphql'; -import { Workspace, WorkspaceMemberStatus } from '@prisma/client'; +import { WorkspaceMemberStatus } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; -import { Permission } from '../permission'; +import { DocRole, WorkspaceRole } from '../permission'; import { UserType } from '../user/types'; -registerEnumType(Permission, { +registerEnumType(WorkspaceRole, { + name: 'WorkspaceRole', + description: 'User role in workspace', +}); + +// @deprecated +registerEnumType(WorkspaceRole, { name: 'Permission', description: 'User permission in workspace', }); +registerEnumType(DocRole, { + name: 'DocRole', + description: 'User permission in doc', +}); + registerEnumType(WorkspaceMemberStatus, { name: 'WorkspaceMemberStatus', description: 'Member invite status in workspace', @@ -33,8 +44,14 @@ export class InviteUserType extends OmitType( @Field(() => ID) id!: string; - @Field(() => Permission, { description: 'User permission in workspace' }) - permission!: Permission; + @Field(() => WorkspaceRole, { + deprecationReason: 'Use role instead', + description: 'User permission in workspace', + }) + permission!: WorkspaceRole; + + @Field(() => WorkspaceRole, { description: 'User role in workspace' }) + role!: WorkspaceRole; @Field({ description: 'Invite id' }) inviteId!: string; @@ -52,22 +69,25 @@ export class InviteUserType extends OmitType( } @ObjectType() -export class WorkspaceType implements Partial { +export class WorkspaceFeatureType { @Field(() => ID) id!: string; @Field({ description: 'is Public workspace' }) public!: boolean; + @Field({ description: 'Workspace created date' }) + createdAt!: Date; +} + +@ObjectType() +export class WorkspaceType extends WorkspaceFeatureType { @Field({ description: 'Enable AI' }) enableAi!: boolean; @Field({ description: 'Enable url previous when sharing' }) enableUrlPreview!: boolean; - @Field({ description: 'Workspace created date' }) - createdAt!: Date; - @Field(() => [InviteUserType], { description: 'Members of workspace', }) diff --git a/packages/backend/server/src/models/common/index.ts b/packages/backend/server/src/models/common/index.ts index 40656295c3ba2..445e67262d361 100644 --- a/packages/backend/server/src/models/common/index.ts +++ b/packages/backend/server/src/models/common/index.ts @@ -1,3 +1,2 @@ export * from './feature'; export * from './page'; -export * from './permission'; diff --git a/packages/backend/server/src/models/common/permission.ts b/packages/backend/server/src/models/common/permission.ts deleted file mode 100644 index a86fcb6c2bb60..0000000000000 --- a/packages/backend/server/src/models/common/permission.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum Permission { - Read = 0, - Write = 1, - Admin = 10, - Owner = 99, -} diff --git a/packages/backend/server/src/models/page.ts b/packages/backend/server/src/models/page.ts index 3fa540435e49e..83f5140ade934 100644 --- a/packages/backend/server/src/models/page.ts +++ b/packages/backend/server/src/models/page.ts @@ -5,9 +5,9 @@ import { type WorkspacePageUserPermission as PageUserPermission, } from '@prisma/client'; +import { WorkspaceRole } from '../core/permission'; import { BaseModel } from './base'; -import { Permission, PublicPageMode } from './common'; - +import { PublicPageMode } from './common'; export type { Page }; export type UpdatePageInput = { mode?: PublicPageMode; @@ -93,7 +93,7 @@ export class PageModel extends BaseModel { workspaceId: string, pageId: string, userId: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ): Promise { let data = await this.db.workspacePageUserPermission.findUnique({ where: { @@ -134,15 +134,15 @@ export class PageModel extends BaseModel { } // If the new permission is owner, we need to revoke old owner - if (permission === Permission.Owner) { + if (permission === WorkspaceRole.Owner) { await this.db.workspacePageUserPermission.updateMany({ where: { workspaceId, pageId, - type: Permission.Owner, + type: WorkspaceRole.Owner, userId: { not: userId }, }, - data: { type: Permission.Admin }, + data: { type: WorkspaceRole.Admin }, }); this.logger.log( `Change owner of workspace ${workspaceId} page ${pageId} to user ${userId}` @@ -163,7 +163,7 @@ export class PageModel extends BaseModel { workspaceId: string, pageId: string, userId: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ) { const count = await this.db.workspacePageUserPermission.count({ where: { @@ -190,7 +190,7 @@ export class PageModel extends BaseModel { userId, type: { // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading - not: Permission.Owner, + not: WorkspaceRole.Owner, }, }, }); diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts index ccd7da3da4ccf..44995802439e9 100644 --- a/packages/backend/server/src/models/workspace.ts +++ b/packages/backend/server/src/models/workspace.ts @@ -8,8 +8,8 @@ import { import { groupBy } from 'lodash-es'; import { EventBus } from '../base'; +import { WorkspaceRole } from '../core/permission'; import { BaseModel } from './base'; -import { Permission } from './common'; declare global { interface Events { @@ -90,7 +90,7 @@ export class WorkspaceModel extends BaseModel { public: false, permissions: { create: { - type: Permission.Owner, + type: WorkspaceRole.Owner, userId: userId, accepted: true, status: WorkspaceMemberStatus.Accepted, @@ -141,7 +141,7 @@ export class WorkspaceModel extends BaseModel { const rows = await this.db.workspaceUserPermission.findMany({ where: { userId, - type: Permission.Owner, + type: WorkspaceRole.Owner, OR: this.acceptedCondition, }, select: { @@ -177,7 +177,7 @@ export class WorkspaceModel extends BaseModel { async grantMember( workspaceId: string, userId: string, - permission: Permission = Permission.Read, + permission: WorkspaceRole = WorkspaceRole.Collaborator, status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending ): Promise { const data = await this.db.workspaceUserPermission.findUnique({ @@ -191,17 +191,19 @@ export class WorkspaceModel extends BaseModel { if (!data) { // Create a new permission - // TODO(fengmk2): should we check the permission here? Like owner can't be pending? const created = await this.db.workspaceUserPermission.create({ data: { workspaceId, userId, type: permission, - status, + status: + permission === WorkspaceRole.Owner + ? WorkspaceMemberStatus.Accepted + : status, }, }); this.logger.log( - `Granted workspace ${workspaceId} member ${userId} with permission ${permission}` + `Granted workspace ${workspaceId} member ${userId} with permission ${WorkspaceRole[permission]}` ); await this.notifyMembersUpdated(workspaceId); return created; @@ -216,14 +218,14 @@ export class WorkspaceModel extends BaseModel { data: { type: permission }, }); // If the new permission is owner, we need to revoke old owner - if (permission === Permission.Owner) { + if (permission === WorkspaceRole.Owner) { await this.db.workspaceUserPermission.updateMany({ where: { workspaceId, - type: Permission.Owner, + type: WorkspaceRole.Owner, userId: { not: userId }, }, - data: { type: Permission.Admin }, + data: { type: WorkspaceRole.Admin }, }); this.logger.log( `Change owner of workspace ${workspaceId} to ${userId}` @@ -318,7 +320,7 @@ export class WorkspaceModel extends BaseModel { async isMember( workspaceId: string, userId: string, - permission: Permission = Permission.Read + permission: WorkspaceRole = WorkspaceRole.Collaborator ) { const count = await this.db.workspaceUserPermission.count({ where: { @@ -340,7 +342,7 @@ export class WorkspaceModel extends BaseModel { return await this.db.workspaceUserPermission.findFirst({ where: { workspaceId, - type: Permission.Owner, + type: WorkspaceRole.Owner, OR: this.acceptedCondition, }, include: { @@ -356,7 +358,7 @@ export class WorkspaceModel extends BaseModel { return await this.db.workspaceUserPermission.findMany({ where: { workspaceId, - type: Permission.Admin, + type: WorkspaceRole.Admin, OR: this.acceptedCondition, }, include: { @@ -394,7 +396,7 @@ export class WorkspaceModel extends BaseModel { // We shouldn't revoke owner permission // should auto deleted by workspace/user delete cascading - if (!member || member.type === Permission.Owner) { + if (!member || member.type === WorkspaceRole.Owner) { return false; } diff --git a/packages/backend/server/src/plugins/license/resolver.ts b/packages/backend/server/src/plugins/license/resolver.ts index 463c759e9cd09..450430ef698ea 100644 --- a/packages/backend/server/src/plugins/license/resolver.ts +++ b/packages/backend/server/src/plugins/license/resolver.ts @@ -11,7 +11,7 @@ import { import { ActionForbidden, Config } from '../../base'; import { CurrentUser } from '../../core/auth'; -import { Permission, PermissionService } from '../../core/permission'; +import { PermissionService, WorkspaceRole } from '../../core/permission'; import { WorkspaceType } from '../../core/workspaces'; import { SubscriptionRecurring } from '../payment/types'; import { LicenseService } from './service'; @@ -61,7 +61,7 @@ export class LicenseResolver { await this.permission.checkWorkspaceIs( workspace.id, user.id, - Permission.Owner + WorkspaceRole.Owner ); return this.service.getLicense(workspace.id); @@ -80,7 +80,7 @@ export class LicenseResolver { await this.permission.checkWorkspaceIs( workspaceId, user.id, - Permission.Owner + WorkspaceRole.Owner ); return this.service.activateTeamLicense(workspaceId, license); @@ -98,7 +98,7 @@ export class LicenseResolver { await this.permission.checkWorkspaceIs( workspaceId, user.id, - Permission.Owner + WorkspaceRole.Owner ); return this.service.deactivateTeamLicense(workspaceId); @@ -116,7 +116,7 @@ export class LicenseResolver { await this.permission.checkWorkspaceIs( workspaceId, user.id, - Permission.Owner + WorkspaceRole.Owner ); const { url } = await this.service.createCustomerPortal(workspaceId); diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 45c7344e027c1..2153ceaf06eaa 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -27,7 +27,7 @@ import { WorkspaceIdRequiredToUpdateTeamSubscription, } from '../../base'; import { CurrentUser, Public } from '../../core/auth'; -import { Permission, PermissionService } from '../../core/permission'; +import { PermissionService, WorkspaceRole } from '../../core/permission'; import { UserType } from '../../core/user'; import { WorkspaceType } from '../../core/workspaces'; import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager'; @@ -541,7 +541,11 @@ export class WorkspaceSubscriptionResolver { @CurrentUser() me: CurrentUser, @Parent() workspace: WorkspaceType ) { - await this.permission.checkWorkspace(workspace.id, me.id, Permission.Owner); + await this.permission.checkWorkspace( + workspace.id, + me.id, + WorkspaceRole.Owner + ); return this.db.invoice.count({ where: { targetId: workspace.id, @@ -557,7 +561,11 @@ export class WorkspaceSubscriptionResolver { take: number, @Args('skip', { type: () => Int, nullable: true }) skip?: number ) { - await this.permission.checkWorkspace(workspace.id, me.id, Permission.Owner); + await this.permission.checkWorkspace( + workspace.id, + me.id, + WorkspaceRole.Owner + ); return this.db.invoice.findMany({ where: { diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 69c0e60d4cee4..a8878d0524051 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -204,12 +204,28 @@ type DocNotFoundDataType { spaceId: String! } +"""User permission in doc""" +enum DocRole { + Editor + External + Manager + Owner + Reader +} + +type DocType { + id: String! + permissions: RolePermissions! + public: Boolean! + role: DocRole! +} + type EditorType { avatarUrl: String name: String! } -union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WrongSignInCredentialsDataType +union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -240,8 +256,11 @@ enum ErrorNames { EMAIL_ALREADY_USED EMAIL_TOKEN_NOT_FOUND EMAIL_VERIFICATION_REQUIRED + EXPECT_TO_GRANT_DOC_USER_ROLES EXPECT_TO_PUBLISH_PAGE + EXPECT_TO_REVOKE_DOC_USER_ROLES EXPECT_TO_REVOKE_PUBLIC_PAGE + EXPECT_TO_UPDATE_DOC_USER_ROLE FAILED_TO_CHECKOUT FAILED_TO_SAVE_UPDATES FAILED_TO_UPSERT_SNAPSHOT @@ -279,6 +298,7 @@ enum ErrorNames { SPACE_ACCESS_DENIED SPACE_NOT_FOUND SPACE_OWNER_NOT_FOUND + SPACE_SHOULD_HAVE_ONLY_ONE_OWNER SUBSCRIPTION_ALREADY_EXISTS SUBSCRIPTION_EXPIRED SUBSCRIPTION_HAS_BEEN_CANCELED @@ -296,10 +316,26 @@ enum ErrorNames { WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION WORKSPACE_LICENSE_ALREADY_EXISTS WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE + WORKSPACE_PERMISSION_NOT_FOUND WRONG_SIGN_IN_CREDENTIALS WRONG_SIGN_IN_METHOD } +type ExpectToGrantDocUserRolesDataType { + docId: String! + spaceId: String! +} + +type ExpectToRevokeDocUserRolesDataType { + docId: String! + spaceId: String! +} + +type ExpectToUpdateDocUserRoleDataType { + docId: String! + spaceId: String! +} + """The type of workspace feature""" enum FeatureType { AIEarlyAccess @@ -321,6 +357,29 @@ input ForkChatSessionInput { workspaceId: String! } +input GrantDocUserRolesInput { + docId: String! + role: DocRole! + userIds: [String!]! + workspaceId: String! +} + +type GrantedDocUserEdge { + cursor: String! + user: GrantedDocUserType! +} + +type GrantedDocUserType { + role: DocRole! + user: UserType! +} + +type GrantedDocUsersConnection { + edges: [GrantedDocUserEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + type HumanReadableQuotaType { blobLimit: String! copilotActionLimit: String @@ -418,7 +477,10 @@ type InviteUserType { name: String """User permission in workspace""" - permission: Permission! + permission: Permission! @deprecated(reason: "Use role instead") + + """User role in workspace""" + role: Permission! """Member invite status in workspace""" status: WorkspaceMemberStatus! @@ -548,6 +610,7 @@ type Mutation { """Create a chat session""" forkCopilotSession(options: ForkChatSessionInput!): String! generateLicenseKey(sessionId: String!): String! + grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean! grantMember(permission: Permission!, userId: String!, workspaceId: String!): String! invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! @@ -561,6 +624,7 @@ type Mutation { removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! revoke(userId: String!, workspaceId: String!): Boolean! + revokeDocUserRoles(docId: String!, userIds: [String!]!): Boolean! revokeInviteLink(workspaceId: String!): Boolean! revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! @@ -578,6 +642,7 @@ type Mutation { """Update a chat session""" updateCopilotSession(options: UpdateChatSessionInput!): String! + updateDocUserRole(docId: String!, role: DocRole!, userId: String!): Boolean! updateProfile(input: UpdateUserInput!): UserType! """update server runtime configurable setting""" @@ -611,6 +676,23 @@ enum OAuthProviderType { OIDC } +input PageGrantedUsersInput { + """Cursor""" + after: String + + """Cursor""" + before: String + first: Int! + offset: Int! +} + +type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String +} + type PasswordLimitsType { maxLength: Int! minLength: Int! @@ -619,9 +701,9 @@ type PasswordLimitsType { """User permission in workspace""" enum Permission { Admin + Collaborator + External Owner - Read - Write } """The mode which the public page default in""" @@ -651,7 +733,7 @@ type Query { """List all copilot prompts""" listCopilotPrompts: [CopilotPromptType!]! - listWorkspaceFeatures(feature: FeatureType!): [WorkspaceType!]! + listWorkspaceFeatures(feature: FeatureType!): [WorkspaceFeatureType!]! prices: [SubscriptionPrice!]! """server config""" @@ -679,6 +761,9 @@ type Query { """Get workspace by id""" workspace(id: String!): WorkspaceType! + """Get workspace role permissions""" + workspaceRolePermissions(id: String!): WorkspaceRolePermissions! + """Get all accessible workspaces for current user""" workspaces: [WorkspaceType!]! } @@ -713,6 +798,35 @@ type RemoveAvatar { success: Boolean! } +type RolePermissions { + Doc_Copy: Boolean! + Doc_Delete: Boolean! + Doc_Duplicate: Boolean! + Doc_Properties_Read: Boolean! + Doc_Properties_Update: Boolean! + Doc_Publish: Boolean! + Doc_Read: Boolean! + Doc_Restore: Boolean! + Doc_TransferOwner: Boolean! + Doc_Trash: Boolean! + Doc_Update: Boolean! + Doc_Users_Manage: Boolean! + Doc_Users_Read: Boolean! + Workspace_CreateDoc: Boolean! + Workspace_Delete: Boolean! + Workspace_Organize_Read: Boolean! + Workspace_Properties_Create: Boolean! + Workspace_Properties_Delete: Boolean! + Workspace_Properties_Read: Boolean! + Workspace_Properties_Update: Boolean! + Workspace_Settings_Read: Boolean! + Workspace_Settings_Update: Boolean! + Workspace_Sync: Boolean! + Workspace_TransferOwner: Boolean! + Workspace_Users_Manage: Boolean! + Workspace_Users_Read: Boolean! +} + type RuntimeConfigNotFoundDataType { key: String! } @@ -814,6 +928,10 @@ type SpaceOwnerNotFoundDataType { spaceId: String! } +type SpaceShouldHaveOnlyOneOwnerDataType { + spaceId: String! +} + type SubscriptionAlreadyExistsDataType { plan: String! } @@ -988,6 +1106,15 @@ type WorkspaceBlobSizes { size: SafeInt! } +type WorkspaceFeatureType { + """Workspace created date""" + createdAt: DateTime! + id: ID! + + """is Public workspace""" + public: Boolean! +} + """Workspace invite link expire time""" enum WorkspaceInviteLinkExpireTime { OneDay @@ -1023,6 +1150,31 @@ type WorkspacePageMeta { updatedBy: EditorType } +type WorkspacePermissionNotFoundDataType { + spaceId: String! +} + +type WorkspacePermissions { + Workspace_CreateDoc: Boolean! + Workspace_Delete: Boolean! + Workspace_Organize_Read: Boolean! + Workspace_Properties_Create: Boolean! + Workspace_Properties_Delete: Boolean! + Workspace_Properties_Read: Boolean! + Workspace_Properties_Update: Boolean! + Workspace_Settings_Read: Boolean! + Workspace_Settings_Update: Boolean! + Workspace_Sync: Boolean! + Workspace_TransferOwner: Boolean! + Workspace_Users_Manage: Boolean! + Workspace_Users_Read: Boolean! +} + +type WorkspaceRolePermissions { + permissions: WorkspacePermissions! + role: Permission! +} + type WorkspaceType { """Available features of workspace""" availableFeatures: [FeatureType!]! @@ -1069,11 +1221,14 @@ type WorkspaceType { """Owner of workspace""" owner: UserType! + """Page granted users list""" + pageGrantedUsersList(pageGrantedUsersInput: PageGrantedUsersInput!, pageId: String!): GrantedDocUsersConnection! + """Cloud page metadata of workspace""" pageMeta(pageId: String!): WorkspacePageMeta! - """Permission of current signed in user in workspace""" - permission: Permission! + """Check if current user has permission to access the page""" + pagePermission(pageId: String!): DocType! """is Public workspace""" public: Boolean! @@ -1087,6 +1242,9 @@ type WorkspaceType { """quota of workspace""" quota: QuotaQueryType! + """Role of current signed in user in workspace""" + role: Permission! + """Shared pages of workspace""" sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages")