From 61162c59fc7abef175dc2eb5537dba603b3660b0 Mon Sep 17 00:00:00 2001 From: liuyi Date: Wed, 5 Mar 2025 15:57:00 +0800 Subject: [PATCH] refactor(server): permission (#10449) --- packages/backend/server/schema.prisma | 20 +- .../src/__tests__/models/doc-user.spec.ts | 160 +++ .../server/src/__tests__/models/page.spec.ts | 232 ---- .../server/src/__tests__/models/user.spec.ts | 20 +- .../__tests__/models/workspace-user.spec.ts | 255 +++++ .../src/__tests__/models/workspace.spec.ts | 1004 +---------------- .../backend/server/src/__tests__/team.e2e.ts | 96 +- .../server/src/__tests__/utils/blobs.ts | 15 +- .../server/src/__tests__/workspace.e2e.ts | 2 +- .../src/__tests__/workspace/blobs.e2e.ts | 2 +- .../__tests__/workspace/controller.spec.ts | 20 +- packages/backend/server/src/base/error/def.ts | 8 + .../server/src/base/error/errors.gen.ts | 14 + .../doc-renderer/__tests__/controller.spec.ts | 7 +- .../src/core/doc-renderer/controller.ts | 14 +- .../backend/server/src/core/doc/options.ts | 5 +- .../__snapshots__/actions.spec.ts.md | 39 +- .../__snapshots__/actions.spec.ts.snap | Bin 1217 -> 1396 bytes .../core/permission/__tests__/actions.spec.ts | 2 +- .../core/permission/__tests__/builder.spec.ts | 48 + .../src/core/permission/__tests__/doc.spec.ts | 160 +++ .../permission/__tests__/workspace.spec.ts | 147 +++ .../server/src/core/permission/builder.ts | 108 ++ .../server/src/core/permission/controller.ts | 53 + .../backend/server/src/core/permission/doc.ts | 105 ++ .../server/src/core/permission/event.ts | 20 + .../server/src/core/permission/index.ts | 20 +- .../server/src/core/permission/resource.ts | 42 + .../server/src/core/permission/service.ts | 798 ------------- .../server/src/core/permission/types.ts | 75 +- .../server/src/core/permission/workspace.ts | 101 ++ .../backend/server/src/core/quota/service.ts | 26 +- .../server/src/core/storage/wrappers/blob.ts | 13 + .../backend/server/src/core/sync/gateway.ts | 38 +- .../server/src/core/workspaces/controller.ts | 67 +- .../server/src/core/workspaces/event.ts | 8 +- .../src/core/workspaces/resolvers/blob.ts | 50 +- .../src/core/workspaces/resolvers/doc.ts | 292 ++--- .../src/core/workspaces/resolvers/history.ts | 11 +- .../src/core/workspaces/resolvers/service.ts | 46 +- .../src/core/workspaces/resolvers/team.ts | 208 ++-- .../core/workspaces/resolvers/workspace.ts | 422 ++++--- .../1732861452428-migrate-invite-status.ts | 2 +- .../backend/server/src/models/common/doc.ts | 5 + .../backend/server/src/models/common/index.ts | 2 +- .../backend/server/src/models/common/page.ts | 4 - .../backend/server/src/models/common/role.ts | 14 + .../backend/server/src/models/doc-user.ts | 203 ++++ packages/backend/server/src/models/index.ts | 6 + packages/backend/server/src/models/page.ts | 125 +- packages/backend/server/src/models/user.ts | 10 +- .../server/src/models/workspace-user.ts | 385 +++++++ .../backend/server/src/models/workspace.ts | 537 ++------- .../server/src/plugins/copilot/resolver.ts | 67 +- .../server/src/plugins/license/resolver.ts | 41 +- .../server/src/plugins/license/service.ts | 4 +- .../src/plugins/payment/manager/workspace.ts | 10 +- .../server/src/plugins/payment/quota.ts | 4 +- .../server/src/plugins/payment/resolver.ts | 23 +- packages/backend/server/src/schema.gql | 25 +- tests/kit/src/utils/cloud.ts | 2 +- 61 files changed, 2680 insertions(+), 3562 deletions(-) create mode 100644 packages/backend/server/src/__tests__/models/doc-user.spec.ts delete mode 100644 packages/backend/server/src/__tests__/models/page.spec.ts create mode 100644 packages/backend/server/src/__tests__/models/workspace-user.spec.ts create mode 100644 packages/backend/server/src/core/permission/__tests__/builder.spec.ts create mode 100644 packages/backend/server/src/core/permission/__tests__/doc.spec.ts create mode 100644 packages/backend/server/src/core/permission/__tests__/workspace.spec.ts create mode 100644 packages/backend/server/src/core/permission/builder.ts create mode 100644 packages/backend/server/src/core/permission/controller.ts create mode 100644 packages/backend/server/src/core/permission/doc.ts create mode 100644 packages/backend/server/src/core/permission/event.ts create mode 100644 packages/backend/server/src/core/permission/resource.ts delete mode 100644 packages/backend/server/src/core/permission/service.ts create mode 100644 packages/backend/server/src/core/permission/workspace.ts delete mode 100644 packages/backend/server/src/models/common/page.ts create mode 100644 packages/backend/server/src/models/common/role.ts create mode 100644 packages/backend/server/src/models/doc-user.ts create mode 100644 packages/backend/server/src/models/workspace-user.ts diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index b5f06fa4d8..9cefe0c596 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -24,17 +24,17 @@ model User { features UserFeature[] userStripeCustomer UserStripeCustomer? - workspacePermissions WorkspaceUserPermission[] - docPermissions WorkspaceDocUserPermission[] + workspacePermissions WorkspaceUserRole[] + docPermissions WorkspaceDocUserRole[] connectedAccounts ConnectedAccount[] sessions UserSession[] aiSessions AiSession[] updatedRuntimeConfigs RuntimeConfig[] userSnapshots UserSnapshot[] - createdSnapshot Snapshot[] @relation("createdSnapshot") - updatedSnapshot Snapshot[] @relation("updatedSnapshot") - createdUpdate Update[] @relation("createdUpdate") - createdHistory SnapshotHistory[] @relation("createdHistory") + createdSnapshot Snapshot[] @relation("createdSnapshot") + updatedSnapshot Snapshot[] @relation("updatedSnapshot") + createdUpdate Update[] @relation("createdUpdate") + createdHistory SnapshotHistory[] @relation("createdHistory") @@index([email]) @@map("users") @@ -104,8 +104,8 @@ model Workspace { features WorkspaceFeature[] docs WorkspaceDoc[] - permissions WorkspaceUserPermission[] - docPermissions WorkspaceDocUserPermission[] + permissions WorkspaceUserRole[] + docPermissions WorkspaceDocUserRole[] blobs Blob[] @@map("workspaces") @@ -139,7 +139,7 @@ enum WorkspaceMemberStatus { Accepted // 4. old state accepted = true } -model WorkspaceUserPermission { +model WorkspaceUserRole { id String @id @default(uuid()) @db.VarChar workspaceId String @map("workspace_id") @db.VarChar userId String @map("user_id") @db.VarChar @@ -162,7 +162,7 @@ model WorkspaceUserPermission { @@map("workspace_user_permissions") } -model WorkspaceDocUserPermission { +model WorkspaceDocUserRole { workspaceId String @map("workspace_id") @db.VarChar docId String @map("page_id") @db.VarChar userId String @map("user_id") @db.VarChar diff --git a/packages/backend/server/src/__tests__/models/doc-user.spec.ts b/packages/backend/server/src/__tests__/models/doc-user.spec.ts new file mode 100644 index 0000000000..9717140786 --- /dev/null +++ b/packages/backend/server/src/__tests__/models/doc-user.spec.ts @@ -0,0 +1,160 @@ +import { PrismaClient } from '@prisma/client'; +import test from 'ava'; +import Sinon from 'sinon'; + +import { EventBus } from '../../base'; +import { DocRole, Models } from '../../models'; +import { createTestingModule, TestingModule } from '../utils'; + +let db: PrismaClient; +let models: Models; +let module: TestingModule; + +test.before(async () => { + module = await createTestingModule({ + tapModule: m => { + m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus)); + }, + }); + models = module.get(Models); + db = module.get(PrismaClient); +}); + +test.beforeEach(async () => { + await module.initTestingDB(); + Sinon.reset(); +}); + +test.after(async () => { + await module.close(); +}); + +async function create() { + return db.workspace.create({ + data: { public: false }, + }); +} + +test('should set doc owner', async t => { + const workspace = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + const docId = 'fake-doc-id'; + + await models.docUser.setOwner(workspace.id, docId, user.id); + const role = await models.docUser.get(workspace.id, docId, user.id); + + t.is(role?.type, DocRole.Owner); +}); + +test('should transfer doc owner', async t => { + const user = await models.user.create({ email: 'u1@affine.pro' }); + const user2 = await models.user.create({ email: 'u2@affine.pro' }); + const workspace = await create(); + const docId = 'fake-doc-id'; + + await models.docUser.setOwner(workspace.id, docId, user.id); + await models.docUser.setOwner(workspace.id, docId, user2.id); + + const oldOwnerRole = await models.docUser.get(workspace.id, docId, user.id); + const newOwnerRole = await models.docUser.get(workspace.id, docId, user2.id); + + t.is(oldOwnerRole?.type, DocRole.Manager); + t.is(newOwnerRole?.type, DocRole.Owner); +}); + +test('should set doc user role', async t => { + const workspace = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + const docId = 'fake-doc-id'; + + await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager); + const role = await models.docUser.get(workspace.id, docId, user.id); + + t.is(role?.type, DocRole.Manager); +}); + +test('should not allow setting doc owner through setDocUserRole', async t => { + const workspace = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + const docId = 'fake-doc-id'; + + await t.throwsAsync( + models.docUser.set(workspace.id, docId, user.id, DocRole.Owner), + { message: 'Cannot set Owner role of a doc to a user.' } + ); +}); + +test('should delete doc user role', async t => { + const workspace = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + const docId = 'fake-doc-id'; + + await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager); + await models.docUser.delete(workspace.id, docId, user.id); + + const role = await models.docUser.get(workspace.id, docId, user.id); + t.is(role, null); +}); + +test('should paginate doc user roles', async t => { + const workspace = await create(); + const docId = 'fake-doc-id'; + await db.user.createMany({ + data: Array.from({ length: 200 }, (_, i) => ({ + id: String(i), + name: `u${i}`, + email: `${i}@affine.pro`, + })), + }); + + await db.workspaceDocUserRole.createMany({ + data: Array.from({ length: 200 }, (_, i) => ({ + workspaceId: workspace.id, + docId, + userId: String(i), + type: DocRole.Editor, + createdAt: new Date(Date.now() + i * 1000), + })), + }); + + const [roles, total] = await models.docUser.paginate(workspace.id, docId, { + first: 10, + offset: 0, + }); + + t.is(roles.length, 10); + t.is(total, 200); + + const [roles2] = await models.docUser.paginate(workspace.id, docId, { + after: roles.at(-1)?.createdAt.toISOString(), + first: 50, + offset: 0, + }); + + t.is(roles2.length, 50); + t.not(roles2[0].type, DocRole.Owner); + t.deepEqual( + roles2.map(r => r.userId), + roles2 + .toSorted((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(r => r.userId) + ); +}); + +test('should count doc user roles', async t => { + const workspace = await create(); + const docId = 'fake-doc-id'; + const users = await Promise.all([ + models.user.create({ email: 'u1@affine.pro' }), + models.user.create({ email: 'u2@affine.pro' }), + ]); + + await Promise.all( + users.map(user => + models.docUser.set(workspace.id, docId, user.id, DocRole.Manager) + ) + ); + + const count = await models.docUser.count(workspace.id, docId); + t.is(count, 2); +}); diff --git a/packages/backend/server/src/__tests__/models/page.spec.ts b/packages/backend/server/src/__tests__/models/page.spec.ts deleted file mode 100644 index 7005f514f0..0000000000 --- a/packages/backend/server/src/__tests__/models/page.spec.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import ava, { TestFn } from 'ava'; - -import { Config } from '../../base/config'; -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'; -import { createTestingModule, type TestingModule } from '../utils'; - -interface Context { - config: Config; - module: TestingModule; - db: PrismaClient; - user: UserModel; - workspace: WorkspaceModel; - page: PageModel; -} - -const test = ava as TestFn; - -test.before(async t => { - const module = await createTestingModule(); - - t.context.user = module.get(UserModel); - t.context.workspace = module.get(WorkspaceModel); - t.context.page = module.get(PageModel); - t.context.db = module.get(PrismaClient); - t.context.config = module.get(Config); - t.context.module = module; -}); - -let user: User; -let workspace: Workspace; - -test.beforeEach(async t => { - await t.context.module.initTestingDB(); - user = await t.context.user.create({ - email: 'test@affine.pro', - }); - workspace = await t.context.workspace.create(user.id); -}); - -test.after(async t => { - await t.context.module.close(); -}); - -test('should create page with default mode and public false', async t => { - const page = await t.context.page.upsert(workspace.id, 'page1'); - t.is(page.workspaceId, workspace.id); - t.is(page.docId, 'page1'); - t.is(page.mode, PublicPageMode.Page); - t.is(page.public, false); -}); - -test('should update page', async t => { - const page = await t.context.page.upsert(workspace.id, 'page1'); - const data = { - mode: PublicPageMode.Edgeless, - public: true, - }; - await t.context.page.upsert(workspace.id, 'page1', data); - const page1 = await t.context.page.get(workspace.id, 'page1'); - t.deepEqual(page1, { - ...page, - ...data, - }); -}); - -test('should get null when page not exists', async t => { - const page = await t.context.page.get(workspace.id, 'page1'); - t.is(page, null); -}); - -test('should get page by id and public flag', async t => { - await t.context.page.upsert(workspace.id, 'page1'); - await t.context.page.upsert(workspace.id, 'page2', { - public: true, - }); - let page1 = await t.context.page.get(workspace.id, 'page1'); - t.is(page1!.public, false); - page1 = await t.context.page.get(workspace.id, 'page1', true); - t.is(page1, null); - let page2 = await t.context.page.get(workspace.id, 'page2', true); - t.is(page2!.public, true); - page2 = await t.context.page.get(workspace.id, 'page2', false); - t.is(page2, null); -}); - -test('should get public page count', async t => { - await t.context.page.upsert(workspace.id, 'page1', { - public: true, - }); - await t.context.page.upsert(workspace.id, 'page2', { - public: true, - }); - await t.context.page.upsert(workspace.id, 'page3'); - const count = await t.context.page.getPublicsCount(workspace.id); - t.is(count, 2); -}); - -test('should get public pages of a workspace', async t => { - await t.context.page.upsert(workspace.id, 'page1', { - public: true, - }); - await t.context.page.upsert(workspace.id, 'page2', { - public: true, - }); - await t.context.page.upsert(workspace.id, 'page3'); - const pages = await t.context.page.findPublics(workspace.id); - t.is(pages.length, 2); - t.deepEqual(pages.map(p => p.docId).sort(), ['page1', 'page2']); -}); - -test('should grant a member to access a page', async t => { - await t.context.page.upsert(workspace.id, 'page1', { - public: true, - }); - const member = await t.context.user.create({ - email: 'test1@affine.pro', - }); - await t.context.page.grantMember(workspace.id, 'page1', member.id); - let hasAccess = await t.context.page.isMember( - workspace.id, - 'page1', - member.id - ); - t.true(hasAccess); - hasAccess = await t.context.page.isMember( - workspace.id, - 'page1', - user.id, - WorkspaceRole.Collaborator - ); - t.false(hasAccess); - // grant write permission - await t.context.page.grantMember( - workspace.id, - 'page1', - user.id, - WorkspaceRole.Collaborator - ); - hasAccess = await t.context.page.isMember( - workspace.id, - 'page1', - user.id, - WorkspaceRole.Collaborator - ); - t.true(hasAccess); - hasAccess = await t.context.page.isMember( - workspace.id, - 'page1', - user.id, - WorkspaceRole.Collaborator - ); - t.true(hasAccess); - // delete member - const count = await t.context.page.deleteMember( - workspace.id, - 'page1', - user.id - ); - t.is(count, 1); - hasAccess = await t.context.page.isMember(workspace.id, 'page1', user.id); - t.false(hasAccess); -}); - -test('should change the page owner', async t => { - await t.context.page.upsert(workspace.id, 'page1', { - public: true, - }); - await t.context.page.grantMember( - workspace.id, - 'page1', - user.id, - WorkspaceRole.Owner - ); - t.true( - await t.context.page.isMember( - workspace.id, - 'page1', - user.id, - WorkspaceRole.Owner - ) - ); - - // change owner - const otherUser = await t.context.user.create({ - email: 'test1@affine.pro', - }); - await t.context.page.grantMember( - workspace.id, - 'page1', - otherUser.id, - WorkspaceRole.Owner - ); - t.true( - await t.context.page.isMember( - workspace.id, - 'page1', - otherUser.id, - WorkspaceRole.Owner - ) - ); - t.false( - await t.context.page.isMember( - workspace.id, - 'page1', - user.id, - WorkspaceRole.Owner - ) - ); -}); - -test('should not delete owner from page', async t => { - await t.context.page.upsert(workspace.id, 'page1', { - public: true, - }); - await t.context.page.grantMember( - workspace.id, - 'page1', - user.id, - WorkspaceRole.Owner - ); - const count = await t.context.page.deleteMember( - workspace.id, - 'page1', - user.id - ); - t.is(count, 0); -}); diff --git a/packages/backend/server/src/__tests__/models/user.spec.ts b/packages/backend/server/src/__tests__/models/user.spec.ts index 4ce583530f..a8be93dd25 100644 --- a/packages/backend/server/src/__tests__/models/user.spec.ts +++ b/packages/backend/server/src/__tests__/models/user.spec.ts @@ -2,13 +2,13 @@ import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; import { EmailAlreadyUsed, EventBus } from '../../base'; -import { WorkspaceRole } from '../../core/permission'; +import { Models } from '../../models'; import { UserModel } from '../../models/user'; -import { WorkspaceMemberStatus } from '../../models/workspace'; import { createTestingModule, sleep, type TestingModule } from '../utils'; interface Context { module: TestingModule; + models: Models; user: UserModel; } @@ -18,6 +18,7 @@ test.before(async t => { const module = await createTestingModule({}); t.context.user = module.get(UserModel); + t.context.models = module.get(Models); t.context.module = module; }); @@ -253,24 +254,13 @@ test('should trigger user.deleted event', async t => { const user = await t.context.user.create({ email: 'test@affine.pro', - workspacePermissions: { - create: { - workspace: { - create: { - id: 'test-workspace', - public: false, - }, - }, - type: WorkspaceRole.Owner, - status: WorkspaceMemberStatus.Accepted, - }, - }, }); + const workspace = await t.context.models.workspace.create(user.id); await t.context.user.delete(user.id); t.true( - spy.calledOnceWithExactly({ ...user, ownedWorkspaces: ['test-workspace'] }) + spy.calledOnceWithExactly({ ...user, ownedWorkspaces: [workspace.id] }) ); // await for 'user.deleted' event to be emitted and executed // avoid race condition cause database dead lock diff --git a/packages/backend/server/src/__tests__/models/workspace-user.spec.ts b/packages/backend/server/src/__tests__/models/workspace-user.spec.ts new file mode 100644 index 0000000000..5f77f232ef --- /dev/null +++ b/packages/backend/server/src/__tests__/models/workspace-user.spec.ts @@ -0,0 +1,255 @@ +import { PrismaClient } from '@prisma/client'; +import test from 'ava'; +import Sinon from 'sinon'; + +import { EventBus } from '../../base'; +import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models'; +import { createTestingModule, TestingModule } from '../utils'; + +let db: PrismaClient; +let models: Models; +let module: TestingModule; +let event: Sinon.SinonStubbedInstance; + +test.before(async () => { + module = await createTestingModule({ + tapModule: m => { + m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus)); + }, + }); + models = module.get(Models); + event = module.get(EventBus); + db = module.get(PrismaClient); +}); + +test.beforeEach(async () => { + await module.initTestingDB(); + Sinon.reset(); +}); + +test.after(async () => { + await module.close(); +}); + +async function create() { + return db.workspace.create({ + data: { public: false }, + }); +} + +test('should set workspace owner', async t => { + const workspace = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + await models.workspaceUser.setOwner(workspace.id, user.id); + const owner = await models.workspaceUser.getOwner(workspace.id); + + t.is(owner.id, user.id); +}); + +test('should transfer workespace owner', async t => { + const user = await models.user.create({ email: 'u1@affine.pro' }); + const user2 = await models.user.create({ email: 'u2@affine.pro' }); + const workspace = await models.workspace.create(user.id); + + await models.workspaceUser.setOwner(workspace.id, user2.id); + + t.true( + event.emit.lastCall.calledWith('workspace.owner.changed', { + workspaceId: workspace.id, + from: user.id, + to: user2.id, + }) + ); + + const owner2 = await models.workspaceUser.getOwner(workspace.id); + t.is(owner2.id, user2.id); +}); + +test('should get user role', async t => { + const workspace = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin); + + const role = await models.workspaceUser.get(workspace.id, user.id); + + t.is(role!.type, WorkspaceRole.Admin); +}); + +test('should get active workspace role', async t => { + const workspace = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + await models.workspaceUser.set( + workspace.id, + user.id, + WorkspaceRole.Admin, + WorkspaceMemberStatus.Accepted + ); + + const role = await models.workspaceUser.getActive(workspace.id, user.id); + + t.is(role!.type, WorkspaceRole.Admin); +}); + +test('should not get inactive workspace role', async t => { + const workspace = await create(); + + const u1 = await models.user.create({ email: 'u1@affine.pro' }); + + await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin); + + let role = await models.workspaceUser.getActive(workspace.id, u1.id); + t.is(role, null); + + await models.workspaceUser.setStatus( + workspace.id, + u1.id, + WorkspaceMemberStatus.UnderReview + ); + + role = await models.workspaceUser.getActive(workspace.id, u1.id); + t.is(role, null); +}); + +test('should update user role', async t => { + const workspace = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + await models.workspaceUser.set( + workspace.id, + user.id, + WorkspaceRole.Admin, + WorkspaceMemberStatus.Accepted + ); + const role = await models.workspaceUser.get(workspace.id, user.id); + + t.is(role!.type, WorkspaceRole.Admin); + + await models.workspaceUser.set( + workspace.id, + user.id, + WorkspaceRole.Collaborator + ); + + const role2 = await models.workspaceUser.get(workspace.id, user.id); + + t.is(role2!.type, WorkspaceRole.Collaborator); + t.deepEqual(event.emit.lastCall.args, [ + 'workspace.members.roleChanged', + { + userId: user.id, + workspaceId: workspace.id, + role: WorkspaceRole.Collaborator, + }, + ]); +}); + +test('should return workspace role if status is Accepted', async t => { + const workspace = await create(); + const u1 = await models.user.create({ email: 'u1@affine.pro' }); + + await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin); + await models.workspaceUser.setStatus( + workspace.id, + u1.id, + WorkspaceMemberStatus.Accepted + ); + const role = await models.workspaceUser.get(workspace.id, u1.id); + + t.is(role!.type, WorkspaceRole.Admin); +}); + +test('should delete workspace user role', async t => { + const workspace = await create(); + const u1 = await models.user.create({ email: 'u1@affine.pro' }); + + await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin); + await models.workspaceUser.setStatus( + workspace.id, + u1.id, + WorkspaceMemberStatus.Accepted + ); + + let role = await models.workspaceUser.get(workspace.id, u1.id); + t.is(role!.type, WorkspaceRole.Admin); + + await models.workspaceUser.delete(workspace.id, u1.id); + + role = await models.workspaceUser.get(workspace.id, u1.id); + t.is(role, null); +}); + +test('should get user workspace roles with filter', async t => { + const ws1 = await create(); + const ws2 = await create(); + const user = await models.user.create({ email: 'u1@affine.pro' }); + + await db.workspaceUserRole.createMany({ + data: [ + { + workspaceId: ws1.id, + userId: user.id, + type: WorkspaceRole.Admin, + status: WorkspaceMemberStatus.Accepted, + }, + { + workspaceId: ws2.id, + userId: user.id, + type: WorkspaceRole.Collaborator, + status: WorkspaceMemberStatus.Accepted, + }, + ], + }); + + let roles = await models.workspaceUser.getUserActiveRoles(user.id, { + role: WorkspaceRole.Admin, + }); + t.is(roles.length, 1); + t.is(roles[0].type, WorkspaceRole.Admin); + + roles = await models.workspaceUser.getUserActiveRoles(user.id); + t.is(roles.length, 2); +}); + +test('should paginate workspace user roles', async t => { + const workspace = await create(); + await db.user.createMany({ + data: Array.from({ length: 200 }, (_, i) => ({ + id: String(i), + name: `u${i}`, + email: `${i}@affine.pro`, + })), + }); + + await db.workspaceUserRole.createMany({ + data: Array.from({ length: 200 }, (_, i) => ({ + workspaceId: workspace.id, + userId: String(i), + type: WorkspaceRole.Collaborator, + status: Object.values(WorkspaceMemberStatus)[ + Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length) + ], + createdAt: new Date(Date.now() + i * 1000), + })), + }); + + const [roles, total] = await models.workspaceUser.paginate(workspace.id, { + first: 10, + offset: 0, + }); + + t.is(roles.length, 10); + t.is(total, 200); + + const [roles2] = await models.workspaceUser.paginate(workspace.id, { + after: roles.at(-1)?.createdAt.toISOString(), + first: 50, + offset: 0, + }); + + t.is(roles2.length, 50); + t.deepEqual( + roles2.map(r => r.id), + roles2 + .toSorted((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(r => r.id) + ); +}); diff --git a/packages/backend/server/src/__tests__/models/workspace.spec.ts b/packages/backend/server/src/__tests__/models/workspace.spec.ts index 435a277555..b2f09b37bb 100644 --- a/packages/backend/server/src/__tests__/models/workspace.spec.ts +++ b/packages/backend/server/src/__tests__/models/workspace.spec.ts @@ -1,9 +1,7 @@ -import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; -import Sinon from 'sinon'; -import { Config, EventBus } from '../../base'; -import { WorkspaceRole } from '../../core/permission'; +import { Config } from '../../base'; import { UserModel } from '../../models/user'; import { WorkspaceModel } from '../../models/workspace'; import { createTestingModule, type TestingModule } from '../utils'; @@ -82,1001 +80,3 @@ test('should delete workspace', async t => { // delete again should not throw await t.context.workspace.delete(workspace.id); }); - -test('should workspace owner has all permissions', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - let allowed = await t.context.workspace.isMember( - workspace.id, - user.id, - WorkspaceRole.Owner - ); - t.is(allowed, true); - allowed = await t.context.workspace.isMember( - workspace.id, - user.id, - WorkspaceRole.Admin - ); - t.is(allowed, true); - allowed = await t.context.workspace.isMember( - workspace.id, - user.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, true); - allowed = await t.context.workspace.isMember( - workspace.id, - user.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, true); -}); - -test('should workspace admin has all permissions except owner', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - await t.context.db.workspaceUserPermission.create({ - data: { - workspaceId: workspace.id, - userId: otherUser.id, - type: WorkspaceRole.Admin, - status: WorkspaceMemberStatus.Accepted, - }, - }); - let allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Owner - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Admin - ); - t.is(allowed, true); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, true); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, true); -}); - -test('should workspace write has write and read permissions', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - await t.context.db.workspaceUserPermission.create({ - data: { - workspaceId: workspace.id, - userId: otherUser.id, - type: WorkspaceRole.Collaborator, - status: WorkspaceMemberStatus.Accepted, - }, - }); - let allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Owner - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Admin - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, true); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, true); -}); - -test('should workspace read has read permission only', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - await t.context.db.workspaceUserPermission.create({ - data: { - workspaceId: workspace.id, - userId: otherUser.id, - type: WorkspaceRole.Collaborator, - status: WorkspaceMemberStatus.Accepted, - }, - }); - let allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Owner - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Admin - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, true); -}); - -test('should user not in workspace has no permissions', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - let allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Owner - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Admin - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, false); - allowed = await t.context.workspace.isMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator - ); - t.is(allowed, false); -}); - -test('should find user accessible workspaces', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const workspace1 = await t.context.workspace.create(user.id); - const workspace2 = await t.context.workspace.create(user.id); - await t.context.workspace.create(otherUser.id); - const workspaces = await t.context.workspace.findAccessibleWorkspaces( - user.id - ); - t.is(workspaces.length, 2); - t.deepEqual( - workspaces.map(w => w.workspace.id), - [workspace1.id, workspace2.id] - ); -}); - -test('should grant member with read permission and Pending status by default', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - - const event = t.context.module.get(EventBus); - const updatedSpy = Sinon.spy(); - event.on('workspace.members.updated', updatedSpy); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.Pending); - - // grant again should do nothing - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id - ); - t.deepEqual(member1, member2); - t.true( - updatedSpy.calledOnceWith({ - workspaceId: workspace.id, - count: 2, - }) - ); -}); - -test('should grant Pending status member to Accepted status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.Pending); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - t.is(member2.workspaceId, workspace.id); - t.is(member2.userId, otherUser.id); - t.is(member2.type, WorkspaceRole.Collaborator); - t.is(member2.status, WorkspaceMemberStatus.Accepted); -}); - -test('should grant new owner and change exists owner to admin', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.Accepted); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Owner, - WorkspaceMemberStatus.Accepted - ); - t.is(member2.workspaceId, workspace.id); - t.is(member2.userId, otherUser.id); - 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, WorkspaceRole.Admin); - t.is(owner!.status, WorkspaceMemberStatus.Accepted); -}); - -test('should grant write permission on exists member', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.Accepted); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - t.is(member2.workspaceId, workspace.id); - t.is(member2.userId, otherUser.id); - t.is(member2.type, WorkspaceRole.Collaborator); - t.is(member2.status, WorkspaceMemberStatus.Accepted); -}); - -test('should grant UnderReview status member to Accepted status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.UnderReview - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.UnderReview); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - t.is(member2.workspaceId, workspace.id); - t.is(member2.userId, otherUser.id); - t.is(member2.type, WorkspaceRole.Collaborator); - t.is(member2.status, WorkspaceMemberStatus.Accepted); -}); - -test('should grant NeedMoreSeat status member to Pending status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.NeedMoreSeat - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeat); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Pending - ); - t.is(member2.workspaceId, workspace.id); - t.is(member2.userId, otherUser.id); - t.is(member2.type, WorkspaceRole.Collaborator); - t.is(member2.status, WorkspaceMemberStatus.Pending); -}); - -test('should grant NeedMoreSeatAndReview status member to UnderReview status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.NeedMoreSeatAndReview - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeatAndReview); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.UnderReview - ); - t.is(member2.workspaceId, workspace.id); - t.is(member2.userId, otherUser.id); - t.is(member2.type, WorkspaceRole.Collaborator); - t.is(member2.status, WorkspaceMemberStatus.UnderReview); -}); - -test('should grant Pending status member to write permission and Accepted status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.Pending); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - t.is(member2.workspaceId, workspace.id); - t.is(member2.userId, otherUser.id); - // TODO(fengmk2): fix this - // t.is(member2.type, WorkspaceRole.Collaborator); - t.is(member2.status, WorkspaceMemberStatus.Accepted); -}); - -test('should grant no thing on invalid status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member1 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.NeedMoreSeat - ); - t.is(member1.workspaceId, workspace.id); - t.is(member1.userId, otherUser.id); - t.is(member1.type, WorkspaceRole.Collaborator); - t.is(member1.status, WorkspaceMemberStatus.NeedMoreSeat); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - t.is(member2.workspaceId, workspace.id); - t.is(member2.userId, otherUser.id); - t.is(member2.type, WorkspaceRole.Collaborator); - t.is(member2.status, WorkspaceMemberStatus.NeedMoreSeat); -}); - -test('should get the accepted status workspace member', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - const member = await t.context.workspace.getMember( - workspace.id, - otherUser.id - ); - t.is(member!.workspaceId, workspace.id); - t.is(member!.userId, otherUser.id); - t.is(member!.type, WorkspaceRole.Collaborator); - t.is(member!.status, WorkspaceMemberStatus.Accepted); -}); - -test('should get any status workspace member, including pending and accepted', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test2@affine.pro', - }); - await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Pending - ); - const member = await t.context.workspace.getMemberInAnyStatus( - workspace.id, - otherUser.id - ); - t.is(member!.workspaceId, workspace.id); - t.is(member!.userId, otherUser.id); - t.is(member!.type, WorkspaceRole.Collaborator); - t.is(member!.status, WorkspaceMemberStatus.Pending); -}); - -test('should get workspace owner by workspace id', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - 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, WorkspaceRole.Owner); - t.is(owner!.status, WorkspaceMemberStatus.Accepted); - t.truthy(owner!.user); - t.deepEqual(owner!.user, user); -}); - -test('should find workspace admin by workspace id', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser1 = await t.context.user.create({ - email: 'tes1@affine.pro', - }); - const otherUser2 = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const otherUser3 = await t.context.user.create({ - email: 'test3@affine.pro', - }); - await t.context.workspace.grantMember( - workspace.id, - otherUser1.id, - WorkspaceRole.Admin, - WorkspaceMemberStatus.Accepted - ); - await t.context.workspace.grantMember( - workspace.id, - otherUser2.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - // pending member should not be admin - await t.context.workspace.grantMember( - workspace.id, - otherUser3.id, - 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, WorkspaceRole.Admin); - t.is(members[0].status, WorkspaceMemberStatus.Accepted); -}); - -test('should find workspace ids by owner id', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace1 = await t.context.workspace.create(user.id); - const workspace2 = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'tes1@affine.pro', - }); - await t.context.workspace.create(otherUser.id); - const workspaceIds = await t.context.workspace.findOwnedIds(user.id); - t.deepEqual(workspaceIds, [workspace1.id, workspace2.id]); -}); - -test('should the workspace member total count, including pending and accepted', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser1 = await t.context.user.create({ - email: 'tes1@affine.pro', - }); - const otherUser2 = await t.context.user.create({ - email: 'test2@affine.pro', - }); - await t.context.workspace.grantMember( - workspace.id, - otherUser1.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Pending - ); - await t.context.workspace.grantMember( - workspace.id, - otherUser2.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - const count = await t.context.workspace.getMemberTotalCount(workspace.id); - t.is(count, 3); -}); - -test('should the workspace member used count, only count the accepted member', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser1 = await t.context.user.create({ - email: 'tes1@affine.pro', - }); - const otherUser2 = await t.context.user.create({ - email: 'test2@affine.pro', - }); - await t.context.workspace.grantMember( - workspace.id, - otherUser1.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Pending - ); - await t.context.workspace.grantMember( - workspace.id, - otherUser2.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - const count = await t.context.workspace.getMemberUsedCount(workspace.id); - t.is(count, 2); -}); - -test('should accept workspace member invitation', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser1 = await t.context.user.create({ - email: 'test1@affine.pro', - }); - const member = await t.context.workspace.grantMember( - workspace.id, - otherUser1.id - ); - t.is(member.status, WorkspaceMemberStatus.Pending); - let success = await t.context.workspace.acceptMemberInvitation( - member.id, - workspace.id - ); - t.is(success, true); - const member1 = await t.context.workspace.getMemberInAnyStatus( - workspace.id, - otherUser1.id - ); - t.is(member1!.status, WorkspaceMemberStatus.Accepted); - // accept again should do nothing - success = await t.context.workspace.acceptMemberInvitation( - member.id, - workspace.id - ); - t.is(success, false); - const member2 = await t.context.workspace.getMember( - workspace.id, - otherUser1.id - ); - t.deepEqual(member1, member2); - - // accept with UnderReview status - const otherUser2 = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const member3 = await t.context.workspace.grantMember( - workspace.id, - otherUser2.id - ); - t.is(member3.status, WorkspaceMemberStatus.Pending); - success = await t.context.workspace.acceptMemberInvitation( - member3.id, - workspace.id, - WorkspaceMemberStatus.UnderReview - ); - t.is(success, true); - const member4 = await t.context.workspace.getMember( - workspace.id, - otherUser2.id - ); - t.is(member4!.status, WorkspaceMemberStatus.UnderReview); - // accept again should do nothing - success = await t.context.workspace.acceptMemberInvitation( - member3.id, - workspace.id, - WorkspaceMemberStatus.UnderReview - ); - t.is(success, false); -}); - -test('should delete workspace member in Pending, Accepted status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test1@affine.pro', - }); - const member = await t.context.workspace.grantMember( - workspace.id, - otherUser.id - ); - t.is(member.status, WorkspaceMemberStatus.Pending); - - const event = t.context.module.get(EventBus); - const updatedSpy = Sinon.spy(); - event.on('workspace.members.updated', updatedSpy); - let success = await t.context.workspace.deleteMember( - workspace.id, - otherUser.id - ); - t.is(success, true); - const member1 = await t.context.workspace.getMember( - workspace.id, - otherUser.id - ); - t.is(member1, null); - t.true( - updatedSpy.calledOnceWith({ - workspaceId: workspace.id, - count: 1, - }) - ); - - // delete again should do nothing - success = await t.context.workspace.deleteMember(workspace.id, otherUser.id); - t.is(success, false); - - const member2 = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - t.is(member2.status, WorkspaceMemberStatus.Accepted); - success = await t.context.workspace.deleteMember(workspace.id, otherUser.id); - t.is(success, true); -}); - -test('should trigger workspace.members.requestDeclined event when delete workspace member in UnderReview status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test1@affine.pro', - }); - const member = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.UnderReview - ); - t.is(member.status, WorkspaceMemberStatus.UnderReview); - - const event = t.context.module.get(EventBus); - const updatedSpy = Sinon.spy(); - const requestDeclinedSpy = Sinon.spy(); - event.on('workspace.members.updated', updatedSpy); - event.on('workspace.members.requestDeclined', requestDeclinedSpy); - let success = await t.context.workspace.deleteMember( - workspace.id, - otherUser.id - ); - t.is(success, true); - const member1 = await t.context.workspace.getMember( - workspace.id, - otherUser.id - ); - t.is(member1, null); - t.true( - updatedSpy.calledOnceWith({ - workspaceId: workspace.id, - count: 1, - }) - ); - t.true( - requestDeclinedSpy.calledOnceWith({ - workspaceId: workspace.id, - userId: otherUser.id, - }) - ); -}); - -test('should trigger workspace.members.requestDeclined event when delete workspace member in NeedMoreSeatAndReview status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser = await t.context.user.create({ - email: 'test1@affine.pro', - }); - const member = await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.NeedMoreSeatAndReview - ); - t.is(member.status, WorkspaceMemberStatus.NeedMoreSeatAndReview); - - const event = t.context.module.get(EventBus); - const updatedSpy = Sinon.spy(); - const requestDeclinedSpy = Sinon.spy(); - event.on('workspace.members.updated', updatedSpy); - event.on('workspace.members.requestDeclined', requestDeclinedSpy); - let success = await t.context.workspace.deleteMember( - workspace.id, - otherUser.id - ); - t.is(success, true); - const member1 = await t.context.workspace.getMember( - workspace.id, - otherUser.id - ); - t.is(member1, null); - t.true( - updatedSpy.calledOnceWith({ - workspaceId: workspace.id, - count: 1, - }) - ); - t.true( - requestDeclinedSpy.calledOnceWith({ - workspaceId: workspace.id, - userId: otherUser.id, - }) - ); -}); - -test('should refresh member seat status', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser1 = await t.context.user.create({ - email: 'test1@affine.pro', - }); - const otherUser2 = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const otherUser3 = await t.context.user.create({ - email: 'test3@affine.pro', - }); - await t.context.workspace.grantMember( - workspace.id, - otherUser1.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.NeedMoreSeatAndReview - ); - await t.context.workspace.grantMember( - workspace.id, - otherUser2.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Pending - ); - await t.context.workspace.grantMember( - workspace.id, - otherUser3.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.NeedMoreSeat - ); - let count = await t.context.db.workspaceUserPermission.count({ - where: { - workspaceId: workspace.id, - status: WorkspaceMemberStatus.Pending, - }, - }); - t.is(count, 1); - // available not enough - await t.context.workspace.refreshMemberSeatStatus(workspace.id, 1); - count = await t.context.db.workspaceUserPermission.count({ - where: { - workspaceId: workspace.id, - status: WorkspaceMemberStatus.Pending, - }, - }); - t.is(count, 1); - - // available enough - await t.context.workspace.refreshMemberSeatStatus(workspace.id, 3); - // pending member should be 2 and under review member should be 1 - count = await t.context.db.workspaceUserPermission.count({ - where: { - workspaceId: workspace.id, - status: WorkspaceMemberStatus.Pending, - }, - }); - t.is(count, 2); - count = await t.context.db.workspaceUserPermission.count({ - where: { - workspaceId: workspace.id, - status: WorkspaceMemberStatus.UnderReview, - }, - }); - t.is(count, 1); - - // again should do nothing - await t.context.workspace.refreshMemberSeatStatus(workspace.id, 3); - count = await t.context.db.workspaceUserPermission.count({ - where: { - workspaceId: workspace.id, - status: WorkspaceMemberStatus.Pending, - }, - }); - t.is(count, 2); -}); - -test('should find the workspace members order by type:desc and createdAt:asc', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - for (let i = 0; i < 10; i++) { - const otherUser = await t.context.user.create({ - email: `test${i}@affine.pro`, - }); - await t.context.workspace.grantMember( - workspace.id, - otherUser.id, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - } - let members = await t.context.workspace.findMembers(workspace.id); - t.is(members.length, 8); - 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, 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, WorkspaceRole.Owner); - t.is(members[0].status, WorkspaceMemberStatus.Accepted); - for (let i = 1; i < 11; i++) { - 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, WorkspaceRole.Collaborator); -}); - -test('should get the workspace member invitation', async t => { - const user = await t.context.user.create({ - email: 'test@affine.pro', - }); - const workspace = await t.context.workspace.create(user.id); - const otherUser1 = await t.context.user.create({ - email: 'test2@affine.pro', - }); - const invitation = await t.context.workspace.grantMember( - workspace.id, - otherUser1.id - ); - t.is(invitation.status, WorkspaceMemberStatus.Pending); - const invitation1 = await t.context.workspace.getMemberInvitation( - invitation.id - ); - t.deepEqual(invitation, invitation1); -}); diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts index 5aa05e4fd9..1420ed892b 100644 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ b/packages/backend/server/src/__tests__/team.e2e.ts @@ -11,7 +11,7 @@ import { AppModule } from '../app.module'; import { EventBus } from '../base'; import { AuthService } from '../core/auth'; import { DocReader } from '../core/doc'; -import { DocRole, PermissionService, WorkspaceRole } from '../core/permission'; +import { DocRole, WorkspaceRole } from '../core/permission'; import { WorkspaceType } from '../core/workspaces'; import { Models } from '../models'; import { @@ -43,7 +43,6 @@ const test = ava as TestFn<{ auth: AuthService; event: Sinon.SinonStubbedInstance; models: Models; - permissions: PermissionService; }>; test.before(async t => { @@ -68,7 +67,6 @@ test.before(async t => { t.context.auth = app.get(AuthService); t.context.event = app.get(EventBus); t.context.models = app.get(Models); - t.context.permissions = app.get(PermissionService); }); test.beforeEach(async t => { @@ -256,7 +254,7 @@ test('should be able to invite multiple users', async t => { }); test('should be able to check seat limit', async t => { - const { app, permissions, models } = t.context; + const { app, models } = t.context; const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 5); { @@ -284,10 +282,8 @@ test('should be able to check seat limit', async t => { ); t.is( - await permissions.getWorkspaceMemberStatus( - ws.id, - (await members1)[0][0].id - ), + (await models.workspaceUser.get(ws.id, (await members1)[0][0].id)) + ?.status, WorkspaceMemberStatus.NeedMoreSeat, 'should be able to check member status' ); @@ -295,18 +291,16 @@ test('should be able to check seat limit', async t => { // refresh seat, fifo await sleep(1000); const [[members2]] = await inviteBatch(['member6@affine.pro']); - await permissions.refreshSeatStatus(ws.id, 7); + await models.workspaceUser.refresh(ws.id, 7); t.is( - await permissions.getWorkspaceMemberStatus( - ws.id, - (await members1)[0][0].id - ), + (await models.workspaceUser.get(ws.id, (await members1)[0][0].id)) + ?.status, WorkspaceMemberStatus.Pending, 'should become accepted after refresh' ); t.is( - await permissions.getWorkspaceMemberStatus(ws.id, members2.id), + (await models.workspaceUser.get(ws.id, members2.id))?.status, WorkspaceMemberStatus.NeedMoreSeat, 'should not change status' ); @@ -314,7 +308,7 @@ test('should be able to check seat limit', async t => { }); test('should be able to grant team member permission', async t => { - const { app, permissions } = t.context; + const { app, models } = t.context; const { owner, teamWorkspace: ws, write, read } = await init(app); app.switchUser(read); @@ -335,11 +329,8 @@ test('should be able to grant team member permission', async t => { // owner should be able to grant permission app.switchUser(owner); t.true( - await permissions.tryCheckWorkspaceIs( - ws.id, - read.id, - WorkspaceRole.Collaborator - ), + (await models.workspaceUser.get(ws.id, read.id))?.type === + WorkspaceRole.Collaborator, 'should be able to check permission' ); t.truthy( @@ -347,11 +338,8 @@ 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, - WorkspaceRole.Admin - ), + (await models.workspaceUser.get(ws.id, read.id))?.type === + WorkspaceRole.Admin, 'should be able to check permission' ); } @@ -362,10 +350,9 @@ test('should be able to leave workspace', async t => { const { owner, teamWorkspace: ws, admin, write, read } = await init(app); app.switchUser(owner); - t.false( - await leaveWorkspace(app, ws.id), - 'owner should not be able to leave workspace' - ); + await t.throwsAsync(leaveWorkspace(app, ws.id), { + message: 'Owner can not leave the workspace.', + }); app.switchUser(admin); t.true( @@ -425,10 +412,9 @@ test('should be able to revoke team member', async t => { 'owner should be able to revoke member' ); - t.false( - await revokeUser(app, ws.id, owner.id), - 'should not be able to revoke themselves' - ); + await t.throwsAsync(revokeUser(app, ws.id, owner.id), { + message: 'You can not revoke your own permission.', + }); await revokeUser(app, ws.id, admin.id); app.switchUser(admin); @@ -508,7 +494,7 @@ test('should be able to approve team member', async t => { const memberInvite = members.find(m => m.id === member.id)!; t.is(memberInvite.status, 'UnderReview', 'should be under review'); - t.is(await approveMember(app, tws.id, member.id), memberInvite.inviteId); + t.true(await approveMember(app, tws.id, member.id)); } { @@ -536,7 +522,7 @@ test('should be able to approve team member', async t => { }); test('should be able to invite by link', async t => { - const { app, permissions, models } = t.context; + const { app, models } = t.context; const { createInviteLink, owner, @@ -562,10 +548,10 @@ test('should be able to invite by link', async t => { // invite link for (const [i] of Array.from({ length: 5 }).entries()) { const user = await invite(`test${i}@affine.pro`); - const status = await permissions.getWorkspaceMemberStatus(ws.id, user.id); + const status = (await models.workspaceUser.get(ws.id, user.id))?.status; t.is( status, - WorkspaceMemberStatus.Accepted, + WorkspaceMemberStatus.UnderReview, 'should be able to check status' ); } @@ -587,12 +573,12 @@ test('should be able to invite by link', async t => { const [m3, m4] = members; t.is( - await permissions.getWorkspaceMemberStatus(tws.id, m3.id), + (await models.workspaceUser.get(tws.id, m3.id))?.status, WorkspaceMemberStatus.NeedMoreSeatAndReview, 'should not change status' ); t.is( - await permissions.getWorkspaceMemberStatus(tws.id, m4.id), + (await models.workspaceUser.get(tws.id, m4.id))?.status, WorkspaceMemberStatus.NeedMoreSeatAndReview, 'should not change status' ); @@ -600,14 +586,14 @@ test('should be able to invite by link', async t => { models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', { memberLimit: 6, }); - await permissions.refreshSeatStatus(tws.id, 6); + await models.workspaceUser.refresh(tws.id, 6); t.is( - await permissions.getWorkspaceMemberStatus(tws.id, m3.id), + (await models.workspaceUser.get(tws.id, m3.id))?.status, WorkspaceMemberStatus.UnderReview, 'should not change status' ); t.is( - await permissions.getWorkspaceMemberStatus(tws.id, m4.id), + (await models.workspaceUser.get(tws.id, m4.id))?.status, WorkspaceMemberStatus.NeedMoreSeatAndReview, 'should not change status' ); @@ -615,9 +601,9 @@ test('should be able to invite by link', async t => { models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', { memberLimit: 7, }); - await permissions.refreshSeatStatus(tws.id, 7); + await models.workspaceUser.refresh(tws.id, 7); t.is( - await permissions.getWorkspaceMemberStatus(tws.id, m4.id), + (await models.workspaceUser.get(tws.id, m4.id))?.status, WorkspaceMemberStatus.UnderReview, 'should not change status' ); @@ -665,6 +651,7 @@ test('should be able to emit events', async t => { const { teamWorkspace: tws, owner, createInviteLink } = await init(app); const [, invite] = await createInviteLink(tws); const user = await invite('m3@affine.pro'); + app.switchUser(owner); const { members } = await getWorkspace(app, tws.id); const memberInvite = members.find(m => m.id === user.id)!; t.deepEqual( @@ -698,7 +685,7 @@ test('should be able to emit events', async t => { { userId: read.id, workspaceId: tws.id, - permission: WorkspaceRole.Admin, + role: WorkspaceRole.Admin, }, ], 'should emit role changed event' @@ -712,7 +699,7 @@ test('should be able to emit events', async t => { t.deepEqual( ownershipTransferred, [ - 'workspace.members.ownershipTransferred', + 'workspace.owner.changed', { from: owner.id, to: read.id, workspaceId: tws.id }, ], 'should emit owner transferred event' @@ -880,20 +867,15 @@ test('should be able to grant and revoke doc user role', async t => { grantDocUserRoles: true, }); - // external user now can manage the page + // external user can never be able to manage the page { app.switchUser(external); - const externalRes = await grantDocUserRoles( - app, - ws.id, - docId, - [read.id], - DocRole.Manager + await t.throwsAsync( + grantDocUserRoles(app, ws.id, docId, [read.id], DocRole.Manager), + { + message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`, + } ); - - t.deepEqual(externalRes, { - grantDocUserRoles: true, - }); } // revoke the role of the external user diff --git a/packages/backend/server/src/__tests__/utils/blobs.ts b/packages/backend/server/src/__tests__/utils/blobs.ts index 9e09a7f0e8..6aa2f34922 100644 --- a/packages/backend/server/src/__tests__/utils/blobs.ts +++ b/packages/backend/server/src/__tests__/utils/blobs.ts @@ -1,15 +1,24 @@ +import { type Blob } from '@prisma/client'; + import { TestingApp } from './testing-app'; export async function listBlobs( app: TestingApp, workspaceId: string -): Promise { +): Promise { const res = await app.gql(` query { - listBlobs(workspaceId: "${workspaceId}") + workspace(id: "${workspaceId}") { + blobs { + key + mime + size + createdAt + } + } } `); - return res.listBlobs; + return res.workspace.blobs; } export async function getWorkspaceBlobsSize( diff --git a/packages/backend/server/src/__tests__/workspace.e2e.ts b/packages/backend/server/src/__tests__/workspace.e2e.ts index 5489b6d230..7affaac35c 100644 --- a/packages/backend/server/src/__tests__/workspace.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace.e2e.ts @@ -94,7 +94,7 @@ test('should visit public page', async t => { const docs2 = await getWorkspacePublicDocs(app, workspace.id); t.is(docs2.length, 0, 'failed to get shared docs'); await t.throwsAsync(revokePublicDoc(app, workspace.id, 'doc3'), { - message: 'Doc is not public', + message: 'Doc is not public.', }); const docs3 = await getWorkspacePublicDocs(app, workspace.id); diff --git a/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts b/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts index bf3e1ccdf0..5e09a41d36 100644 --- a/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts @@ -77,7 +77,7 @@ test('should list blobs', async t => { const ret = await listBlobs(app, workspace.id); t.is(ret.length, 2, 'failed to list blobs'); // list blob result is not ordered - t.deepEqual(ret.sort(), [hash1, hash2].sort()); + t.deepEqual(ret.map(x => x.key).sort(), [hash1, hash2].sort()); }); test('should auto delete blobs when workspace is deleted', async t => { diff --git a/packages/backend/server/src/__tests__/workspace/controller.spec.ts b/packages/backend/server/src/__tests__/workspace/controller.spec.ts index d2f1f190d7..582cc47e4d 100644 --- a/packages/backend/server/src/__tests__/workspace/controller.spec.ts +++ b/packages/backend/server/src/__tests__/workspace/controller.spec.ts @@ -7,6 +7,7 @@ import Sinon from 'sinon'; import { PgWorkspaceDocStorageAdapter } from '../../core/doc'; import { WorkspaceBlobStorage } from '../../core/storage'; +import { Models, WorkspaceRole } from '../../models'; import { createTestingApp, TestingApp, TestUser } from '../utils'; const test = ava as TestFn<{ @@ -15,6 +16,7 @@ const test = ava as TestFn<{ u1: TestUser; storage: Sinon.SinonStubbedInstance; workspace: Sinon.SinonStubbedInstance; + models: Models; }>; test.before(async t => { @@ -34,6 +36,7 @@ test.before(async t => { t.context.app = app; t.context.storage = app.get(WorkspaceBlobStorage); t.context.workspace = app.get(PgWorkspaceDocStorageAdapter); + t.context.models = app.get(Models); await db.workspaceDoc.create({ data: { @@ -155,17 +158,14 @@ test('should not be able to get private workspace with no public pages', async t }); test('should be able to get permission granted workspace', async t => { - const { app, db, storage } = t.context; + const { app, storage } = t.context; - await db.workspaceUserPermission.create({ - data: { - workspaceId: 'totally-private', - userId: t.context.u1.id, - type: 1, - accepted: true, - status: WorkspaceMemberStatus.Accepted, - }, - }); + await t.context.models.workspaceUser.set( + 'totally-private', + t.context.u1.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.Accepted + ); storage.get.resolves(blob()); await app.login(t.context.u1); diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index e44f4f61bf..52889b088d 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -419,6 +419,14 @@ export const USER_FRIENDLY_ERRORS = { args: { spaceId: 'string' }, message: 'Space should have only one owner.', }, + owner_can_not_leave_workspace: { + type: 'action_forbidden', + message: 'Owner can not leave the workspace.', + }, + can_not_revoke_yourself: { + type: 'action_forbidden', + message: 'You can not revoke your own permission.', + }, doc_not_found: { type: 'resource_not_found', args: { spaceId: 'string', docId: 'string' }, diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index 6bbad70b39..3d081d2cc3 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -298,6 +298,18 @@ export class SpaceShouldHaveOnlyOneOwner extends UserFriendlyError { super('invalid_input', 'space_should_have_only_one_owner', message, args); } } + +export class OwnerCanNotLeaveWorkspace extends UserFriendlyError { + constructor(message?: string) { + super('action_forbidden', 'owner_can_not_leave_workspace', message); + } +} + +export class CanNotRevokeYourself extends UserFriendlyError { + constructor(message?: string) { + super('action_forbidden', 'can_not_revoke_yourself', message); + } +} @ObjectType() class DocNotFoundDataType { @Field() spaceId!: string @@ -855,6 +867,8 @@ export enum ErrorNames { SPACE_ACCESS_DENIED, SPACE_OWNER_NOT_FOUND, SPACE_SHOULD_HAVE_ONLY_ONE_OWNER, + OWNER_CAN_NOT_LEAVE_WORKSPACE, + CAN_NOT_REVOKE_YOURSELF, DOC_NOT_FOUND, DOC_ACTION_DENIED, VERSION_REJECTED, diff --git a/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts b/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts index a0b2d1d5de..fc277e45b9 100644 --- a/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts +++ b/packages/backend/server/src/core/doc-renderer/__tests__/controller.spec.ts @@ -10,14 +10,12 @@ import { Config } from '../../../base'; import { ConfigModule } from '../../../base/config'; import { Models } from '../../../models'; import { PgWorkspaceDocStorageAdapter } from '../../doc'; -import { PermissionService } from '../../permission'; const test = ava as TestFn<{ models: Models; app: TestingApp; config: Config; adapter: PgWorkspaceDocStorageAdapter; - permission: PermissionService; }>; test.before(async t => { @@ -38,7 +36,6 @@ test.before(async t => { t.context.models = app.get(Models); t.context.config = app.get(Config); t.context.adapter = app.get(PgWorkspaceDocStorageAdapter); - t.context.permission = app.get(PermissionService); t.context.app = app; }); @@ -60,7 +57,7 @@ test.after.always(async t => { test('should render page success', async t => { const docId = randomUUID(); - const { app, adapter, permission } = t.context; + const { app, adapter, models } = t.context; const doc = new YDoc(); const text = doc.getText('content'); @@ -75,7 +72,7 @@ test('should render page success', async t => { text.insert(5, ' '); await adapter.pushDocUpdates(workspace.id, docId, updates, user.id); - await permission.publishPage(workspace.id, docId); + await models.workspace.publishDoc(workspace.id, docId); await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200); t.pass(); diff --git a/packages/backend/server/src/core/doc-renderer/controller.ts b/packages/backend/server/src/core/doc-renderer/controller.ts index e85481363a..5875f9f919 100644 --- a/packages/backend/server/src/core/doc-renderer/controller.ts +++ b/packages/backend/server/src/core/doc-renderer/controller.ts @@ -6,10 +6,10 @@ import type { Request, Response } from 'express'; import isMobile from 'is-mobile'; import { Config, metrics } from '../../base'; +import { Models } from '../../models'; import { htmlSanitize } from '../../native'; import { Public } from '../auth'; import { DocReader } from '../doc'; -import { PermissionService } from '../permission'; interface RenderOptions { title: string; @@ -51,8 +51,8 @@ export class DocRendererController { constructor( private readonly doc: DocReader, - private readonly permission: PermissionService, - private readonly config: Config + private readonly config: Config, + private readonly models: Models ) { this.webAssets = this.readHtmlAssets( join(this.config.projectRoot, 'static') @@ -102,14 +102,15 @@ export class DocRendererController { workspaceId: string, docId: string ): Promise { - let allowUrlPreview = await this.permission.isPublicPage( + let allowUrlPreview = await this.models.workspace.isPublicPage( workspaceId, docId ); if (!allowUrlPreview) { // if page is private, but workspace url preview is on - allowUrlPreview = await this.permission.allowUrlPreview(workspaceId); + allowUrlPreview = + await this.models.workspace.allowUrlPreview(workspaceId); } if (allowUrlPreview) { @@ -122,7 +123,8 @@ export class DocRendererController { private async getWorkspaceContent( workspaceId: string ): Promise { - const allowUrlPreview = await this.permission.allowUrlPreview(workspaceId); + const allowUrlPreview = + await this.models.workspace.allowUrlPreview(workspaceId); if (allowUrlPreview) { const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); diff --git a/packages/backend/server/src/core/doc/options.ts b/packages/backend/server/src/core/doc/options.ts index 807887aa7b..4793a1cbf9 100644 --- a/packages/backend/server/src/core/doc/options.ts +++ b/packages/backend/server/src/core/doc/options.ts @@ -9,7 +9,6 @@ import { metrics, Runtime, } from '../../base'; -import { PermissionService } from '../permission'; import { QuotaService } from '../quota'; import { DocStorageOptions as IDocStorageOptions } from './storage'; @@ -37,7 +36,6 @@ export class DocStorageOptions implements IDocStorageOptions { constructor( private readonly config: Config, private readonly runtime: Runtime, - private readonly permission: PermissionService, private readonly quota: QuotaService ) {} @@ -87,8 +85,7 @@ export class DocStorageOptions implements IDocStorageOptions { }; historyMaxAge = async (spaceId: string) => { - const owner = await this.permission.getWorkspaceOwner(spaceId); - const quota = await this.quota.getUserQuota(owner.id); + const quota = await this.quota.getWorkspaceQuota(spaceId); return quota.historyPeriod; }; diff --git a/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.md b/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.md index 72c15d4960..687e1a3233 100644 --- a/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.md +++ b/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.md @@ -91,13 +91,20 @@ Generated by [AVA](https://avajs.dev). > WorkspaceRole: External { + 'Workspace.Adminitrators.Manage': false, + 'Workspace.Blobs.List': false, + 'Workspace.Blobs.Read': true, + 'Workspace.Blobs.Write': false, + 'Workspace.Copilot': false, 'Workspace.CreateDoc': false, 'Workspace.Delete': false, 'Workspace.Organize.Read': true, + 'Workspace.Payment.Manage': false, 'Workspace.Properties.Create': false, 'Workspace.Properties.Delete': false, - 'Workspace.Properties.Read': false, + 'Workspace.Properties.Read': true, 'Workspace.Properties.Update': false, + 'Workspace.Read': true, 'Workspace.Settings.Read': false, 'Workspace.Settings.Update': false, 'Workspace.Sync': false, @@ -109,13 +116,20 @@ Generated by [AVA](https://avajs.dev). > WorkspaceRole: Collaborator { + 'Workspace.Adminitrators.Manage': false, + 'Workspace.Blobs.List': true, + 'Workspace.Blobs.Read': true, + 'Workspace.Blobs.Write': true, + 'Workspace.Copilot': true, 'Workspace.CreateDoc': true, 'Workspace.Delete': false, 'Workspace.Organize.Read': true, + 'Workspace.Payment.Manage': false, 'Workspace.Properties.Create': false, 'Workspace.Properties.Delete': false, 'Workspace.Properties.Read': true, 'Workspace.Properties.Update': false, + 'Workspace.Read': true, 'Workspace.Settings.Read': true, 'Workspace.Settings.Update': false, 'Workspace.Sync': true, @@ -127,13 +141,20 @@ Generated by [AVA](https://avajs.dev). > WorkspaceRole: Admin { + 'Workspace.Adminitrators.Manage': false, + 'Workspace.Blobs.List': true, + 'Workspace.Blobs.Read': true, + 'Workspace.Blobs.Write': true, + 'Workspace.Copilot': true, 'Workspace.CreateDoc': true, 'Workspace.Delete': false, 'Workspace.Organize.Read': true, + 'Workspace.Payment.Manage': false, 'Workspace.Properties.Create': true, 'Workspace.Properties.Delete': true, 'Workspace.Properties.Read': true, 'Workspace.Properties.Update': true, + 'Workspace.Read': true, 'Workspace.Settings.Read': true, 'Workspace.Settings.Update': true, 'Workspace.Sync': true, @@ -145,13 +166,20 @@ Generated by [AVA](https://avajs.dev). > WorkspaceRole: Owner { + 'Workspace.Adminitrators.Manage': true, + 'Workspace.Blobs.List': true, + 'Workspace.Blobs.Read': true, + 'Workspace.Blobs.Write': true, + 'Workspace.Copilot': true, 'Workspace.CreateDoc': true, 'Workspace.Delete': true, 'Workspace.Organize.Read': true, + 'Workspace.Payment.Manage': true, 'Workspace.Properties.Create': true, 'Workspace.Properties.Delete': true, 'Workspace.Properties.Read': true, 'Workspace.Properties.Update': true, + 'Workspace.Read': true, 'Workspace.Settings.Read': true, 'Workspace.Settings.Update': true, 'Workspace.Sync': true, @@ -257,13 +285,20 @@ Generated by [AVA](https://avajs.dev). > Snapshot 1 { + 'Workspace.Adminitrators.Manage': 'Owner', + 'Workspace.Blobs.List': 'Collaborator', + 'Workspace.Blobs.Read': 'External', + 'Workspace.Blobs.Write': 'Collaborator', + 'Workspace.Copilot': 'Collaborator', 'Workspace.CreateDoc': 'Collaborator', 'Workspace.Delete': 'Owner', 'Workspace.Organize.Read': 'External', + 'Workspace.Payment.Manage': 'Owner', 'Workspace.Properties.Create': 'Admin', 'Workspace.Properties.Delete': 'Admin', - 'Workspace.Properties.Read': 'Collaborator', + 'Workspace.Properties.Read': 'External', 'Workspace.Properties.Update': 'Admin', + 'Workspace.Read': 'External', 'Workspace.Settings.Read': 'Collaborator', 'Workspace.Settings.Update': 'Admin', 'Workspace.Sync': 'Collaborator', diff --git a/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.snap b/packages/backend/server/src/core/permission/__tests__/__snapshots__/actions.spec.ts.snap index b4612b4a23629b1c55431430ff67aec18c93e260..6384bb13027f6e9dff2b56e5c275c67e3a2c64da 100644 GIT binary patch literal 1396 zcmV-)1&jJYRzVRa z3h8clBOi+h00000000B+T1{-+L>PTzXWKwXva2NJr&93&N+~55r1B#qC`~1VKtWB@ ziUT08JxNR*&(@wONx4v<2M!#NIB`Hq1wy@XffM3fID*6xRXuV z+LX;{6~B4&%{Sk?Z}zP>7MdaRE+$v6Q6BJ+uANGj*h=U-P3pMKkUE?>3&Gk-%ki1# z#4M!FLd=$(RTf`NTCPX4|GAOh0R&0pwvr{rlP|CNzRTV7HV&fwM`~!0;WKNT#*rJ+ z&~4Ii?f8XFs;6|pcdm=>z**}0G|tJ6naMJ|lB!g|WgHM<&ZGU-!4EpVM?Vdyql>~a=wEBz;`5mw!Tige{|nD3%Hn5Q-8 zd$t;1*IvwZ5!TY&=5)_gqbqHPHac-lKZ1f=dFI+9{nnqiPE$r0(=)RT>5kCV2&EGKBR5fysU z1369=NSa06TIwmlc=sJEU_IZCFDbmr`=_jW+Qp!(cCoB->0?sodVds^2-v%#lGa3j zi>l|6waucEW>L46z7|#acZ@+%?X;*w#J(@8o~xp!s9N%0wFXtSZ>mbf-3wI}$$#T~ zQT9?y@DqUB0FD#DC%{((_>BOtfEO$v zvViX_;HCvUW&?9J@Uad2Yy-D#;MfrG?hx?B(5~1Fwas6ME4vM~26?FL#8&nmYNhv7 zs=E+|=@g!XVU*ILFpQJ?6{;L+Z3|5{^ah4%HlvTx9+=j_R_zScK!dBHS)tM2P)#Yb zIW+TV4G+U!8|;q?5q4Y5CPsGzzPArCcq5>{6m1pE#P$vSlemq))a}@#A}_m)y&Pj)_GB`YE###pt`*) zW9n)1wc-seZNJyV(bM^rvny@@s+tup0!mu(LZAf7F9zxq$ZiP57XAmk@AqXhD*ynp C_?r6w literal 1217 zcmV;y1U~ygRzV6@%EHB+ll zzGQJ6oga$`00000000B+TF-CXL=^tUk(1EUWLIg6s!HGkP=18HRH{_yfhws$NGPan zKyYX^YflolUXN?fDqC(9RGdIU;)sx1!GUWf_!Cg!AK(r*xN$+^gx2=1y|Xi8Z?bKZ zYNI_wJDv@}Xp+TCJ;CCT1}va$ z7W7{GXh3zD2kcE}Fudxlb9eNsjPB|P-TY-nJr?I=uT+y|cqP>(&lh|GstjLE^=dWM z`!uB6b<8?b&9$)`4w%fYZ%JHhJP2r;$5ilmPwro>M_6|YYSNXrVRM9TA$M!e_eMRw zk-fMwLTIPH&FNmRM_1YoqjmDmUi3PCsNDu5)k_sro~ zkzJjRXG_sRrn!P$%=E{6aOg%+Qhz8!ru-xyHhgZYD~jqi^Zu{)##H2dNW~h(#qmP4L&Y|OH0eb zW>=G$52mnX4L-HPu4mKUg)Q?>Mc6XwlM(iEo)1n-$}Sf=Gj$u(UGIsqMFurZN!7BY{{VQN0B;lEIsxwFaq=qx{vtqg^7vR>=Wc6_N4M3!Inp0Cz2uLjfNm7{t~M#J z^f9%hc|t;(QvJ~s+ieGavZF?%Ytv75G#gI(MHt6;=5x zZYHYzib_TtdQpv)iUv_d>XX@-st%2+WZXeeRj59vKd-vj4Lzshhkl0!@{Mj<_)}N- zJS^(|JNGhy&Ye{W^jiSG0k8=076AkS9uVMf0-UjcYZmae1^i+G|4p$kwwvVH@{v@n zF4-R3c}&tQ+oe0yN!#ZBgz4GBq}{Xb!HOoj!Yh?6wQgEtXI(LAMW3ykQq^u(%w%l7 zb$f5>!F9IjTXEUKK_H&32*lAJTIfR1`auf z0qt#`1|>TxS~-i@}bu6r-J8lWKID fl`@W&R?a+HpgIHTU7$)u#TNb#|GUhtU?czl2e3{3 diff --git a/packages/backend/server/src/core/permission/__tests__/actions.spec.ts b/packages/backend/server/src/core/permission/__tests__/actions.spec.ts index e8ef43e6f0..f04bd24076 100644 --- a/packages/backend/server/src/core/permission/__tests__/actions.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/actions.spec.ts @@ -37,7 +37,7 @@ test(`should be able to fixup doc role from workspace role and doc role`, t => { for (const workspaceRole of workspaceRoles) { for (const docRole of docRoles) { t.snapshot( - DocRole[fixupDocRole(workspaceRole, docRole)], + DocRole[fixupDocRole(workspaceRole, docRole)!], `WorkspaceRole: ${WorkspaceRole[workspaceRole]}, DocRole: ${DocRole[docRole]}` ); } diff --git a/packages/backend/server/src/core/permission/__tests__/builder.spec.ts b/packages/backend/server/src/core/permission/__tests__/builder.spec.ts new file mode 100644 index 0000000000..c5472ec839 --- /dev/null +++ b/packages/backend/server/src/core/permission/__tests__/builder.spec.ts @@ -0,0 +1,48 @@ +import test from 'ava'; + +import { DocID } from '../../utils/doc'; +import { AccessControllerBuilder } from '../builder'; + +let builder: AccessControllerBuilder; + +test.before(async () => { + builder = new AccessControllerBuilder(); +}); + +test('should build correct workspace resource', t => { + t.deepEqual(builder.user('u1').workspace('ws1').data, { + userId: 'u1', + workspaceId: 'ws1', + }); + + t.deepEqual(builder.user('u1').workspace('ws1').allowLocal().data, { + allowLocal: true, + userId: 'u1', + workspaceId: 'ws1', + }); +}); + +test('should build correct doc resource', t => { + const resources = [ + builder.user('u1').workspace('ws1').doc('doc1').data, + builder.user('u1').doc('ws1', 'doc1').data, + builder.user('u1').doc({ workspaceId: 'ws1', docId: 'doc1' }).data, + builder.user('u1').doc(new DocID('ws1:space:doc1', 'ws1')).data, + ]; + + t.deepEqual( + resources, + Array.from({ length: 4 }, () => ({ + userId: 'u1', + workspaceId: 'ws1', + docId: 'doc1', + })) + ); + + t.deepEqual(builder.user('u1').doc('ws1', 'doc1').allowLocal().data, { + allowLocal: true, + docId: 'doc1', + userId: 'u1', + workspaceId: 'ws1', + }); +}); diff --git a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts new file mode 100644 index 0000000000..ee01d0e6d0 --- /dev/null +++ b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts @@ -0,0 +1,160 @@ +import test from 'ava'; + +import { createTestingModule, TestingModule } from '../../../__tests__/utils'; +import { + Models, + User, + Workspace, + WorkspaceMemberStatus, + WorkspaceRole, +} from '../../../models'; +import { PermissionModule } from '..'; +import { DocAccessController } from '../doc'; +import { DocRole, mapDocRoleToPermissions } from '../types'; + +let module: TestingModule; +let models: Models; +let ac: DocAccessController; +let user: User; +let ws: Workspace; + +test.before(async () => { + module = await createTestingModule({ imports: [PermissionModule] }); + models = module.get(Models); + ac = new DocAccessController(models); +}); + +test.beforeEach(async () => { + await module.initTestingDB(); + user = await models.user.create({ email: 'u1@affine.pro' }); + ws = await models.workspace.create(user.id); +}); + +test.after.always(async () => { + await module.close(); +}); + +test('should get null role', async t => { + const role = await ac.getRole({ + workspaceId: 'ws1', + docId: 'doc1', + userId: 'u1', + }); + + t.is(role, null); +}); + +test('should return null if workspace role is not accepted', async t => { + const u2 = await models.user.create({ email: 'u2@affine.pro' }); + await models.workspaceUser.set( + ws.id, + u2.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.UnderReview + ); + + const role = await ac.getRole({ + workspaceId: ws.id, + docId: 'doc1', + userId: u2.id, + }); + + t.is(role, null); +}); + +test('should return [Owner] role if workspace is not found but local is allowed', async t => { + const role = await ac.getRole({ + workspaceId: 'ws1', + docId: 'doc1', + userId: 'u1', + allowLocal: true, + }); + + t.is(role, DocRole.Owner); +}); + +test('should fallback to [External] if workspace is public', async t => { + await models.workspace.update(ws.id, { + public: true, + }); + + const role = await ac.getRole({ + workspaceId: ws.id, + docId: 'doc1', + userId: 'random-user-id', + }); + + t.is(role, DocRole.External); +}); + +test('should return null even if workspace has other public doc', async t => { + await models.workspace.publishDoc(ws.id, 'doc1'); + + const role = await ac.getRole({ + workspaceId: ws.id, + docId: 'doc2', + userId: 'random-user-id', + }); + + t.is(role, null); +}); + +test('should return [External] if doc is public', async t => { + await models.workspace.publishDoc(ws.id, 'doc1'); + + const role = await ac.getRole({ + workspaceId: ws.id, + docId: 'doc1', + userId: 'random-user-id', + }); + + t.is(role, DocRole.External); +}); + +test('should return mapped permissions', async t => { + const { permissions } = await ac.role({ + workspaceId: ws.id, + docId: 'doc1', + userId: user.id, + }); + + t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner)); +}); + +test('should assert action', async t => { + await t.notThrowsAsync( + ac.assert( + { + workspaceId: ws.id, + docId: 'doc1', + userId: user.id, + }, + 'Doc.Update' + ) + ); + + const u2 = await models.user.create({ email: 'u2@affine.pro' }); + + await t.throwsAsync( + ac.assert( + { workspaceId: ws.id, docId: 'doc1', userId: u2.id }, + 'Doc.Update' + ) + ); + + await models.workspaceUser.set( + ws.id, + u2.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.Accepted + ); + + await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager); + + await t.notThrowsAsync( + ac.assert( + { workspaceId: ws.id, docId: 'doc1', userId: u2.id }, + 'Doc.Delete' + ) + ); +}); diff --git a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts new file mode 100644 index 0000000000..d23d248972 --- /dev/null +++ b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts @@ -0,0 +1,147 @@ +import test from 'ava'; + +import { createTestingModule, TestingModule } from '../../../__tests__/utils'; +import { + Models, + User, + Workspace, + WorkspaceMemberStatus, + WorkspaceRole, +} from '../../../models'; +import { PermissionModule } from '..'; +import { mapWorkspaceRoleToPermissions } from '../types'; +import { WorkspaceAccessController } from '../workspace'; + +let module: TestingModule; +let models: Models; +let ac: WorkspaceAccessController; +let user: User; +let ws: Workspace; + +test.before(async () => { + module = await createTestingModule({ imports: [PermissionModule] }); + models = module.get(Models); + ac = new WorkspaceAccessController(models); +}); + +test.beforeEach(async () => { + await module.initTestingDB(); + user = await models.user.create({ email: 'u1@affine.pro' }); + ws = await models.workspace.create(user.id); +}); + +test.after.always(async () => { + await module.close(); +}); + +test('should get null role', async t => { + const role = await ac.getRole({ + workspaceId: 'ws1', + userId: 'u1', + }); + + t.is(role, null); +}); + +test('should return null if role is not accepted', async t => { + const u2 = await models.user.create({ email: 'u2@affine.pro' }); + await models.workspaceUser.set( + ws.id, + u2.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.UnderReview + ); + + const role = await ac.getRole({ + workspaceId: ws.id, + userId: u2.id, + }); + + t.is(role, null); +}); + +test('should return [Owner] role if workspace is not found but local is allowed', async t => { + const role = await ac.getRole({ + workspaceId: 'ws1', + userId: 'u1', + allowLocal: true, + }); + + t.is(role, WorkspaceRole.Owner); +}); + +test('should fallback to [External] if workspace is public', async t => { + await models.workspace.update(ws.id, { + public: true, + }); + + const role = await ac.getRole({ + workspaceId: ws.id, + userId: 'random-user-id', + }); + + t.is(role, WorkspaceRole.External); +}); + +test('should return null even workspace has public doc', async t => { + await models.workspace.publishDoc(ws.id, 'doc1'); + + const role = await ac.getRole({ + workspaceId: ws.id, + userId: 'random-user-id', + }); + + t.is(role, null); +}); + +test('should return mapped external permission for workspace has public docs', async t => { + await models.workspace.publishDoc(ws.id, 'doc1'); + + const { permissions } = await ac.role({ + workspaceId: ws.id, + userId: 'random-user-id', + }); + + t.deepEqual( + permissions, + mapWorkspaceRoleToPermissions(WorkspaceRole.External) + ); +}); + +test('should return mapped permissions', async t => { + const { permissions } = await ac.role({ + workspaceId: ws.id, + userId: user.id, + }); + + t.deepEqual(permissions, mapWorkspaceRoleToPermissions(WorkspaceRole.Owner)); +}); + +test('should assert action', async t => { + await t.notThrowsAsync( + ac.assert( + { workspaceId: ws.id, userId: user.id }, + 'Workspace.TransferOwner' + ) + ); + + const u2 = await models.user.create({ email: 'u2@affine.pro' }); + + await t.throwsAsync( + ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync') + ); + + await models.workspaceUser.set( + ws.id, + u2.id, + WorkspaceRole.Admin, + WorkspaceMemberStatus.Accepted + ); + + await t.notThrowsAsync( + ac.assert( + { workspaceId: ws.id, userId: u2.id }, + 'Workspace.Settings.Update' + ) + ); +}); diff --git a/packages/backend/server/src/core/permission/builder.ts b/packages/backend/server/src/core/permission/builder.ts new file mode 100644 index 0000000000..ab391a07d2 --- /dev/null +++ b/packages/backend/server/src/core/permission/builder.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; + +import { DocID } from '../utils/doc'; +import { getAccessController } from './controller'; +import { Resource } from './resource'; +import { DocAction, WorkspaceAction } from './types'; + +@Injectable() +export class AccessControllerBuilder { + user(userId: string) { + return new UserAccessControllerBuilder(userId); + } +} + +export class UserAccessControllerBuilder { + constructor(private readonly userId: string) {} + + workspace(workspaceId: string) { + return new WorkspaceAccessControllerBuilder({ + userId: this.userId, + workspaceId, + }); + } + + doc( + docId: DocID | { workspaceId: string; docId: string } + ): DocAccessControllerBuilder; + doc(workspaceId: string, docId: string): DocAccessControllerBuilder; + doc( + docIdOrWorkspaceId: string | DocID | { workspaceId: string; docId: string }, + doc?: string + ) { + let workspaceId: string; + let docId: string; + + if (docIdOrWorkspaceId instanceof DocID) { + workspaceId = docIdOrWorkspaceId.workspace; + docId = docIdOrWorkspaceId.guid; + } else if (typeof docIdOrWorkspaceId === 'string') { + workspaceId = docIdOrWorkspaceId; + docId = doc as string; + } else { + workspaceId = docIdOrWorkspaceId.workspaceId; + docId = docIdOrWorkspaceId.docId; + } + + return new DocAccessControllerBuilder({ + userId: this.userId, + workspaceId, + docId, + }); + } +} + +class WorkspaceAccessControllerBuilder { + constructor(public readonly data: Resource<'ws'>) {} + + allowLocal() { + this.data.allowLocal = true; + return this; + } + + doc(docId: string) { + return new DocAccessControllerBuilder({ + ...this.data, + docId, + }); + } + + async assert(action: WorkspaceAction) { + const checker = getAccessController('ws'); + await checker.assert(this.data, action); + } + + async can(action: WorkspaceAction) { + const checker = getAccessController('ws'); + return await checker.can(this.data, action); + } + + async permissions() { + const checker = getAccessController('ws'); + return await checker.role(this.data); + } +} + +class DocAccessControllerBuilder { + constructor(public readonly data: Resource<'doc'>) {} + + allowLocal() { + this.data.allowLocal = true; + return this; + } + + async assert(action: DocAction) { + const checker = getAccessController('doc'); + await checker.assert(this.data, action); + } + + async can(action: DocAction) { + const checker = getAccessController('doc'); + return await checker.can(this.data, action); + } + + async permissions() { + const checker = getAccessController('doc'); + return await checker.role(this.data); + } +} diff --git a/packages/backend/server/src/core/permission/controller.ts b/packages/backend/server/src/core/permission/controller.ts new file mode 100644 index 0000000000..590bdb1e36 --- /dev/null +++ b/packages/backend/server/src/core/permission/controller.ts @@ -0,0 +1,53 @@ +import { Logger, OnModuleInit } from '@nestjs/common'; + +import type { + Resource, + ResourceAction, + ResourceRole, + ResourceType, +} from './resource'; + +const ACTION_CHECKER_PROVIDERS = new Map>(); + +function registerAccessController( + type: Type, + provider: AccessController +) { + ACTION_CHECKER_PROVIDERS.set(type, provider); +} + +export function getAccessController( + type: Type +): AccessController { + const provider = ACTION_CHECKER_PROVIDERS.get(type); + if (!provider) { + throw new Error(`No action checker provider for type ${type}`); + } + return provider; +} + +export abstract class AccessController + implements OnModuleInit +{ + protected abstract readonly type: Type; + protected logger = new Logger(AccessController.name); + + onModuleInit() { + registerAccessController(this.type, this); + } + + abstract assert( + resource: Resource, + action: ResourceAction + ): Promise; + + abstract can( + resource: Resource, + action: ResourceAction + ): Promise; + + abstract role(resource: Resource): Promise<{ + role: ResourceRole | null; + permissions: Record, boolean>; + }>; +} diff --git a/packages/backend/server/src/core/permission/doc.ts b/packages/backend/server/src/core/permission/doc.ts new file mode 100644 index 0000000000..3c7e86d7f0 --- /dev/null +++ b/packages/backend/server/src/core/permission/doc.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; + +import { DocActionDenied } from '../../base'; +import { Models } from '../../models'; +import { AccessController, getAccessController } from './controller'; +import type { Resource } from './resource'; +import { + DocAction, + docActionRequiredRole, + DocRole, + fixupDocRole, + mapDocRoleToPermissions, + WorkspaceRole, +} from './types'; +import { WorkspaceAccessController } from './workspace'; + +@Injectable() +export class DocAccessController extends AccessController<'doc'> { + protected readonly type = 'doc'; + + constructor(private readonly models: Models) { + super(); + } + + async role(resource: Resource<'doc'>) { + const role = await this.getRole(resource); + + return { + role, + permissions: mapDocRoleToPermissions(role), + }; + } + + async can(resource: Resource<'doc'>, action: DocAction) { + const { permissions, role } = await this.role(resource); + const allow = permissions[action] || false; + + if (!allow) { + this.logger.log('Doc access check failed', { + action, + resource, + role, + requiredRole: docActionRequiredRole(action), + }); + } + + return allow; + } + + async assert(resource: Resource<'doc'>, action: DocAction) { + const allow = await this.can(resource, action); + + if (!allow) { + throw new DocActionDenied({ + docId: resource.docId, + spaceId: resource.workspaceId, + action, + }); + } + } + + async getRole(payload: Resource<'doc'>): Promise { + const workspaceController = getAccessController( + 'ws' + ) as WorkspaceAccessController; + const workspaceRole = await workspaceController.getRole(payload); + + const userRole = await this.models.docUser.get( + payload.workspaceId, + payload.docId, + payload.userId + ); + + let docRole = userRole?.type ?? (null as DocRole | null); + + // fallback logic + if (docRole === null) { + const defaultDocRole = await this.defaultDocRole( + payload.workspaceId, + payload.docId + ); + + // if user is in workspace but doc role is not set, fallback to default doc role + if (workspaceRole && workspaceRole !== WorkspaceRole.External) { + docRole = defaultDocRole.workspace; + } else { + // else fallback to external doc role + docRole = defaultDocRole.external; + } + } + + // we need to fixup doc role to make sure it's not miss set + // for example: workspace owner will have doc owner role + // workspace external will not have role higher than editor + return fixupDocRole(workspaceRole, docRole); + } + + private async defaultDocRole(workspaceId: string, docId: string) { + const doc = await this.models.workspace.getDoc(workspaceId, docId); + return { + external: doc?.public ? DocRole.External : null, + workspace: doc?.defaultRole ?? DocRole.Manager, + }; + } +} diff --git a/packages/backend/server/src/core/permission/event.ts b/packages/backend/server/src/core/permission/event.ts new file mode 100644 index 0000000000..4e6651a145 --- /dev/null +++ b/packages/backend/server/src/core/permission/event.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; + +import { OnEvent } from '../../base'; +import { Models } from '../../models'; + +@Injectable() +export class EventsListener { + constructor(private readonly models: Models) {} + + @OnEvent('doc.created') + async setDefaultPageOwner(payload: Events['doc.created']) { + const { workspaceId, docId, editor } = payload; + + if (!editor) { + return; + } + + await this.models.docUser.setOwner(workspaceId, docId, editor); + } +} diff --git a/packages/backend/server/src/core/permission/index.ts b/packages/backend/server/src/core/permission/index.ts index 3bc7f3924b..35d61b7106 100644 --- a/packages/backend/server/src/core/permission/index.ts +++ b/packages/backend/server/src/core/permission/index.ts @@ -1,22 +1,26 @@ import { Module } from '@nestjs/common'; -import { PermissionService } from './service'; +import { AccessControllerBuilder } from './builder'; +import { DocAccessController } from './doc'; +import { EventsListener } from './event'; +import { WorkspaceAccessController } from './workspace'; @Module({ - providers: [PermissionService], - exports: [PermissionService], + providers: [ + WorkspaceAccessController, + DocAccessController, + AccessControllerBuilder, + EventsListener, + ], + exports: [AccessControllerBuilder], }) export class PermissionModule {} -export { PermissionService } from './service'; +export { AccessControllerBuilder as AccessController } from './builder'; export { DOC_ACTIONS, type DocAction, DocRole, - fixupDocRole, - mapDocRoleToPermissions, - mapWorkspaceRoleToPermissions, - PublicDocMode, WORKSPACE_ACTIONS, type WorkspaceAction, WorkspaceRole, diff --git a/packages/backend/server/src/core/permission/resource.ts b/packages/backend/server/src/core/permission/resource.ts new file mode 100644 index 0000000000..8703cb30bf --- /dev/null +++ b/packages/backend/server/src/core/permission/resource.ts @@ -0,0 +1,42 @@ +import { DocAction, DocRole, WorkspaceAction, WorkspaceRole } from './types'; + +export type ResourceType = 'ws' | 'doc'; + +interface WorkspaceResource { + type: 'ws'; + payload: { + allowLocal?: boolean; + workspaceId: string; + userId: string; + }; + action: WorkspaceAction; + role: WorkspaceRole; +} + +interface DocResource { + type: 'doc'; + payload: { + allowLocal?: boolean; + workspaceId: string; + docId: string; + userId: string; + }; + action: DocAction; + role: DocRole; +} + +export type KnownResource = WorkspaceResource | DocResource; +export type Resource = Extract< + KnownResource, + { type: Type } +>['payload']; + +export type ResourceRole = Extract< + KnownResource, + { type: Type } +>['role']; + +export type ResourceAction = Extract< + KnownResource, + { type: Type } +>['action']; diff --git a/packages/backend/server/src/core/permission/service.ts b/packages/backend/server/src/core/permission/service.ts deleted file mode 100644 index 05fffbd04c..0000000000 --- a/packages/backend/server/src/core/permission/service.ts +++ /dev/null @@ -1,798 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import type { Prisma, WorkspaceDocUserPermission } from '@prisma/client'; -import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; -import { groupBy } from 'lodash-es'; - -import { - CanNotBatchGrantDocOwnerPermissions, - DocActionDenied, - EventBus, - OnEvent, - SpaceAccessDenied, - SpaceOwnerNotFound, - WorkspacePermissionNotFound, -} from '../../base'; -import { - DocAction, - docActionRequiredRole, - docActionRequiredWorkspaceRole, - DocRole, - PublicDocMode, - WorkspaceRole, -} from './types'; - -@Injectable() -export class PermissionService { - private readonly logger = new Logger(PermissionService.name); - - constructor( - private readonly prisma: PrismaClient, - private readonly event: EventBus - ) {} - - @OnEvent('doc.created') - async setDefaultPageOwner(payload: Events['doc.created']) { - const { workspaceId, docId, editor } = payload; - - if (!editor) { - return; - } - - await this.prisma.workspaceDocUserPermission.createMany({ - data: { - workspaceId, - docId, - userId: editor, - type: DocRole.Owner, - createdAt: new Date(), - }, - }); - } - - private get acceptedCondition() { - return [ - { - accepted: true, - }, - { - status: WorkspaceMemberStatus.Accepted, - }, - ]; - } - - /// Start regin: workspace permission - async get(ws: string, user: string): Promise { - const data = await this.prisma.workspaceUserPermission.findFirst({ - where: { - workspaceId: ws, - userId: user, - OR: this.acceptedCondition, - }, - }); - - if (!data) { - throw new WorkspacePermissionNotFound({ spaceId: ws }); - } - - return data.type; - } - - /** - * check whether a workspace exists and has any one can access it - * @param workspaceId workspace id - * @returns - */ - async hasWorkspace(workspaceId: string) { - return await this.prisma.workspaceUserPermission - .count({ - where: { - workspaceId, - OR: this.acceptedCondition, - }, - }) - .then(count => count > 0); - } - - async getOwnedWorkspaces(userId: string) { - return this.prisma.workspaceUserPermission - .findMany({ - where: { - userId, - type: WorkspaceRole.Owner, - OR: this.acceptedCondition, - }, - select: { - workspaceId: true, - }, - }) - .then(data => data.map(({ workspaceId }) => workspaceId)); - } - - async getWorkspaceOwner(workspaceId: string) { - const owner = await this.prisma.workspaceUserPermission.findFirst({ - where: { - workspaceId, - type: WorkspaceRole.Owner, - }, - include: { - user: true, - }, - }); - - if (!owner) { - throw new SpaceOwnerNotFound({ spaceId: workspaceId }); - } - - return owner.user; - } - - async getWorkspaceAdmin(workspaceId: string) { - const admin = await this.prisma.workspaceUserPermission.findMany({ - where: { - workspaceId, - type: WorkspaceRole.Admin, - }, - include: { - user: true, - }, - }); - - return admin.map(({ user }) => user); - } - - async getWorkspaceMemberCount(workspaceId: string) { - return this.prisma.workspaceUserPermission.count({ - where: { - workspaceId, - }, - }); - } - - async tryGetWorkspaceOwner(workspaceId: string) { - return this.prisma.workspaceUserPermission.findFirst({ - where: { - workspaceId, - type: WorkspaceRole.Owner, - }, - include: { - user: true, - }, - }); - } - - /** - * check if a doc binary is accessible by a user - */ - async isPublicAccessible( - ws: string, - id: string, - user?: string - ): Promise { - 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, WorkspaceRole.Collaborator), - this.prisma.workspaceDoc.count({ - where: { - workspaceId: ws, - public: true, - }, - }), - ]); - return isPublicWorkspace || publicPages > 0; - } - - return this.tryCheckPage(ws, id, 'Doc.Read', user); - } - - async getWorkspaceMemberStatus(ws: string, user: string) { - return this.prisma.workspaceUserPermission - .findFirst({ - where: { - workspaceId: ws, - userId: user, - }, - select: { status: true }, - }) - .then(r => r?.status); - } - - /** - * Returns whether a given user is a member of a workspace and has the given or higher permission. - */ - async isWorkspaceMember( - ws: string, - user: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ): Promise { - const count = await this.prisma.workspaceUserPermission.count({ - where: { - workspaceId: ws, - userId: user, - OR: this.acceptedCondition, - type: { - gte: permission, - }, - }, - }); - - return count !== 0; - } - - /** - * only check permission if the workspace is a cloud workspace - * @param workspaceId workspace id - * @param userId user id, check if is a public workspace if not provided - * @param permission default is read - */ - async checkCloudWorkspace( - workspaceId: string, - userId?: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ) { - const hasWorkspace = await this.hasWorkspace(workspaceId); - if (hasWorkspace) { - await this.checkWorkspace(workspaceId, userId, permission); - } - } - - async checkWorkspace( - ws: string, - user?: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ) { - if (!(await this.tryCheckWorkspace(ws, user, permission))) { - throw new SpaceAccessDenied({ spaceId: ws }); - } - } - - async tryCheckWorkspace( - ws: string, - user?: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ) { - // If the permission is read, we should check if the workspace is public - if (permission === WorkspaceRole.Collaborator) { - const count = await this.prisma.workspace.count({ - where: { id: ws, public: true }, - }); - - // workspace is public - // accessible - if (count > 0) { - return true; - } - } - - if (user) { - // normally check if the user has the permission - const count = await this.prisma.workspaceUserPermission.count({ - where: { - workspaceId: ws, - userId: user, - OR: this.acceptedCondition, - type: { - gte: permission, - }, - }, - }); - - if (count > 0) { - return true; - } else { - const info = { - workspaceId: ws, - userId: user, - requiredRole: WorkspaceRole[permission], - }; - this.logger.log( - `User's WorkspaceRole is lower than required (${JSON.stringify(info)})` - ); - } - } - - // unsigned in, workspace is not public - // unaccessible - return false; - } - - async checkWorkspaceIs( - ws: string, - user: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ) { - if (!(await this.tryCheckWorkspaceIs(ws, user, permission))) { - throw new SpaceAccessDenied({ spaceId: ws }); - } - } - - async tryCheckWorkspaceIs( - ws: string, - user: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ) { - const count = await this.prisma.workspaceUserPermission.count({ - where: { - workspaceId: ws, - userId: user, - OR: this.acceptedCondition, - type: permission, - }, - }); - - return count > 0; - } - - async allowUrlPreview(ws: string) { - const count = await this.prisma.workspace.count({ - where: { - id: ws, - enableUrlPreview: true, - }, - }); - - return count > 0; - } - - private getAllowedStatusSource( - to: WorkspaceMemberStatus - ): WorkspaceMemberStatus[] { - switch (to) { - case WorkspaceMemberStatus.NeedMoreSeat: - return [WorkspaceMemberStatus.Pending]; - case WorkspaceMemberStatus.NeedMoreSeatAndReview: - return [WorkspaceMemberStatus.UnderReview]; - case WorkspaceMemberStatus.Pending: - case WorkspaceMemberStatus.UnderReview: - return [WorkspaceMemberStatus.Accepted]; - default: - return []; - } - } - - async grant( - ws: string, - user: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator, - status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending - ): Promise { - const data = await this.prisma.workspaceUserPermission.findFirst({ - where: { workspaceId: ws, userId: user }, - }); - - if (data) { - const toBeOwner = permission === WorkspaceRole.Owner; - if (data.accepted && data.status === WorkspaceMemberStatus.Accepted) { - const [p] = await this.prisma.$transaction( - [ - this.prisma.workspaceUserPermission.update({ - where: { - workspaceId_userId: { workspaceId: ws, userId: user }, - }, - data: { type: permission }, - }), - - // If the new permission is owner, we need to revoke old owner - toBeOwner - ? this.prisma.workspaceUserPermission.updateMany({ - where: { - workspaceId: ws, - type: WorkspaceRole.Owner, - userId: { not: user }, - }, - data: { type: WorkspaceRole.Admin }, - }) - : null, - ].filter(Boolean) as Prisma.PrismaPromise[] - ); - - return p.id; - } - const allowedStatus = this.getAllowedStatusSource(data.status); - if (allowedStatus.includes(status)) { - const ret = await this.prisma.workspaceUserPermission.update({ - where: { workspaceId_userId: { workspaceId: ws, userId: user } }, - data: { status }, - }); - return ret.id; - } - return data.id; - } - - return this.prisma.workspaceUserPermission - .create({ - data: { - workspaceId: ws, - userId: user, - type: permission, - status, - }, - }) - .then(p => p.id); - } - - async acceptWorkspaceInvitation( - invitationId: string, - workspaceId: string, - status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted - ) { - const result = await this.prisma.workspaceUserPermission.updateMany({ - where: { - id: invitationId, - workspaceId: workspaceId, - AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }], - }, - data: { accepted: true, status }, - }); - - return result.count > 0; - } - - async refreshSeatStatus(workspaceId: string, memberLimit: number) { - const usedCount = await this.prisma.workspaceUserPermission.count({ - where: { workspaceId, status: WorkspaceMemberStatus.Accepted }, - }); - - const availableCount = memberLimit - usedCount; - - if (availableCount <= 0) { - return; - } - - await this.prisma.$transaction(async tx => { - const members = await tx.workspaceUserPermission.findMany({ - select: { id: true, status: true }, - where: { - workspaceId, - status: { - in: [ - WorkspaceMemberStatus.NeedMoreSeat, - WorkspaceMemberStatus.NeedMoreSeatAndReview, - ], - }, - }, - orderBy: { createdAt: 'asc' }, - }); - - const needChange = members.slice(0, availableCount); - const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy( - needChange, - m => m.status - ); - - const toPendings = NeedMoreSeat ?? []; - if (toPendings.length > 0) { - await tx.workspaceUserPermission.updateMany({ - where: { id: { in: toPendings.map(m => m.id) } }, - data: { status: WorkspaceMemberStatus.Pending }, - }); - } - - const toUnderReviewUserIds = NeedMoreSeatAndReview ?? []; - if (toUnderReviewUserIds.length > 0) { - await tx.workspaceUserPermission.updateMany({ - where: { id: { in: toUnderReviewUserIds.map(m => m.id) } }, - data: { status: WorkspaceMemberStatus.UnderReview }, - }); - } - - return [toPendings, toUnderReviewUserIds] as const; - }); - } - - async revokeWorkspace(workspaceId: string, user: string) { - const permission = await this.prisma.workspaceUserPermission.findUnique({ - where: { workspaceId_userId: { workspaceId, userId: user } }, - }); - - // We shouldn't revoke owner permission - // should auto deleted by workspace/user delete cascading - if (!permission || permission.type === WorkspaceRole.Owner) { - return false; - } - - await this.prisma.workspaceUserPermission.deleteMany({ - where: { - workspaceId, - userId: user, - }, - }); - - const count = await this.prisma.workspaceUserPermission.count({ - where: { workspaceId }, - }); - - this.event.emit('workspace.members.updated', { - workspaceId, - count, - }); - this.event.emit('workspace.members.removed', { - workspaceId, - userId: user, - }); - - if ( - permission.status === 'UnderReview' || - permission.status === 'NeedMoreSeatAndReview' - ) { - this.event.emit('workspace.members.requestDeclined', { - userId: user, - workspaceId, - }); - } - - return true; - } - /// End regin: workspace permission - - /// Start regin: page permission - /** - * only check permission if the workspace is a cloud workspace - * @param workspaceId workspace id - * @param pageId page id aka doc id - * @param userId user id, check if is a public page if not provided - * @param permission default is read - */ - async checkCloudPagePermission( - workspaceId: string, - pageId: string, - action: DocAction, - userId?: string - ) { - const hasWorkspace = await this.hasWorkspace(workspaceId); - if (hasWorkspace) { - await this.checkPagePermission(workspaceId, pageId, action, userId); - } - } - - async checkPagePermission( - ws: string, - page: string, - action: DocAction, - user?: string - ) { - if (!(await this.tryCheckPage(ws, page, action, user))) { - throw new DocActionDenied({ spaceId: ws, docId: page, action }); - } - } - - async tryCheckPage( - ws: string, - doc: string, - action: DocAction, - user?: string - ) { - const role = docActionRequiredRole(action); - // check whether page is public - if (action === 'Doc.Read') { - const count = await this.prisma.workspaceDoc.count({ - where: { - workspaceId: ws, - docId: doc, - public: true, - }, - }); - - // page is public - // accessible - if (count > 0) { - return true; - } - } - - if (user) { - const [roleEntity, pageEntity, workspaceRoleEntity] = await Promise.all([ - this.prisma.workspaceDocUserPermission.findFirst({ - where: { - workspaceId: ws, - docId: doc, - userId: user, - }, - select: { - type: true, - }, - }), - this.prisma.workspaceDoc.findFirst({ - where: { - workspaceId: ws, - docId: doc, - }, - select: { - defaultRole: true, - }, - }), - this.prisma.workspaceUserPermission.findFirst({ - where: { - workspaceId: ws, - userId: user, - OR: this.acceptedCondition, - }, - select: { - type: true, - }, - }), - ]); - - const defaultPageRole = pageEntity?.defaultRole ?? DocRole.Manager; - - if ( - // Page role exists, check it first - (roleEntity && roleEntity.type >= role) || - // if - // - page has a default role - // - the user is in this workspace - // - the user is not an external user in this workspace - // then use the max of the two - (workspaceRoleEntity && - workspaceRoleEntity.type !== WorkspaceRole.External && - Math.max( - roleEntity?.type ?? Number.MIN_SAFE_INTEGER, - defaultPageRole - ) >= role) - ) { - return true; - } - const info = { - workspaceId: ws, - docId: doc, - userId: user, - workspaceRole: workspaceRoleEntity - ? WorkspaceRole[workspaceRoleEntity.type] - : undefined, - pageRole: roleEntity ? DocRole[roleEntity.type] : undefined, - pageDefaultRole: DocRole[defaultPageRole], - requiredRole: DocRole[role], - action, - }; - this.logger.log( - `Page role is lower than required, continue to check workspace permission (${JSON.stringify(info)})` - ); - } - - // check whether user has workspace related permission - return this.tryCheckWorkspace( - ws, - user, - docActionRequiredWorkspaceRole(action) - ); - } - - async isPublicPage(ws: string, doc: string) { - return this.prisma.workspaceDoc - .count({ - where: { - workspaceId: ws, - docId: doc, - public: true, - }, - }) - .then(count => count > 0); - } - - async publishPage(ws: string, doc: string, mode = PublicDocMode.Page) { - return this.prisma.workspaceDoc.upsert({ - where: { - workspaceId_docId: { - workspaceId: ws, - docId: doc, - }, - }, - update: { - public: true, - mode, - }, - create: { - workspaceId: ws, - docId: doc, - mode, - public: true, - }, - }); - } - - async revokePublicPage(ws: string, doc: string) { - return this.prisma.workspaceDoc.upsert({ - where: { - workspaceId_docId: { - workspaceId: ws, - docId: doc, - }, - }, - update: { - public: false, - }, - create: { - workspaceId: ws, - docId: doc, - public: false, - }, - }); - } - - async grantPage(ws: string, doc: string, user: string, permission: DocRole) { - const [p] = await this.prisma.$transaction( - [ - this.prisma.workspaceDocUserPermission.upsert({ - where: { - workspaceId_docId_userId: { - workspaceId: ws, - docId: doc, - userId: user, - }, - }, - update: { - type: permission, - }, - create: { - workspaceId: ws, - docId: doc, - userId: user, - type: permission, - }, - }), - - // If the new permission is owner, we need to revoke old owner - permission === DocRole.Owner - ? this.prisma.workspaceDocUserPermission.updateMany({ - where: { - workspaceId: ws, - docId: doc, - type: DocRole.Owner, - userId: { - not: user, - }, - }, - data: { - type: DocRole.Manager, - }, - }) - : null, - ].filter(Boolean) as Prisma.PrismaPromise[] - ); - - return p as WorkspaceDocUserPermission; - } - - async revokePage(ws: string, doc: string, user: string) { - const result = await this.prisma.workspaceDocUserPermission.deleteMany({ - where: { - workspaceId: ws, - docId: doc, - userId: user, - type: { - // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading - not: DocRole.Owner, - }, - }, - }); - - return result.count > 0; - } - - async batchGrantPage( - workspaceId: string, - docId: string, - userIds: string[], - role: DocRole - ) { - if (userIds.length === 0) { - return 0; - } - - if (role === DocRole.Owner) { - throw new CanNotBatchGrantDocOwnerPermissions(); - } - - const result = await this.prisma.workspaceDocUserPermission.createMany({ - skipDuplicates: true, - data: userIds.map(id => ({ - workspaceId, - docId, - userId: id, - type: role, - })), - }); - - return result.count; - } -} diff --git a/packages/backend/server/src/core/permission/types.ts b/packages/backend/server/src/core/permission/types.ts index 6f6042a98a..3441c5f221 100644 --- a/packages/backend/server/src/core/permission/types.ts +++ b/packages/backend/server/src/core/permission/types.ts @@ -1,25 +1,7 @@ import { LeafPaths, LeafVisitor } from '../../base'; +import { DocRole, WorkspaceRole } from '../../models'; -export enum PublicDocMode { - 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 { DocRole, WorkspaceRole }; /** * Definitions of all possible actions * @@ -28,6 +10,7 @@ export enum WorkspaceRole { export const Actions = { // Workspace Actions Workspace: { + Read: '', Sync: '', CreateDoc: '', Delete: '', @@ -39,6 +22,9 @@ export const Actions = { Read: '', Manage: '', }, + Adminitrators: { + Manage: '', + }, Properties: { Read: '', Create: '', @@ -49,6 +35,15 @@ export const Actions = { Read: '', Update: '', }, + Blobs: { + Read: '', + List: '', + Write: '', + }, + Copilot: '', + Payment: { + Manage: '', + }, }, // Doc Actions @@ -76,7 +71,12 @@ export const Actions = { export const RoleActionsMap = { WorkspaceRole: { get [WorkspaceRole.External]() { - return [Action.Workspace.Organize.Read]; + return [ + Action.Workspace.Read, + Action.Workspace.Organize.Read, + Action.Workspace.Properties.Read, + Action.Workspace.Blobs.Read, + ]; }, get [WorkspaceRole.Collaborator]() { return [ @@ -84,8 +84,10 @@ export const RoleActionsMap = { Action.Workspace.Sync, Action.Workspace.CreateDoc, Action.Workspace.Users.Read, - Action.Workspace.Properties.Read, Action.Workspace.Settings.Read, + Action.Workspace.Blobs.Write, + Action.Workspace.Blobs.List, + Action.Workspace.Copilot, ]; }, get [WorkspaceRole.Admin]() { @@ -102,7 +104,9 @@ export const RoleActionsMap = { return [ ...this[WorkspaceRole.Admin], Action.Workspace.Delete, + Action.Workspace.Adminitrators.Manage, Action.Workspace.TransferOwner, + Action.Workspace.Payment.Manage, ]; }, }, @@ -187,7 +191,9 @@ export const WORKSPACE_ACTIONS = RoleActionsMap.WorkspaceRole[WorkspaceRole.Owner]; export const DOC_ACTIONS = RoleActionsMap.DocRole[DocRole.Owner]; -export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) { +export function mapWorkspaceRoleToPermissions( + workspaceRole: WorkspaceRole | null +) { const permissions = WORKSPACE_ACTIONS.reduce( (map, action) => { map[action] = false; @@ -196,6 +202,10 @@ export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) { {} as Record ); + if (workspaceRole === null) { + return permissions; + } + RoleActionsMap.WorkspaceRole[workspaceRole].forEach(action => { permissions[action] = true; }); @@ -203,7 +213,7 @@ export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) { return permissions; } -export function mapDocRoleToPermissions(docRole: DocRole) { +export function mapDocRoleToPermissions(docRole: DocRole | null) { const permissions = DOC_ACTIONS.reduce( (map, action) => { map[action] = false; @@ -212,6 +222,10 @@ export function mapDocRoleToPermissions(docRole: DocRole) { {} as Record ); + if (docRole === null) { + return permissions; + } + RoleActionsMap.DocRole[docRole].forEach(action => { permissions[action] = true; }); @@ -232,9 +246,16 @@ export function mapDocRoleToPermissions(docRole: DocRole) { * fixupDocRole(WorkspaceRole.Owner, DocRole.External) // returns DocRole.Manager */ export function fixupDocRole( - workspaceRole: WorkspaceRole = WorkspaceRole.External, - docRole: DocRole = DocRole.External -): DocRole { + workspaceRole: WorkspaceRole | null, + docRole: DocRole | null +): DocRole | null { + if (workspaceRole === null && docRole === null) { + return null; + } + + workspaceRole = workspaceRole ?? WorkspaceRole.External; + docRole = docRole ?? DocRole.External; + switch (workspaceRole) { case WorkspaceRole.External: // Workspace External user won't be able to have any high permission doc role diff --git a/packages/backend/server/src/core/permission/workspace.ts b/packages/backend/server/src/core/permission/workspace.ts new file mode 100644 index 0000000000..a58714d85a --- /dev/null +++ b/packages/backend/server/src/core/permission/workspace.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; + +import { SpaceAccessDenied } from '../../base'; +import { Models } from '../../models'; +import { AccessController } from './controller'; +import type { Resource } from './resource'; +import { + mapWorkspaceRoleToPermissions, + WorkspaceAction, + workspaceActionRequiredRole, + WorkspaceRole, +} from './types'; + +@Injectable() +export class WorkspaceAccessController extends AccessController<'ws'> { + protected readonly type = 'ws'; + + constructor(private readonly models: Models) { + super(); + } + + async role(resource: Resource<'ws'>) { + let role = await this.getRole(resource); + + // NOTE(@forehalo): special case for public page + // Currently, we can not only load binary of a public Doc to render in a shared page, + // so we need to ensure anyone has basic 'read' permission to a workspace that has public pages. + if ( + !role && + (await this.models.workspace.hasPublicDoc(resource.workspaceId)) + ) { + role = WorkspaceRole.External; + } + + return { + role, + permissions: mapWorkspaceRoleToPermissions(role), + }; + } + + async can(resource: Resource<'ws'>, action: WorkspaceAction) { + const { permissions, role } = await this.role(resource); + const allow = permissions[action] || false; + + if (!allow) { + this.logger.log('Workspace access check failed', { + action, + resource, + role, + requiredRole: workspaceActionRequiredRole(action), + }); + } + + return allow; + } + + async assert(resource: Resource<'ws'>, action: WorkspaceAction) { + const allow = await this.can(resource, action); + + if (!allow) { + throw new SpaceAccessDenied({ spaceId: resource.workspaceId }); + } + } + + async getRole(payload: Resource<'ws'>) { + const userRole = await this.models.workspaceUser.getActive( + payload.workspaceId, + payload.userId + ); + + let role = userRole?.type as WorkspaceRole | null; + + if (!role) { + role = await this.defaultWorkspaceRole(payload); + } + + return role; + } + + private async defaultWorkspaceRole(payload: Resource<'ws'>) { + const ws = await this.models.workspace.get(payload.workspaceId); + + // NOTE(@forehalo): + // we allow user to use online service with local workspace + // so we always return owner role for local workspace + // copilot session for local workspace is an example + if (!ws) { + if (payload.allowLocal) { + return WorkspaceRole.Owner; + } + + return null; + } + + if (ws.public) { + return WorkspaceRole.External; + } + + return null; + } +} diff --git a/packages/backend/server/src/core/quota/service.ts b/packages/backend/server/src/core/quota/service.ts index 94f103d4e0..96db1d79ea 100644 --- a/packages/backend/server/src/core/quota/service.ts +++ b/packages/backend/server/src/core/quota/service.ts @@ -5,8 +5,8 @@ import { Models, type UserQuota, WorkspaceQuota as BaseWorkspaceQuota, + WorkspaceRole, } from '../../models'; -import { PermissionService } from '../permission'; import { WorkspaceBlobStorage } from '../storage'; import { UserQuotaHumanReadableType, @@ -28,7 +28,6 @@ export class QuotaService { constructor( private readonly models: Models, - private readonly permissions: PermissionService, private readonly storage: WorkspaceBlobStorage ) {} @@ -73,12 +72,20 @@ export class QuotaService { } async getUserStorageUsage(userId: string) { - const workspaces = await this.permissions.getOwnedWorkspaces(userId); + const workspaces = await this.models.workspaceUser.getUserActiveRoles( + userId, + { + role: WorkspaceRole.Owner, + } + ); + + const ids = workspaces.map(w => w.workspaceId); + const workspacesWithQuota = - await this.models.workspaceFeature.batchHasQuota(workspaces); + await this.models.workspaceFeature.batchHasQuota(ids); const sizes = await Promise.allSettled( - workspaces + ids .filter(w => !workspacesWithQuota.includes(w)) .map(workspace => this.storage.totalSize(workspace)) ); @@ -116,8 +123,7 @@ export class QuotaService { if (!quota) { // get and convert to workspace quota from owner's quota - // TODO(@forehalo): replace it with `WorkspaceRoleModel` when it's ready - const owner = await this.permissions.getWorkspaceOwner(workspaceId); + const owner = await this.models.workspaceUser.getOwner(workspaceId); const ownerQuota = await this.getUserQuota(owner.id); return { @@ -136,8 +142,7 @@ export class QuotaService { const usedStorageQuota = quota.ownerQuota ? await this.getUserStorageUsage(quota.ownerQuota) : await this.getWorkspaceStorageUsage(workspaceId); - const memberCount = - await this.permissions.getWorkspaceMemberCount(workspaceId); + const memberCount = await this.models.workspaceUser.count(workspaceId); return { ...quota, @@ -165,8 +170,7 @@ export class QuotaService { async getWorkspaceSeatQuota(workspaceId: string) { const quota = await this.getWorkspaceQuota(workspaceId); - const memberCount = - await this.permissions.getWorkspaceMemberCount(workspaceId); + const memberCount = await this.models.workspaceUser.count(workspaceId); return { memberCount, diff --git a/packages/backend/server/src/core/storage/wrappers/blob.ts b/packages/backend/server/src/core/storage/wrappers/blob.ts index 274031a492..9122a857f7 100644 --- a/packages/backend/server/src/core/storage/wrappers/blob.ts +++ b/packages/backend/server/src/core/storage/wrappers/blob.ts @@ -13,6 +13,19 @@ import { StorageProviderFactory, } from '../../../base'; +declare global { + interface Events { + 'workspace.blob.sync': { + workspaceId: string; + key: string; + }; + 'workspace.blob.delete': { + workspaceId: string; + key: string; + }; + } +} + @Injectable() export class WorkspaceBlobStorage { private readonly logger = new Logger(WorkspaceBlobStorage.name); diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index e960fd562d..344753ebbe 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 { PermissionService, WorkspaceRole } from '../permission'; +import { AccessController, WorkspaceAction } from '../permission'; import { DocID } from '../utils/doc'; const SubscribeMessage = (event: string) => @@ -144,7 +144,7 @@ export class SpaceSyncGateway constructor( private readonly runtime: Runtime, - private readonly permissions: PermissionService, + private readonly ac: AccessController, private readonly workspace: PgWorkspaceDocStorageAdapter, private readonly userspace: PgUserspaceDocStorageAdapter, private readonly docReader: DocReader @@ -170,7 +170,7 @@ export class SpaceSyncGateway const workspace = new WorkspaceSyncAdapter( client, this.workspace, - this.permissions, + this.ac, this.docReader ); const userspace = new UserspaceSyncAdapter(client, this.userspace); @@ -248,12 +248,13 @@ export class SpaceSyncGateway ): Promise< EventResponse<{ missing: string; state: string; timestamp: number }> > { + const id = new DocID(docId, spaceId); const adapter = this.selectAdapter(client, spaceType); adapter.assertIn(spaceId); const doc = await adapter.diff( spaceId, - docId, + id.guid, stateVector ? Buffer.from(stateVector, 'base64') : undefined ); @@ -293,11 +294,12 @@ export class SpaceSyncGateway ): Promise> { const { spaceType, spaceId, docId, updates } = message; const adapter = this.selectAdapter(client, spaceType); + const id = new DocID(docId, spaceId); - // TODO(@forehalo): we might need to check write permission before push updates + await this.ac.user(user.id).doc(spaceId, id.guid).assert('Doc.Update'); const timestamp = await adapter.push( spaceId, - docId, + id.guid, updates.map(update => Buffer.from(update, 'base64')), user.id ); @@ -334,7 +336,7 @@ export class SpaceSyncGateway const { spaceType, spaceId, docId, update } = message; const adapter = this.selectAdapter(client, spaceType); - // TODO(@forehalo): we might need to check write permission before push updates + await this.ac.user(user.id).doc(spaceId, docId).assert('Doc.Update'); const timestamp = await adapter.push( spaceId, docId, @@ -472,7 +474,7 @@ abstract class SyncSocketAdapter { if (this.in(spaceId, roomType)) { return; } - await this.assertAccessible(spaceId, userId, WorkspaceRole.Collaborator); + await this.assertAccessible(spaceId, userId, 'Workspace.Sync'); return this.client.join(this.room(spaceId, roomType)); } @@ -496,7 +498,7 @@ abstract class SyncSocketAdapter { abstract assertAccessible( spaceId: string, userId: string, - permission?: WorkspaceRole + action: WorkspaceAction ): Promise; push(spaceId: string, docId: string, updates: Buffer[], editorId: string) { @@ -525,7 +527,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter { constructor( client: Socket, storage: DocStorageAdapter, - private readonly permission: PermissionService, + private readonly ac: AccessController, private readonly docReader: DocReader ) { super(SpaceType.Workspace, client, storage); @@ -537,8 +539,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter { updates: Buffer[], editorId: string ) { - const id = new DocID(docId, spaceId); - return super.push(spaceId, id.guid, updates, editorId); + return super.push(spaceId, docId, updates, editorId); } override async diff( @@ -546,20 +547,15 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter { docId: string, stateVector?: Uint8Array ) { - const id = new DocID(docId, spaceId); - return await this.docReader.getDocDiff(spaceId, id.guid, stateVector); + return await this.docReader.getDocDiff(spaceId, docId, stateVector); } async assertAccessible( spaceId: string, userId: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator + action: WorkspaceAction ) { - if ( - !(await this.permission.isWorkspaceMember(spaceId, userId, permission)) - ) { - throw new SpaceAccessDenied({ spaceId }); - } + await this.ac.user(userId).workspace(spaceId).assert(action); } } @@ -571,7 +567,7 @@ class UserspaceSyncAdapter extends SyncSocketAdapter { async assertAccessible( spaceId: string, userId: string, - _permission: WorkspaceRole = WorkspaceRole.Collaborator + _action: WorkspaceAction ) { 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 913fcfe4f2..63dbc1100f 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -1,20 +1,18 @@ import { Controller, Get, Logger, Param, Res } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; import type { Response } from 'express'; import { - AccessDenied, - ActionForbidden, BlobNotFound, CallMetric, DocHistoryNotFound, DocNotFound, InvalidHistoryTimestamp, } from '../../base'; +import { Models, PublicDocMode } from '../../models'; import { CurrentUser, Public } from '../auth'; import { PgWorkspaceDocStorageAdapter } from '../doc'; import { DocReader } from '../doc/reader'; -import { PermissionService, PublicDocMode } from '../permission'; +import { AccessController } from '../permission'; import { WorkspaceBlobStorage } from '../storage'; import { DocID } from '../utils/doc'; @@ -23,10 +21,10 @@ export class WorkspacesController { logger = new Logger(WorkspacesController.name); constructor( private readonly storage: WorkspaceBlobStorage, - private readonly permission: PermissionService, + private readonly ac: AccessController, private readonly workspace: PgWorkspaceDocStorageAdapter, private readonly docReader: DocReader, - private readonly prisma: PrismaClient + private readonly models: Models ) {} // get workspace blob @@ -41,18 +39,10 @@ export class WorkspacesController { @Param('name') name: string, @Res() res: Response ) { - // if workspace is public or have any public page, then allow to access - // otherwise, check permission - if ( - !(await this.permission.isPublicAccessible( - workspaceId, - workspaceId, - user?.id - )) - ) { - throw new ActionForbidden(); - } - + await this.ac + .user(user?.id ?? 'anonymous') + .workspace(workspaceId) + .assert('Workspace.Read'); const { body, metadata } = await this.storage.get(workspaceId, name); if (!body) { @@ -86,17 +76,17 @@ export class WorkspacesController { @Res() res: Response ) { const docId = new DocID(guid, ws); - if ( - // if a user has the permission - !(await this.permission.isPublicAccessible( - docId.workspace, - docId.guid, - user?.id - )) - ) { - throw new AccessDenied(); + if (docId.isWorkspace) { + await this.ac + .user(user?.id ?? 'anonymous') + .workspace(ws) + .assert('Workspace.Read'); + } else { + await this.ac + .user(user?.id ?? 'anonymous') + .doc(ws, guid) + .assert('Doc.Read'); } - const binResponse = await this.docReader.getDoc( docId.workspace, docId.guid @@ -111,16 +101,12 @@ export class WorkspacesController { if (!docId.isWorkspace) { // fetch the publish page mode for publish page - const publishPage = await this.prisma.workspaceDoc.findUnique({ - where: { - workspaceId_docId: { - workspaceId: docId.workspace, - docId: docId.guid, - }, - }, - }); + const doc = await this.models.workspace.getDoc( + docId.workspace, + docId.guid + ); const publishPageMode = - publishPage?.mode === PublicDocMode.Edgeless ? 'edgeless' : 'page'; + doc?.mode === PublicDocMode.Edgeless ? 'edgeless' : 'page'; res.setHeader('publish-mode', publishPageMode); } @@ -146,12 +132,7 @@ export class WorkspacesController { throw new InvalidHistoryTimestamp({ timestamp }); } - await this.permission.checkPagePermission( - docId.workspace, - docId.guid, - 'Doc.Read', - user.id - ); + await this.ac.user(user.id).doc(ws, guid).assert('Doc.Read'); const history = await this.workspace.getDocHistory( docId.workspace, diff --git a/packages/backend/server/src/core/workspaces/event.ts b/packages/backend/server/src/core/workspaces/event.ts index 324ba94347..07cdc41f90 100644 --- a/packages/backend/server/src/core/workspaces/event.ts +++ b/packages/backend/server/src/core/workspaces/event.ts @@ -44,21 +44,21 @@ export class WorkspaceEvents { async onRoleChanged({ userId, workspaceId, - permission, + role, }: Events['workspace.members.roleChanged']) { // send role changed mail await this.workspaceService.sendRoleChangedEmail(userId, { id: workspaceId, - role: permission, + role, }); } - @OnEvent('workspace.members.ownershipTransferred') + @OnEvent('workspace.owner.changed') async onOwnerTransferred({ workspaceId, from, to, - }: Events['workspace.members.ownershipTransferred']) { + }: Events['workspace.owner.changed']) { // send ownership transferred mail const fromUser = await this.models.user.getPublicUser(from); const toUser = await this.models.user.getPublicUser(to); diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index a9633ce8fc..37abf2f2f2 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 { PermissionService, WorkspaceRole } from '../../permission'; +import { AccessController } from '../../permission'; import { QuotaService } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobSizes, WorkspaceType } from '../types'; @@ -40,7 +40,7 @@ class ListedBlob { export class WorkspaceBlobResolver { logger = new Logger(WorkspaceBlobResolver.name); constructor( - private readonly permissions: PermissionService, + private readonly ac: AccessController, private readonly quota: QuotaService, private readonly storage: WorkspaceBlobStorage ) {} @@ -53,7 +53,10 @@ export class WorkspaceBlobResolver { @CurrentUser() user: CurrentUser, @Parent() workspace: WorkspaceType ) { - await this.permissions.checkWorkspace(workspace.id, user.id); + await this.ac + .user(user.id) + .workspace(workspace.id) + .assert('Workspace.Blobs.List'); return this.storage.list(workspace.id); } @@ -66,24 +69,6 @@ export class WorkspaceBlobResolver { return this.storage.totalSize(workspace.id); } - /** - * @deprecated use `workspace.blobs` instead - */ - @Query(() => [String], { - description: 'List blobs of workspace', - deprecationReason: 'use `workspace.blobs` instead', - }) - async listBlobs( - @CurrentUser() user: CurrentUser, - @Args('workspaceId') workspaceId: string - ) { - await this.permissions.checkWorkspace(workspaceId, user.id); - - return this.storage - .list(workspaceId) - .then(list => list.map(item => item.key)); - } - @Query(() => WorkspaceBlobSizes, { deprecationReason: 'use `user.quotaUsage` instead', }) @@ -99,11 +84,10 @@ export class WorkspaceBlobResolver { @Args({ name: 'blob', type: () => GraphQLUpload }) blob: FileUpload ) { - await this.permissions.checkWorkspace( - workspaceId, - user.id, - WorkspaceRole.Collaborator - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Blobs.Write'); const checkExceeded = await this.quota.getWorkspaceQuotaCalculator(workspaceId); @@ -159,7 +143,10 @@ export class WorkspaceBlobResolver { return false; } - await this.permissions.checkWorkspace(workspaceId, user.id); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Blobs.Write'); await this.storage.delete(workspaceId, key, permanently); @@ -171,11 +158,10 @@ export class WorkspaceBlobResolver { @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { - await this.permissions.checkWorkspace( - workspaceId, - user.id, - WorkspaceRole.Collaborator - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Blobs.Write'); await this.storage.release(workspaceId); diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 4a26ce4c7e..533ca5b556 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -17,7 +17,6 @@ import { Cache, DocActionDenied, DocDefaultRoleCanNotBeOwner, - DocIsNotPublic, ExpectToGrantDocUserRoles, ExpectToPublishDoc, ExpectToRevokeDocUserRoles, @@ -28,21 +27,20 @@ import { PaginationInput, registerObjectType, } from '../../../base'; -import { Models } from '../../../models'; +import { Models, PublicDocMode } from '../../../models'; import { CurrentUser } from '../../auth'; import { + AccessController, DOC_ACTIONS, DocAction, DocRole, - fixupDocRole, - mapDocRoleToPermissions, - PermissionService, - PublicDocMode, } from '../../permission'; import { PublicUserType } from '../../user'; -import { DocID } from '../../utils/doc'; import { WorkspaceType } from '../types'; -import { DotToUnderline, mapPermissionToGraphqlPermissions } from './workspace'; +import { + DotToUnderline, + mapPermissionsToGraphqlPermissions, +} from './workspace'; registerEnumType(PublicDocMode, { name: 'PublicDocMode', @@ -155,8 +153,11 @@ export class WorkspaceDocResolver { private readonly logger = new Logger(WorkspaceDocResolver.name); constructor( + /** + * @deprecated migrate to models + */ private readonly prisma: PrismaClient, - private readonly permission: PermissionService, + private readonly ac: AccessController, private readonly models: Models, private readonly cache: Cache ) {} @@ -174,12 +175,7 @@ export class WorkspaceDocResolver { complexity: 2, }) async publicDocs(@Parent() workspace: WorkspaceType) { - return this.prisma.workspaceDoc.findMany({ - where: { - workspaceId: workspace.id, - public: true, - }, - }); + return this.models.workspace.getPublicDocs(workspace.id); } @ResolveField(() => DocType, { @@ -203,14 +199,7 @@ export class WorkspaceDocResolver { @Parent() workspace: WorkspaceType, @Args('docId') docId: string ): Promise { - const doc = await this.prisma.workspaceDoc.findUnique({ - where: { - workspaceId_docId: { - workspaceId: workspace.id, - docId, - }, - }, - }); + const doc = await this.models.workspace.getDoc(workspace.id, docId); if (doc) { return doc; @@ -249,7 +238,7 @@ export class WorkspaceDocResolver { async publishDoc( @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, - @Args('docId') rawDocId: string, + @Args('docId') docId: string, @Args({ name: 'mode', type: () => PublicDocMode, @@ -258,28 +247,27 @@ export class WorkspaceDocResolver { }) mode: PublicDocMode ) { - const docId = new DocID(rawDocId, workspaceId); - - if (docId.isWorkspace) { + if (workspaceId === docId) { this.logger.error('Expect to publish doc, but it is a workspace', { workspaceId, - docId: rawDocId, + docId, }); throw new ExpectToPublishDoc(); } - await this.permission.checkPagePermission( - docId.workspace, - docId.guid, - 'Doc.Publish', - user.id + await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish'); + + const doc = await this.models.workspace.publishDoc( + workspaceId, + docId, + mode ); this.logger.log( - `Publish page ${rawDocId} with mode ${mode} in workspace ${workspaceId}` + `Publish page ${docId} with mode ${mode} in workspace ${workspaceId}` ); - return this.permission.publishPage(docId.workspace, docId.guid, mode); + return doc; } @Mutation(() => DocType, { @@ -297,44 +285,23 @@ export class WorkspaceDocResolver { async revokePublicDoc( @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, - @Args('docId') rawDocId: string + @Args('docId') docId: string ) { - const docId = new DocID(rawDocId, workspaceId); - - if (docId.isWorkspace) { + if (workspaceId === docId) { this.logger.error('Expect to revoke public doc, but it is a workspace', { workspaceId, - docId: rawDocId, + docId, }); throw new ExpectToRevokePublicDoc('Expect doc not to be workspace'); } - await this.permission.checkPagePermission( - docId.workspace, - docId.guid, - 'Doc.Publish', - user.id - ); + await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish'); - const isPublic = await this.permission.isPublicPage( - docId.workspace, - docId.guid - ); + const doc = await this.models.workspace.revokePublicDoc(workspaceId, docId); - const info = { - workspaceId, - docId: rawDocId, - }; - if (!isPublic) { - this.logger.log( - `Expect to revoke public doc, but it is not public (${JSON.stringify(info)})` - ); - throw new DocIsNotPublic('Doc is not public'); - } + this.logger.log(`Revoke public doc ${docId} in workspace ${workspaceId}`); - this.logger.log(`Revoke public doc (${JSON.stringify(info)})`); - - return this.permission.revokePublicPage(docId.workspace, docId.guid); + return doc; } private async tryFixDocOwner(workspaceId: string, docId: string) { @@ -357,13 +324,7 @@ export class WorkspaceDocResolver { return; } - const owner = await this.prisma.workspaceDocUserPermission.findFirst({ - where: { - workspaceId, - docId, - type: DocRole.Owner, - }, - }); + const owner = await this.models.docUser.getOwner(workspaceId, docId); // skip if owner already exists if (owner) { @@ -387,18 +348,11 @@ export class WorkspaceDocResolver { // try workspace.owner if (!fixedOwner) { - const owner = await this.permission.getWorkspaceOwner(workspaceId); + const owner = await this.models.workspaceUser.getOwner(workspaceId); fixedOwner = owner.id; } - await this.prisma.workspaceDocUserPermission.createMany({ - data: { - workspaceId, - docId, - userId: fixedOwner, - type: DocRole.Owner, - }, - }); + await this.models.docUser.setOwner(workspaceId, docId, fixedOwner); this.logger.debug( `Fixed doc owner for ${docId} in workspace ${workspaceId}, new owner: ${fixedOwner}` @@ -411,8 +365,7 @@ export class DocResolver { private readonly logger = new Logger(DocResolver.name); constructor( - private readonly prisma: PrismaClient, - private readonly permission: PermissionService, + private readonly ac: AccessController, private readonly models: Models ) {} @@ -421,33 +374,9 @@ export class DocResolver { @CurrentUser() user: CurrentUser, @Parent() doc: DocType ): Promise> { - const [permission, workspacePermission] = await this.prisma.$transaction( - tx => - Promise.all([ - tx.workspaceDocUserPermission.findFirst({ - where: { - workspaceId: doc.workspaceId, - docId: doc.docId, - userId: user.id, - }, - }), - tx.workspaceUserPermission.findFirst({ - where: { - workspaceId: doc.workspaceId, - userId: user.id, - }, - }), - ]) - ); + const { permissions } = await this.ac.user(user.id).doc(doc).permissions(); - return mapPermissionToGraphqlPermissions( - mapDocRoleToPermissions( - fixupDocRole( - workspacePermission?.type, - permission?.type ?? doc.defaultRole - ) - ) - ); + return mapPermissionsToGraphqlPermissions(permissions); } @ResolveField(() => PaginatedGrantedDocUserType, { @@ -459,49 +388,20 @@ export class DocResolver { @Parent() doc: DocType, @Args('pagination', PaginationInput.decode) pagination: PaginationInput ): Promise { - await this.permission.checkPagePermission( + await this.ac.user(user.id).doc(doc).assert('Doc.Users.Read'); + + const [permissions, totalCount] = await this.models.docUser.paginate( doc.workspaceId, doc.docId, - 'Doc.Users.Read', - user.id + pagination ); - const [permissions, totalCount] = await this.prisma.$transaction(tx => { - return Promise.all([ - tx.workspaceDocUserPermission.findMany({ - where: { - workspaceId: doc.workspaceId, - docId: doc.docId, - createdAt: pagination.after - ? { - gt: pagination.after, - } - : undefined, - }, - orderBy: [ - { - type: 'desc', - }, - { - createdAt: 'desc', - }, - ], - take: pagination.first, - skip: pagination.offset, - }), - tx.workspaceDocUserPermission.count({ - where: { - workspaceId: doc.workspaceId, - docId: doc.docId, - }, - }), - ]); - }); - const publicUsers = await this.models.user.getPublicUsers( permissions.map(p => p.userId) ); + const publicUsersMap = new Map(publicUsers.map(pu => [pu.id, pu])); + return paginate( permissions.map(p => ({ ...p, @@ -518,12 +418,12 @@ export class DocResolver { @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) { + + if (input.workspaceId === input.docId) { this.logger.error( 'Expect to grant doc user roles, but it is a workspace', pairs @@ -533,18 +433,16 @@ export class DocResolver { 'Expect doc not to be workspace' ); } - await this.permission.checkPagePermission( - doc.workspace, - doc.guid, - 'Doc.Users.Manage', - user.id - ); - await this.permission.batchGrantPage( - doc.workspace, - doc.guid, + + await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage'); + + await this.models.docUser.batchSetUserRoles( + input.workspaceId, + input.docId, input.userIds, input.role ); + const info = { ...pairs, userIds: input.userIds, @@ -559,12 +457,11 @@ export class DocResolver { @CurrentUser() user: CurrentUser, @Args('input') input: RevokeDocUserRoleInput ): Promise { - const doc = new DocID(input.docId, input.workspaceId); const pairs = { spaceId: input.workspaceId, - docId: doc.guid, + docId: input.docId, }; - if (doc.isWorkspace) { + if (input.workspaceId === input.docId) { this.logger.error( 'Expect to revoke doc user roles, but it is a workspace', pairs @@ -574,13 +471,14 @@ export class DocResolver { 'Expect doc not to be workspace' ); } - await this.permission.checkPagePermission( - doc.workspace, - doc.guid, - 'Doc.Users.Manage', - user.id + await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage'); + + await this.models.docUser.delete( + input.workspaceId, + input.docId, + input.userId ); - await this.permission.revokePage(doc.workspace, doc.guid, input.userId); + const info = { ...pairs, userId: input.userId, @@ -594,12 +492,11 @@ export class DocResolver { @CurrentUser() user: CurrentUser, @Args('input') input: UpdateDocUserRoleInput ): Promise { - const doc = new DocID(input.docId, input.workspaceId); const pairs = { - spaceId: doc.workspace, - docId: doc.guid, + spaceId: input.workspaceId, + docId: input.docId, }; - if (doc.isWorkspace) { + if (input.workspaceId === input.docId) { this.logger.error( 'Expect to update doc user role, but it is a workspace', pairs @@ -610,28 +507,28 @@ export class DocResolver { ); } - await this.permission.checkPagePermission( - doc.workspace, - doc.guid, - input.role === DocRole.Owner ? 'Doc.TransferOwner' : 'Doc.Users.Manage', - user.id - ); - - await this.permission.grantPage( - doc.workspace, - doc.guid, - input.userId, - input.role - ); - const info = { ...pairs, userId: input.userId, role: input.role, }; + if (input.role === DocRole.Owner) { + await this.ac.user(user.id).doc(input).assert('Doc.TransferOwner'); + await this.models.docUser.setOwner( + input.workspaceId, + input.docId, + input.userId + ); this.logger.log(`Transfer doc owner (${JSON.stringify(info)})`); } else { + await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage'); + await this.models.docUser.set( + input.workspaceId, + input.docId, + input.userId, + input.role + ); this.logger.log(`Update doc user role (${JSON.stringify(info)})`); } @@ -649,12 +546,11 @@ export class DocResolver { ); throw new DocDefaultRoleCanNotBeOwner(); } - const doc = new DocID(input.docId, input.workspaceId); const pairs = { - spaceId: doc.workspace, - docId: doc.guid, + spaceId: input.workspaceId, + docId: input.docId, }; - if (doc.isWorkspace) { + if (input.workspaceId === input.docId) { this.logger.error( 'Expect to update page default role, but it is a workspace', pairs @@ -665,12 +561,7 @@ export class DocResolver { ); } try { - await this.permission.checkPagePermission( - doc.workspace, - doc.guid, - 'Doc.Users.Manage', - user.id - ); + await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage'); } catch (error) { if (error instanceof DocActionDenied) { this.logger.log( @@ -684,22 +575,11 @@ export class DocResolver { } throw error; } - await this.prisma.workspaceDoc.upsert({ - where: { - workspaceId_docId: { - workspaceId: doc.workspace, - docId: doc.guid, - }, - }, - update: { - defaultRole: input.role, - }, - create: { - workspaceId: doc.workspace, - docId: doc.guid, - defaultRole: input.role, - }, - }); + await this.models.workspace.setDocDefaultRole( + input.workspaceId, + input.docId, + input.role + ); return true; } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/history.ts b/packages/backend/server/src/core/workspaces/resolvers/history.ts index b3a858624c..b031b5ecf7 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 { PermissionService } from '../../permission'; +import { AccessController } from '../../permission'; import { DocID } from '../../utils/doc'; import { WorkspaceType } from '../types'; import { EditorType } from './workspace'; @@ -37,7 +37,7 @@ class DocHistoryType implements Partial { export class DocHistoryResolver { constructor( private readonly workspace: PgWorkspaceDocStorageAdapter, - private readonly permission: PermissionService + private readonly ac: AccessController ) {} @ResolveField(() => [DocHistoryType]) @@ -76,12 +76,7 @@ export class DocHistoryResolver { ): Promise { const docId = new DocID(guid, workspaceId); - await this.permission.checkPagePermission( - docId.workspace, - docId.guid, - 'Doc.Update', - user.id - ); + await this.ac.user(user.id).doc(docId).assert('Doc.Update'); await this.workspace.rollbackDoc( docId.workspace, diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index 87f5f48f0a..6c43ed8906 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -1,17 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; import { getStreamAsBuffer } from 'get-stream'; import { Cache, MailService, + NotFound, OnEvent, URLHelper, UserNotFound, } from '../../../base'; import { Models } from '../../../models'; import { DocReader } from '../../doc'; -import { PermissionService, WorkspaceRole } from '../../permission'; +import { WorkspaceRole } from '../../permission'; import { WorkspaceBlobStorage } from '../../storage'; export const defaultWorkspaceAvatar = @@ -32,8 +32,6 @@ export class WorkspaceService { private readonly cache: Cache, private readonly doc: DocReader, private readonly mailer: MailService, - private readonly permission: PermissionService, - private readonly prisma: PrismaClient, private readonly models: Models, private readonly url: URLHelper ) {} @@ -47,20 +45,16 @@ export class WorkspaceService { return invite; } - return await this.prisma.workspaceUserPermission - .findUniqueOrThrow({ - where: { - id: inviteId, - }, - select: { - workspaceId: true, - userId: true, - }, - }) - .then(r => ({ - workspaceId: r.workspaceId, - inviteeUserId: r.userId, - })); + const workspaceUser = await this.models.workspaceUser.getById(inviteId); + + if (!workspaceUser) { + throw new NotFound('Invitation not found'); + } + + return { + workspaceId: workspaceUser.workspaceId, + inviteeUserId: workspaceUser.userId, + }; } async getWorkspaceInfo(workspaceId: string) { @@ -115,7 +109,7 @@ export class WorkspaceService { : null; const inviter = inviterUserId ? await this.models.user.getPublicUser(inviterUserId) - : await this.permission.getWorkspaceOwner(workspaceId); + : await this.models.workspaceUser.getOwner(workspaceId); if (!inviter || !invitee) { this.logger.error( @@ -138,7 +132,7 @@ export class WorkspaceService { return; } - const owner = await this.permission.getWorkspaceOwner(target.workspace.id); + const owner = await this.models.workspaceUser.getOwner(target.workspace.id); await this.mailer.sendMemberInviteMail(target.email, { workspace: target.workspace, @@ -154,8 +148,8 @@ export class WorkspaceService { async sendTeamWorkspaceUpgradedEmail(workspaceId: string) { const workspace = await this.getWorkspaceInfo(workspaceId); - const owner = await this.permission.getWorkspaceOwner(workspaceId); - const admins = await this.permission.getWorkspaceAdmin(workspaceId); + const owner = await this.models.workspaceUser.getOwner(workspaceId); + const admins = await this.models.workspaceUser.getAdmins(workspaceId); await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, { workspace, @@ -188,8 +182,8 @@ export class WorkspaceService { } const workspace = await this.getWorkspaceInfo(workspaceId); - const owner = await this.permission.getWorkspaceOwner(workspaceId); - const admin = await this.permission.getWorkspaceAdmin(workspaceId); + const owner = await this.models.workspaceUser.getOwner(workspaceId); + const admin = await this.models.workspaceUser.getAdmins(workspaceId); for (const user of [owner, ...admin]) { await this.mailer.sendLinkInvitationReviewRequestMail(user.email, { @@ -260,7 +254,7 @@ export class WorkspaceService { workspaceId, }: Events['workspace.members.leave']) { const workspace = await this.getWorkspaceInfo(workspaceId); - const owner = await this.permission.getWorkspaceOwner(workspaceId); + const owner = await this.models.workspaceUser.getOwner(workspaceId); await this.mailer.sendMemberLeaveEmail(owner.email, { workspace, user, @@ -271,7 +265,7 @@ export class WorkspaceService { async onMemberRemoved({ userId, workspaceId, - }: Events['workspace.members.requestDeclined']) { + }: Events['workspace.members.removed']) { const user = await this.models.user.get(userId); if (!user) return; diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index 3b0fe5d89b..2fbd3a1e47 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -6,7 +6,7 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; +import { WorkspaceMemberStatus } from '@prisma/client'; import { nanoid } from 'nanoid'; import { @@ -17,11 +17,10 @@ import { RequestMutex, TooManyRequest, URLHelper, - UserFriendlyError, } from '../../../base'; import { Models } from '../../../models'; import { CurrentUser } from '../../auth'; -import { PermissionService, WorkspaceRole } from '../../permission'; +import { AccessController, WorkspaceRole } from '../../permission'; import { QuotaService } from '../../quota'; import { InviteLink, @@ -44,8 +43,7 @@ export class TeamWorkspaceResolver { private readonly cache: Cache, private readonly event: EventBus, private readonly url: URLHelper, - private readonly prisma: PrismaClient, - private readonly permissions: PermissionService, + private readonly ac: AccessController, private readonly models: Models, private readonly quota: QuotaService, private readonly mutex: RequestMutex, @@ -68,21 +66,20 @@ export class TeamWorkspaceResolver { @Args({ name: 'emails', type: () => [String] }) emails: string[], @Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean ) { - await this.permissions.checkWorkspace( - workspaceId, - user.id, - WorkspaceRole.Admin - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Users.Manage'); if (emails.length > 512) { - return new TooManyRequest(); + throw new TooManyRequest(); } // lock to prevent concurrent invite const lockFlag = `invite:${workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { - return new TooManyRequest(); + throw new TooManyRequest(); } const quota = await this.quota.getWorkspaceSeatQuota(workspaceId); @@ -93,13 +90,10 @@ export class TeamWorkspaceResolver { try { let target = await this.models.user.getUserByEmail(email); if (target) { - const originRecord = - await this.prisma.workspaceUserPermission.findFirst({ - where: { - workspaceId, - userId: target.id, - }, - }); + const originRecord = await this.models.workspaceUser.get( + workspaceId, + target.id + ); // only invite if the user is not already in the workspace if (originRecord) continue; } else { @@ -110,7 +104,7 @@ export class TeamWorkspaceResolver { } const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit; - ret.inviteId = await this.permissions.grant( + const role = await this.models.workspaceUser.set( workspaceId, target.id, WorkspaceRole.Collaborator, @@ -118,6 +112,7 @@ export class TeamWorkspaceResolver { ? WorkspaceMemberStatus.NeedMoreSeat : WorkspaceMemberStatus.Pending ); + ret.inviteId = role.id; // NOTE: we always send email even seat not enough // because at this moment we cannot know whether the seat increase charge was successful // after user click the invite link, we can check again and reject if charge failed @@ -156,11 +151,10 @@ export class TeamWorkspaceResolver { @Parent() workspace: WorkspaceType, @CurrentUser() user: CurrentUser ) { - await this.permissions.checkWorkspace( - workspace.id, - user.id, - WorkspaceRole.Admin - ); + await this.ac + .user(user.id) + .workspace(workspace.id) + .assert('Workspace.Users.Manage'); const cacheId = `workspace:inviteLink:${workspace.id}`; const id = await this.cache.get<{ inviteId: string }>(cacheId); @@ -183,11 +177,11 @@ export class TeamWorkspaceResolver { @Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime }) expireTime: WorkspaceInviteLinkExpireTime ): Promise { - await this.permissions.checkWorkspace( - workspaceId, - user.id, - WorkspaceRole.Admin - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Users.Manage'); + const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`; const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId); if (typeof invite?.inviteId === 'string') { @@ -219,134 +213,80 @@ export class TeamWorkspaceResolver { @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { - await this.permissions.checkWorkspace( - workspaceId, - user.id, - WorkspaceRole.Admin - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Users.Manage'); + const cacheId = `workspace:inviteLink:${workspaceId}`; return await this.cache.delete(cacheId); } - @Mutation(() => String) + @Mutation(() => Boolean) async approveMember( @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { - await this.permissions.checkWorkspace( - workspaceId, - user.id, - WorkspaceRole.Admin - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Users.Manage'); - try { - // lock to prevent concurrent invite and grant - const lockFlag = `invite:${workspaceId}`; - await using lock = await this.mutex.acquire(lockFlag); - if (!lock) { - return new TooManyRequest(); + const role = await this.models.workspaceUser.get(workspaceId, userId); + + if (role) { + if (role.status === WorkspaceMemberStatus.UnderReview) { + const result = await this.models.workspaceUser.setStatus( + workspaceId, + userId, + WorkspaceMemberStatus.Accepted + ); + + this.event.emit('workspace.members.requestApproved', { + inviteId: result.id, + }); } - - const status = await this.permissions.getWorkspaceMemberStatus( - workspaceId, - userId - ); - if (status) { - if (status === WorkspaceMemberStatus.UnderReview) { - const result = await this.permissions.grant( - workspaceId, - userId, - WorkspaceRole.Collaborator, - WorkspaceMemberStatus.Accepted - ); - - if (result) { - this.event.emit('workspace.members.requestApproved', { - inviteId: result, - }); - } - return result; - } - return new TooManyRequest(); - } else { - return new MemberNotFoundInSpace({ spaceId: workspaceId }); - } - } catch (e) { - this.logger.error('failed to invite user', e); - return new TooManyRequest(); + return true; + } else { + throw new MemberNotFoundInSpace({ spaceId: workspaceId }); } } - @Mutation(() => String) + @Mutation(() => Boolean) async grantMember( @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('userId') userId: string, - @Args('permission', { type: () => WorkspaceRole }) permission: WorkspaceRole + @Args('permission', { type: () => WorkspaceRole }) newRole: WorkspaceRole ) { - // non-team workspace can only transfer ownership, but no detailed permission control - if (permission !== WorkspaceRole.Owner) { + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert( + newRole === WorkspaceRole.Owner + ? 'Workspace.TransferOwner' + : 'Workspace.Users.Manage' + ); + + const role = await this.models.workspaceUser.get(workspaceId, userId); + + if (!role) { + throw new MemberNotFoundInSpace({ spaceId: workspaceId }); + } + + if (newRole === WorkspaceRole.Owner) { + await this.models.workspaceUser.setOwner(workspaceId, userId); + } else { + // non-team workspace can only transfer ownership, but no detailed permission control const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId); if (!isTeam) { throw new ActionForbiddenOnNonTeamWorkspace(); } + + await this.models.workspaceUser.set(workspaceId, userId, newRole); } - await this.permissions.checkWorkspace( - workspaceId, - user.id, - permission >= WorkspaceRole.Admin - ? WorkspaceRole.Owner - : WorkspaceRole.Admin - ); - - try { - // lock to prevent concurrent invite and grant - const lockFlag = `invite:${workspaceId}`; - await using lock = await this.mutex.acquire(lockFlag); - if (!lock) { - return new TooManyRequest(); - } - - const isMember = await this.permissions.isWorkspaceMember( - workspaceId, - userId - ); - if (isMember) { - const result = await this.permissions.grant( - workspaceId, - userId, - permission - ); - - if (result) { - if (permission === WorkspaceRole.Owner) { - this.event.emit('workspace.members.ownershipTransferred', { - workspaceId, - from: user.id, - to: userId, - }); - } else { - this.event.emit('workspace.members.roleChanged', { - userId, - workspaceId, - permission, - }); - } - } - - return result; - } else { - return new MemberNotFoundInSpace({ spaceId: workspaceId }); - } - } catch (e) { - this.logger.error('failed to invite user', e); - // pass through user friendly error - if (e instanceof UserFriendlyError) { - return e; - } - return new TooManyRequest(); - } + return true; } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 257b53dc26..ce59b66d1b 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -9,7 +9,7 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { Prisma, PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; +import { WorkspaceMemberStatus } from '@prisma/client'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import type { FileUpload } from '../../../base'; @@ -17,10 +17,13 @@ import { AFFiNELogger, AlreadyInSpace, Cache, + CanNotRevokeYourself, DocNotFound, EventBus, InternalServerError, + MemberNotFoundInSpace, MemberQuotaExceeded, + OwnerCanNotLeaveWorkspace, QueryTooLong, registerObjectType, RequestMutex, @@ -33,10 +36,9 @@ import { } from '../../../base'; import { Models } from '../../../models'; import { CurrentUser, Public } from '../../auth'; -import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc'; +import { type Editor } from '../../doc'; import { - mapWorkspaceRoleToPermissions, - PermissionService, + AccessController, WORKSPACE_ACTIONS, WorkspaceAction, WorkspaceRole, @@ -56,7 +58,7 @@ export type DotToUnderline = ? `${Prefix}_${DotToUnderline}` : T; -export function mapPermissionToGraphqlPermissions( +export function mapPermissionsToGraphqlPermissions( permission: Record ): Record, boolean> { return Object.fromEntries( @@ -126,14 +128,12 @@ export class WorkspaceRolePermissions { export class WorkspaceResolver { constructor( private readonly cache: Cache, - private readonly prisma: PrismaClient, - private readonly permissions: PermissionService, + private readonly ac: AccessController, private readonly quota: QuotaService, private readonly models: Models, private readonly event: EventBus, private readonly mutex: RequestMutex, private readonly workspaceService: WorkspaceService, - private readonly workspaceStorage: PgWorkspaceDocStorageAdapter, private readonly logger: AFFiNELogger ) { logger.setContext(WorkspaceResolver.name); @@ -152,13 +152,27 @@ export class WorkspaceResolver { return workspace.role; } - const role = await this.permissions.get(workspace.id, user.id); + const { role } = await this.ac + .user(user.id) + .workspace(workspace.id) + .permissions(); - if (!role) { - throw new SpaceAccessDenied({ spaceId: workspace.id }); - } + return role ?? WorkspaceRole.External; + } - return role; + @ResolveField(() => WorkspacePermissions, { + description: 'map of action permissions', + }) + async permissions( + @CurrentUser() user: CurrentUser, + @Parent() workspace: WorkspaceType + ) { + const { permissions } = await this.ac + .user(user.id) + .workspace(workspace.id) + .permissions(); + + return mapPermissionsToGraphqlPermissions(permissions); } @ResolveField(() => Int, { @@ -166,7 +180,7 @@ export class WorkspaceResolver { complexity: 2, }) memberCount(@Parent() workspace: WorkspaceType) { - return this.permissions.getWorkspaceMemberCount(workspace.id); + return this.models.workspaceUser.count(workspace.id); } @ResolveField(() => Boolean, { @@ -174,14 +188,7 @@ export class WorkspaceResolver { complexity: 2, }) async initialized(@Parent() workspace: WorkspaceType) { - return this.prisma.snapshot - .count({ - where: { - id: workspace.id, - workspaceId: workspace.id, - }, - }) - .then(count => count > 0); + return this.models.doc.exists(workspace.id, workspace.id); } @ResolveField(() => UserType, { @@ -189,7 +196,7 @@ export class WorkspaceResolver { complexity: 2, }) async owner(@Parent() workspace: WorkspaceType) { - return this.permissions.getWorkspaceOwner(workspace.id); + return this.models.workspaceUser.getOwner(workspace.id); } @ResolveField(() => [InviteUserType], { @@ -197,44 +204,48 @@ export class WorkspaceResolver { complexity: 2, }) async members( + @CurrentUser() user: CurrentUser, @Parent() workspace: WorkspaceType, @Args('skip', { type: () => Int, nullable: true }) skip?: number, @Args('take', { type: () => Int, nullable: true }) take?: number, @Args('query', { type: () => String, nullable: true }) query?: string ) { - const args: Prisma.WorkspaceUserPermissionFindManyArgs = { - where: { workspaceId: workspace.id }, - skip, - take: take || 8, - orderBy: [{ createdAt: 'asc' }, { type: 'desc' }], - }; + await this.ac + .user(user.id) + .workspace(workspace.id) + .assert('Workspace.Users.Read'); if (query) { if (query.length > 255) { throw new QueryTooLong({ max: 255 }); } - // @ts-expect-error not null - args.where.user = { - // TODO(@forehalo): case-insensitive search later - OR: [{ name: { contains: query } }, { email: { contains: query } }], - }; + const list = await this.models.workspaceUser.search(workspace.id, query, { + offset: skip ?? 0, + first: take ?? 8, + }); + + return list.map(({ id, accepted, status, type, user }) => ({ + ...user, + permission: type, + inviteId: id, + accepted, + status, + })); + } else { + const [list] = await this.models.workspaceUser.paginate(workspace.id, { + offset: skip ?? 0, + first: take ?? 8, + }); + + return list.map(({ id, accepted, status, type, user }) => ({ + ...user, + permission: type, + inviteId: id, + accepted, + status, + })); } - - const data = await this.prisma.workspaceUserPermission.findMany({ - ...args, - include: { - user: true, - }, - }); - - return data.map(({ id, accepted, status, type, user }) => ({ - ...user, - permission: type, - inviteId: id, - accepted, - status, - })); } @ResolveField(() => WorkspacePageMeta, { @@ -245,15 +256,7 @@ export class WorkspaceResolver { @Parent() workspace: WorkspaceType, @Args('pageId') pageId: string ) { - const metadata = await this.prisma.snapshot.findFirst({ - where: { workspaceId: workspace.id, id: pageId }, - select: { - createdAt: true, - updatedAt: true, - createdByUser: { select: { name: true, avatarUrl: true } }, - updatedByUser: { select: { name: true, avatarUrl: true } }, - }, - }); + const metadata = await this.models.doc.getMeta(workspace.id, pageId); if (!metadata) { throw new DocNotFound({ spaceId: workspace.id, docId: pageId }); } @@ -284,29 +287,35 @@ export class WorkspaceResolver { @Query(() => Boolean, { description: 'Get is owner of workspace', complexity: 2, + deprecationReason: 'use WorkspaceType[role] instead', }) async isOwner( @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { - const data = await this.permissions.tryGetWorkspaceOwner(workspaceId); + const role = await this.models.workspaceUser.getActive( + workspaceId, + user.id + ); - return data?.user?.id === user.id; + return role?.type === WorkspaceRole.Owner; } @Query(() => Boolean, { description: 'Get is admin of workspace', complexity: 2, + deprecationReason: 'use WorkspaceType[role] instead', }) async isAdmin( @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { - return this.permissions.tryCheckWorkspaceIs( + const role = await this.models.workspaceUser.getActive( workspaceId, - user.id, - WorkspaceRole.Admin + user.id ); + + return role?.type === WorkspaceRole.Admin; } @Query(() => [WorkspaceType], { @@ -314,38 +323,30 @@ export class WorkspaceResolver { complexity: 2, }) async workspaces(@CurrentUser() user: CurrentUser) { - const data = await this.prisma.workspaceUserPermission.findMany({ - where: { - userId: user.id, - OR: [ - { - accepted: true, - }, - { - status: WorkspaceMemberStatus.Accepted, - }, - ], - }, - include: { - workspace: true, - }, - }); + const roles = await this.models.workspaceUser.getUserActiveRoles(user.id); - return data.map(({ workspace, type }) => { - return { - ...workspace, - permission: type, - role: type, - }; - }); + const map = new Map( + roles.map(({ workspaceId, type }) => [workspaceId, type]) + ); + + const workspaces = await this.models.workspace.findMany( + roles.map(({ workspaceId }) => workspaceId) + ); + + return workspaces.map(workspace => ({ + ...workspace, + permission: map.get(workspace.id), + role: map.get(workspace.id), + })); } @Query(() => WorkspaceType, { description: 'Get workspace by id', }) async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) { - await this.permissions.checkWorkspace(id, user.id); - const workspace = await this.prisma.workspace.findUnique({ where: { id } }); + await this.ac.user(user.id).workspace(id).assert('Workspace.Read'); + + const workspace = await this.models.workspace.get(id); if (!workspace) { throw new SpaceNotFound({ spaceId: id }); @@ -356,22 +357,24 @@ export class WorkspaceResolver { @Query(() => WorkspaceRolePermissions, { description: 'Get workspace role permissions', + deprecationReason: 'use WorkspaceType[permissions] instead', }) async workspaceRolePermissions( @CurrentUser() user: CurrentUser, @Args('id') id: string ): Promise { - const workspace = await this.prisma.workspaceUserPermission.findFirst({ - where: { workspaceId: id, userId: user.id }, - }); - if (!workspace) { + const { role, permissions } = await this.ac + .user(user.id) + .workspace(id) + .permissions(); + + if (!role) { throw new SpaceAccessDenied({ spaceId: id }); } + return { - role: workspace.type, - permissions: mapPermissionToGraphqlPermissions( - mapWorkspaceRoleToPermissions(workspace.type) - ), + role, + permissions: mapPermissionsToGraphqlPermissions(permissions), }; } @@ -385,19 +388,7 @@ export class WorkspaceResolver { @Args({ name: 'init', type: () => GraphQLUpload, nullable: true }) init: FileUpload | null ) { - const workspace = await this.prisma.workspace.create({ - data: { - public: false, - permissions: { - create: { - type: WorkspaceRole.Owner, - userId: user.id, - accepted: true, - status: WorkspaceMemberStatus.Accepted, - }, - }, - }, - }); + const workspace = await this.models.workspace.create(user.id); if (init) { // convert stream to buffer @@ -413,13 +404,12 @@ export class WorkspaceResolver { const buffer = chunks.length ? Buffer.concat(chunks) : null; if (buffer) { - await this.prisma.snapshot.create({ - data: { - id: workspace.id, - workspaceId: workspace.id, - blob: buffer, - updatedAt: new Date(), - }, + await this.models.doc.upsert({ + spaceId: workspace.id, + docId: workspace.id, + blob: buffer, + timestamp: Date.now(), + editorId: user.id, }); } } @@ -435,14 +425,11 @@ export class WorkspaceResolver { @Args({ name: 'input', type: () => UpdateWorkspaceInput }) { id, ...updates }: UpdateWorkspaceInput ) { - await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Admin); - - return this.prisma.workspace.update({ - where: { - id, - }, - data: updates, - }); + await this.ac + .user(user.id) + .workspace(id) + .assert('Workspace.Settings.Update'); + return this.models.workspace.update(id, updates); } @Mutation(() => Boolean) @@ -450,16 +437,9 @@ export class WorkspaceResolver { @CurrentUser() user: CurrentUser, @Args('id') id: string ) { - await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Owner); + await this.ac.user(user.id).workspace(id).assert('Workspace.Delete'); - await this.prisma.workspace.delete({ - where: { - id, - }, - }); - await this.workspaceStorage.deleteSpace(id); - - this.event.emit('workspace.deleted', { id }); + await this.models.workspace.delete(id); return true; } @@ -477,11 +457,10 @@ export class WorkspaceResolver { }) _permission?: WorkspaceRole ) { - await this.permissions.checkWorkspace( - workspaceId, - user.id, - WorkspaceRole.Admin - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Users.Manage'); try { // lock to prevent concurrent invite and grant @@ -494,53 +473,40 @@ export class WorkspaceResolver { // member limit check await this.quota.checkSeat(workspaceId); - let target = await this.models.user.getUserByEmail(email); - if (target) { - const originRecord = - await this.prisma.workspaceUserPermission.findFirst({ - where: { - workspaceId, - userId: target.id, - }, - }); + let user = await this.models.user.getUserByEmail(email); + if (user) { + const role = await this.models.workspaceUser.get(workspaceId, user.id); // only invite if the user is not already in the workspace - if (originRecord) return originRecord.id; + if (role) return role.id; } else { - target = await this.models.user.create({ + user = await this.models.user.create({ email, registered: false, }); } - const inviteId = await this.permissions.grant( + const role = await this.models.workspaceUser.set( workspaceId, - target.id, + user.id, WorkspaceRole.Collaborator ); + if (sendInviteMail) { try { - await this.workspaceService.sendInviteEmail(inviteId); + await this.workspaceService.sendInviteEmail(role.id); } catch (e) { - const ret = await this.permissions.revokeWorkspace( - workspaceId, - target.id + await this.models.workspaceUser.delete(workspaceId, user.id); + + this.logger.warn( + `failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}` ); - if (!ret) { - this.logger.fatal( - `failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}` - ); - } else { - this.logger.warn( - `failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}` - ); - } throw new InternalServerError( 'Failed to send invite email. Please try again.' ); } } - return inviteId; + return role.id; } catch (e) { // pass through user friendly error if (e instanceof UserFriendlyError) { @@ -563,7 +529,7 @@ export class WorkspaceResolver { const { workspaceId, inviteeUserId } = await this.workspaceService.getInviteInfo(inviteId); const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId); - const owner = await this.permissions.getWorkspaceOwner(workspaceId); + const owner = await this.models.workspaceUser.getOwner(workspaceId); const inviteeId = inviteeUserId || user?.id; if (!inviteeId) throw new UserNotFound(); @@ -578,28 +544,47 @@ export class WorkspaceResolver { @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { - const isAdmin = await this.permissions.tryCheckWorkspaceIs( - workspaceId, - userId, - WorkspaceRole.Admin - ); - - if (isAdmin) { - // only owner can revoke workspace admin - await this.permissions.checkWorkspaceIs( - workspaceId, - user.id, - WorkspaceRole.Owner - ); - } else { - await this.permissions.checkWorkspace( - workspaceId, - user.id, - WorkspaceRole.Admin - ); + if (userId === user.id) { + throw new CanNotRevokeYourself(); } - return await this.permissions.revokeWorkspace(workspaceId, userId); + const role = await this.models.workspaceUser.get(workspaceId, userId); + + if (!role) { + throw new MemberNotFoundInSpace({ spaceId: workspaceId }); + } + + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert( + role.type === WorkspaceRole.Admin + ? 'Workspace.Adminitrators.Manage' + : 'Workspace.Users.Manage' + ); + + await this.models.workspaceUser.delete(workspaceId, userId); + + const count = await this.models.workspaceUser.count(workspaceId); + + this.event.emit('workspace.members.updated', { + workspaceId, + count, + }); + + if (role.status === WorkspaceMemberStatus.UnderReview) { + this.event.emit('workspace.members.requestDeclined', { + userId, + workspaceId, + }); + } else { + this.event.emit('workspace.members.removed', { + userId, + workspaceId, + }); + } + + return true; } @Mutation(() => Boolean) @@ -617,11 +602,12 @@ export class WorkspaceResolver { } if (user) { - const status = await this.permissions.getWorkspaceMemberStatus( + const role = await this.models.workspaceUser.getActive( workspaceId, user.id ); - if (status === WorkspaceMemberStatus.Accepted) { + + if (role) { throw new AlreadyInSpace({ spaceId: workspaceId }); } @@ -630,42 +616,39 @@ export class WorkspaceResolver { `workspace:inviteLink:${workspaceId}` ); if (invite?.inviteId === inviteId) { - const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId); const seatAvailable = await this.quota.tryCheckSeat(workspaceId); - if (!seatAvailable) { + if (seatAvailable) { + const invite = await this.models.workspaceUser.set( + workspaceId, + user.id, + WorkspaceRole.Collaborator, + WorkspaceMemberStatus.UnderReview + ); + this.event.emit('workspace.members.reviewRequested', { + inviteId: invite.id, + }); + return true; + } else { + const isTeam = + await this.workspaceService.isTeamWorkspace(workspaceId); // only team workspace allow over limit if (isTeam) { - await this.permissions.grant( + await this.models.workspaceUser.set( workspaceId, user.id, WorkspaceRole.Collaborator, WorkspaceMemberStatus.NeedMoreSeatAndReview ); const memberCount = - await this.permissions.getWorkspaceMemberCount(workspaceId); + await this.models.workspaceUser.count(workspaceId); this.event.emit('workspace.members.updated', { workspaceId, count: memberCount, }); return true; - } else if (!status) { + } else { throw new MemberQuotaExceeded(); } - } else { - const inviteId = await this.permissions.grant(workspaceId, user.id); - if (isTeam) { - this.event.emit('workspace.members.reviewRequested', { - inviteId, - }); - } - // invite by link need admin to approve - return await this.permissions.acceptWorkspaceInvitation( - inviteId, - workspaceId, - isTeam - ? WorkspaceMemberStatus.UnderReview - : WorkspaceMemberStatus.Accepted - ); } } } @@ -675,10 +658,8 @@ export class WorkspaceResolver { if (!success) throw new UserNotFound(); } - return await this.permissions.acceptWorkspaceInvitation( - inviteId, - workspaceId - ); + await this.models.workspaceUser.accept(inviteId); + return true; } @Mutation(() => Boolean) @@ -692,8 +673,19 @@ export class WorkspaceResolver { }) _workspaceName?: string ) { - await this.permissions.checkWorkspace(workspaceId, user.id); - const success = this.permissions.revokeWorkspace(workspaceId, user.id); + const role = await this.models.workspaceUser.getActive( + workspaceId, + user.id + ); + if (!role) { + throw new SpaceAccessDenied({ spaceId: workspaceId }); + } + + if (role.type === WorkspaceRole.Owner) { + throw new OwnerCanNotLeaveWorkspace(); + } + + await this.models.workspaceUser.delete(workspaceId, user.id); if (sendLeaveMail) { this.event.emit('workspace.members.leave', { @@ -705,6 +697,6 @@ export class WorkspaceResolver { }); } - return success; + return true; } } diff --git a/packages/backend/server/src/data/migrations/1732861452428-migrate-invite-status.ts b/packages/backend/server/src/data/migrations/1732861452428-migrate-invite-status.ts index 05d36714eb..2cbc0136f1 100644 --- a/packages/backend/server/src/data/migrations/1732861452428-migrate-invite-status.ts +++ b/packages/backend/server/src/data/migrations/1732861452428-migrate-invite-status.ts @@ -3,7 +3,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; export class MigrateInviteStatus1732861452428 { // do the migration static async up(db: PrismaClient) { - await db.workspaceUserPermission.updateMany({ + await db.workspaceUserRole.updateMany({ where: { accepted: true, }, diff --git a/packages/backend/server/src/models/common/doc.ts b/packages/backend/server/src/models/common/doc.ts index 9206e7e1ea..0c393d81c8 100644 --- a/packages/backend/server/src/models/common/doc.ts +++ b/packages/backend/server/src/models/common/doc.ts @@ -12,3 +12,8 @@ export interface Doc { } export type DocEditor = Pick; + +export enum PublicDocMode { + Page, + Edgeless, +} diff --git a/packages/backend/server/src/models/common/index.ts b/packages/backend/server/src/models/common/index.ts index 8f93c2e5cf..346b83cca9 100644 --- a/packages/backend/server/src/models/common/index.ts +++ b/packages/backend/server/src/models/common/index.ts @@ -1,3 +1,3 @@ export * from './doc'; export * from './feature'; -export * from './page'; +export * from './role'; diff --git a/packages/backend/server/src/models/common/page.ts b/packages/backend/server/src/models/common/page.ts deleted file mode 100644 index cc7c6428f5..0000000000 --- a/packages/backend/server/src/models/common/page.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum PublicPageMode { - Page, - Edgeless, -} diff --git a/packages/backend/server/src/models/common/role.ts b/packages/backend/server/src/models/common/role.ts new file mode 100644 index 0000000000..0239ffa4fd --- /dev/null +++ b/packages/backend/server/src/models/common/role.ts @@ -0,0 +1,14 @@ +export enum DocRole { + External = 0, + Reader = 10, + Editor = 20, + Manager = 30, + Owner = 99, +} + +export enum WorkspaceRole { + External = -99, + Collaborator = 1, + Admin = 10, + Owner = 99, +} diff --git a/packages/backend/server/src/models/doc-user.ts b/packages/backend/server/src/models/doc-user.ts new file mode 100644 index 0000000000..9110444098 --- /dev/null +++ b/packages/backend/server/src/models/doc-user.ts @@ -0,0 +1,203 @@ +import assert from 'node:assert'; + +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { WorkspaceDocUserRole } from '@prisma/client'; + +import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base'; +import { BaseModel } from './base'; +import { DocRole } from './common'; + +@Injectable() +export class DocUserModel extends BaseModel { + /** + * Set or update the [Owner] of a doc. + * The old [Owner] will be changed to [Manager] if there is already an [Owner]. + */ + @Transactional() + async setOwner(workspaceId: string, docId: string, userId: string) { + const oldOwner = await this.db.workspaceDocUserRole.findFirst({ + where: { + workspaceId, + docId, + type: DocRole.Owner, + }, + }); + + if (oldOwner) { + await this.db.workspaceDocUserRole.update({ + where: { + workspaceId_docId_userId: { + workspaceId, + docId, + userId: oldOwner.userId, + }, + }, + data: { + type: DocRole.Manager, + }, + }); + } + + await this.db.workspaceDocUserRole.upsert({ + where: { + workspaceId_docId_userId: { + workspaceId, + docId, + userId, + }, + }, + update: { + type: DocRole.Owner, + }, + create: { + workspaceId, + docId, + userId, + type: DocRole.Owner, + }, + }); + + if (oldOwner) { + this.logger.log( + `Transfer doc owner of [${workspaceId}/${docId}] from [${oldOwner.userId}] to [${userId}]` + ); + } else { + this.logger.log( + `Set doc owner of [${workspaceId}/${docId}] to [${userId}]` + ); + } + } + + /** + * Set or update the Role of a user in a doc. + * + * NOTE: do not use this method to set the [Owner] of a doc. Use {@link setOwner} instead. + */ + @Transactional() + async set(workspaceId: string, docId: string, userId: string, role: DocRole) { + // internal misuse, throw directly + assert(role !== DocRole.Owner, 'Cannot set Owner role of a doc to a user.'); + + const oldRole = await this.get(workspaceId, docId, userId); + + if (oldRole && oldRole.type === role) { + return oldRole; + } + + const newRole = await this.db.workspaceDocUserRole.upsert({ + where: { + workspaceId_docId_userId: { + workspaceId, + docId, + userId, + }, + }, + update: { + type: role, + }, + create: { + workspaceId, + docId, + userId, + type: role, + }, + }); + + return newRole; + } + + async batchSetUserRoles( + workspaceId: string, + docId: string, + userIds: string[], + role: DocRole + ) { + if (userIds.length === 0) { + return 0; + } + + if (role === DocRole.Owner) { + throw new CanNotBatchGrantDocOwnerPermissions(); + } + + const result = await this.db.workspaceDocUserRole.createMany({ + skipDuplicates: true, + data: userIds.map(userId => ({ + workspaceId, + docId, + userId, + type: role, + })), + }); + + return result.count; + } + + async delete(workspaceId: string, docId: string, userId: string) { + await this.db.workspaceDocUserRole.deleteMany({ + where: { + workspaceId, + docId, + userId, + }, + }); + } + + async getOwner(workspaceId: string, docId: string) { + return await this.db.workspaceDocUserRole.findFirst({ + where: { + workspaceId, + docId, + type: DocRole.Owner, + }, + }); + } + + async get(workspaceId: string, docId: string, userId: string) { + return await this.db.workspaceDocUserRole.findUnique({ + where: { + workspaceId_docId_userId: { + workspaceId, + docId, + userId, + }, + }, + }); + } + + count(workspaceId: string, docId: string) { + return this.db.workspaceDocUserRole.count({ + where: { + workspaceId, + docId, + }, + }); + } + + async paginate( + workspaceId: string, + docId: string, + pagination: PaginationInput + ): Promise<[WorkspaceDocUserRole[], number]> { + return await Promise.all([ + this.db.workspaceDocUserRole.findMany({ + where: { + workspaceId, + docId, + createdAt: pagination.after + ? { + gte: pagination.after, + } + : undefined, + }, + orderBy: { + createdAt: 'asc', + }, + take: pagination.first, + skip: pagination.offset + (pagination.after ? 1 : 0), + }), + this.count(workspaceId, docId), + ]); + } +} diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 745d652bbe..653f58272d 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core'; import { ApplyType } from '../base'; import { DocModel } from './doc'; +import { DocUserModel } from './doc-user'; import { FeatureModel } from './feature'; import { PageModel } from './page'; import { MODELS_SYMBOL } from './provider'; @@ -18,6 +19,7 @@ import { UserFeatureModel } from './user-feature'; import { VerificationTokenModel } from './verification-token'; import { WorkspaceModel } from './workspace'; import { WorkspaceFeatureModel } from './workspace-feature'; +import { WorkspaceUserModel } from './workspace-user'; const MODELS = { user: UserModel, @@ -30,6 +32,8 @@ const MODELS = { workspaceFeature: WorkspaceFeatureModel, doc: DocModel, userDoc: UserDocModel, + workspaceUser: WorkspaceUserModel, + docUser: DocUserModel, }; type ModelsType = { @@ -83,6 +87,7 @@ export class ModelsModule {} export * from './common'; export * from './doc'; +export * from './doc-user'; export * from './feature'; export * from './page'; export * from './session'; @@ -92,3 +97,4 @@ export * from './user-feature'; export * from './verification-token'; export * from './workspace'; export * from './workspace-feature'; +export * from './workspace-user'; diff --git a/packages/backend/server/src/models/page.ts b/packages/backend/server/src/models/page.ts index 269b01ccae..60614d81fe 100644 --- a/packages/backend/server/src/models/page.ts +++ b/packages/backend/server/src/models/page.ts @@ -1,16 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { - type WorkspaceDoc as Page, - type WorkspaceDocUserPermission as PageUserPermission, -} from '@prisma/client'; +import { type WorkspaceDoc as Page } from '@prisma/client'; -import { WorkspaceRole } from '../core/permission'; import { BaseModel } from './base'; -import { PublicPageMode } from './common'; +import { PublicDocMode } from './common'; export type { Page }; export type UpdatePageInput = { - mode?: PublicPageMode; + mode?: PublicDocMode; public?: boolean; }; @@ -82,118 +77,4 @@ export class PageModel extends BaseModel { } // #endregion - - // #region page member and permission - - /** - * Grant the page member with the given permission. - */ - @Transactional() - async grantMember( - workspaceId: string, - docId: string, - userId: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ): Promise { - let data = await this.db.workspaceDocUserPermission.findUnique({ - where: { - workspaceId_docId_userId: { - workspaceId, - docId, - userId, - }, - }, - }); - - // If the user is already accepted and the new permission is owner, we need to revoke old owner - if (!data || data.type !== permission) { - if (data) { - // Update the permission - data = await this.db.workspaceDocUserPermission.update({ - where: { - workspaceId_docId_userId: { - workspaceId, - docId, - userId, - }, - }, - data: { type: permission }, - }); - } else { - // Create a new permission - data = await this.db.workspaceDocUserPermission.create({ - data: { - workspaceId, - docId, - userId, - type: permission, - }, - }); - } - - // If the new permission is owner, we need to revoke old owner - if (permission === WorkspaceRole.Owner) { - await this.db.workspaceDocUserPermission.updateMany({ - where: { - workspaceId, - docId, - type: WorkspaceRole.Owner, - userId: { not: userId }, - }, - data: { type: WorkspaceRole.Admin }, - }); - this.logger.log( - `Change owner of workspace ${workspaceId} doc ${docId} to user ${userId}` - ); - } - return data; - } - - // nothing to do - return data; - } - - /** - * Returns whether a given user is a member of a workspace and has the given or higher permission. - * Default to read permission. - */ - async isMember( - workspaceId: string, - docId: string, - userId: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ) { - const count = await this.db.workspaceDocUserPermission.count({ - where: { - workspaceId, - docId, - userId, - type: { - gte: permission, - }, - }, - }); - return count > 0; - } - - /** - * Delete a page member - * Except the owner, the owner can't be deleted. - */ - async deleteMember(workspaceId: string, docId: string, userId: string) { - const { count } = await this.db.workspaceDocUserPermission.deleteMany({ - where: { - workspaceId, - docId, - userId, - type: { - // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading - not: WorkspaceRole.Owner, - }, - }, - }); - return count; - } - - // #endregion } diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index 1aac574b49..ba8194ee02 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -10,6 +10,7 @@ import { WrongSignInMethod, } from '../base'; import { BaseModel } from './base'; +import { WorkspaceRole } from './common'; import type { Workspace } from './workspace'; const publicUserSelect = { @@ -215,12 +216,17 @@ export class UserModel extends BaseModel { } async delete(id: string) { - const ownedWorkspaceIds = await this.models.workspace.findOwnedIds(id); + const ownedWorkspaces = await this.models.workspaceUser.getUserActiveRoles( + id, + { + role: WorkspaceRole.Owner, + } + ); const user = await this.db.user.delete({ where: { id } }); this.event.emit('user.deleted', { ...user, - ownedWorkspaces: ownedWorkspaceIds, + ownedWorkspaces: ownedWorkspaces.map(r => r.workspaceId), }); return user; diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts new file mode 100644 index 0000000000..974e340b29 --- /dev/null +++ b/packages/backend/server/src/models/workspace-user.ts @@ -0,0 +1,385 @@ +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { WorkspaceMemberStatus } from '@prisma/client'; +import { groupBy } from 'lodash-es'; + +import { EventBus, PaginationInput } from '../base'; +import { BaseModel } from './base'; +import { WorkspaceRole } from './common'; + +export { WorkspaceMemberStatus }; + +declare global { + interface Events { + 'workspace.owner.changed': { + workspaceId: string; + from: string; + to: string; + }; + 'workspace.members.roleChanged': { + userId: string; + workspaceId: string; + role: WorkspaceRole; + }; + // below are business events, should be declare somewhere else + 'workspace.members.updated': { + workspaceId: string; + count: number; + }; + 'workspace.members.reviewRequested': { + inviteId: string; + }; + 'workspace.members.requestApproved': { + inviteId: string; + }; + 'workspace.members.requestDeclined': { + userId: string; + workspaceId: string; + }; + 'workspace.members.removed': { + userId: string; + workspaceId: string; + }; + 'workspace.members.leave': { + workspaceId: string; + user: { + id: string; + email: string; + }; + }; + } +} + +@Injectable() +export class WorkspaceUserModel extends BaseModel { + constructor(private readonly event: EventBus) { + super(); + } + + /** + * Set or update the [Owner] of a workspace. + * The old [Owner] will be changed to [Admin] if there is already an [Owner]. + */ + @Transactional() + async setOwner(workspaceId: string, userId: string) { + const oldOwner = await this.db.workspaceUserRole.findFirst({ + where: { + workspaceId, + type: WorkspaceRole.Owner, + }, + }); + + // If there is already an owner, we need to change the old owner to admin + if (oldOwner) { + await this.db.workspaceUserRole.update({ + where: { + id: oldOwner.id, + }, + data: { + type: WorkspaceRole.Admin, + }, + }); + } + + await this.db.workspaceUserRole.upsert({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + }, + update: { + type: WorkspaceRole.Owner, + }, + create: { + workspaceId, + userId, + type: WorkspaceRole.Owner, + status: WorkspaceMemberStatus.Accepted, + }, + }); + + if (oldOwner) { + this.event.emit('workspace.owner.changed', { + workspaceId, + from: oldOwner.userId, + to: userId, + }); + this.logger.log( + `Transfer workspace owner of [${workspaceId}] from [${oldOwner.userId}] to [${userId}]` + ); + } else { + this.logger.log(`Set workspace owner of [${workspaceId}] to [${userId}]`); + } + } + + /** + * Set or update the Role of a user in a workspace. + * + * NOTE: do not use this method to set the [Owner] of a workspace. Use {@link setOwner} instead. + */ + @Transactional() + async set( + workspaceId: string, + userId: string, + role: WorkspaceRole, + defaultStatus: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending + ) { + if (role === WorkspaceRole.Owner) { + throw new Error('Cannot grant Owner role of a workspace to a user.'); + } + + const oldRole = await this.get(workspaceId, userId); + + if (oldRole) { + if (oldRole.type === role) { + return oldRole; + } + + const newRole = await this.db.workspaceUserRole.update({ + where: { id: oldRole.id }, + data: { type: role }, + }); + + if (oldRole.status === WorkspaceMemberStatus.Accepted) { + this.event.emit('workspace.members.roleChanged', { + userId, + workspaceId, + role: newRole.type, + }); + } + + return newRole; + } else { + return await this.db.workspaceUserRole.create({ + data: { + workspaceId, + userId, + type: role, + status: defaultStatus, + }, + }); + } + } + + async setStatus( + workspaceId: string, + userId: string, + status: WorkspaceMemberStatus + ) { + return await this.db.workspaceUserRole.update({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + }, + data: { + status, + }, + }); + } + + async accept(id: string) { + await this.db.workspaceUserRole.update({ + where: { id }, + data: { status: WorkspaceMemberStatus.Accepted }, + }); + } + + async delete(workspaceId: string, userId: string) { + await this.db.workspaceUserRole.deleteMany({ + where: { + workspaceId, + userId, + }, + }); + } + + async get(workspaceId: string, userId: string) { + return await this.db.workspaceUserRole.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId, + }, + }, + }); + } + + async getById(id: string) { + return await this.db.workspaceUserRole.findUnique({ + where: { id }, + }); + } + + /** + * Get the **accepted** Role of a user in a workspace. + */ + async getActive(workspaceId: string, userId: string) { + return await this.db.workspaceUserRole.findUnique({ + where: { + workspaceId_userId: { workspaceId, userId }, + status: WorkspaceMemberStatus.Accepted, + }, + }); + } + + async getOwner(workspaceId: string) { + const role = await this.db.workspaceUserRole.findFirst({ + include: { + user: true, + }, + where: { + workspaceId, + type: WorkspaceRole.Owner, + }, + }); + + if (!role) { + throw new Error('Workspace owner not found'); + } + + return role.user; + } + + async getAdmins(workspaceId: string) { + const list = await this.db.workspaceUserRole.findMany({ + include: { + user: true, + }, + where: { + workspaceId, + type: WorkspaceRole.Admin, + status: WorkspaceMemberStatus.Accepted, + }, + }); + + return list.map(l => l.user); + } + + async count(workspaceId: string) { + return this.db.workspaceUserRole.count({ + where: { + workspaceId, + }, + }); + } + + async getUserActiveRoles( + userId: string, + filter: { role?: WorkspaceRole } = {} + ) { + return await this.db.workspaceUserRole.findMany({ + where: { + userId, + status: WorkspaceMemberStatus.Accepted, + type: filter.role, + }, + }); + } + + async paginate(workspaceId: string, pagination: PaginationInput) { + return await Promise.all([ + this.db.workspaceUserRole.findMany({ + include: { + user: true, + }, + where: { + workspaceId, + createdAt: pagination.after + ? { + gte: pagination.after, + } + : undefined, + }, + orderBy: { + createdAt: 'asc', + }, + take: pagination.first, + skip: pagination.offset + (pagination.after ? 1 : 0), + }), + this.count(workspaceId), + ]); + } + + async search( + workspaceId: string, + query: string, + pagination: PaginationInput + ) { + return await this.db.workspaceUserRole.findMany({ + include: { user: true }, + where: { + workspaceId, + status: WorkspaceMemberStatus.Accepted, + user: { + OR: [ + { + email: { + contains: query, + }, + }, + { + name: { + contains: query, + }, + }, + ], + }, + }, + orderBy: { createdAt: 'asc' }, + take: pagination.first, + skip: pagination.offset + (pagination.after ? 1 : 0), + }); + } + + @Transactional() + async refresh(workspaceId: string, memberLimit: number) { + const usedCount = await this.db.workspaceUserRole.count({ + where: { workspaceId, status: WorkspaceMemberStatus.Accepted }, + }); + + const availableCount = memberLimit - usedCount; + + if (availableCount <= 0) { + return; + } + + const members = await this.db.workspaceUserRole.findMany({ + select: { id: true, status: true }, + where: { + workspaceId, + status: { + in: [ + WorkspaceMemberStatus.NeedMoreSeat, + WorkspaceMemberStatus.NeedMoreSeatAndReview, + ], + }, + }, + orderBy: { createdAt: 'asc' }, + }); + + const needChange = members.slice(0, availableCount); + const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy( + needChange, + m => m.status + ); + + const toPendings = NeedMoreSeat ?? []; + if (toPendings.length > 0) { + await this.db.workspaceUserRole.updateMany({ + where: { id: { in: toPendings.map(m => m.id) } }, + data: { status: WorkspaceMemberStatus.Pending }, + }); + } + + const toUnderReviewUserIds = NeedMoreSeatAndReview ?? []; + if (toUnderReviewUserIds.length > 0) { + await this.db.workspaceUserRole.updateMany({ + where: { id: { in: toUnderReviewUserIds.map(m => m.id) } }, + data: { status: WorkspaceMemberStatus.UnderReview }, + }); + } + } +} diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts index 4499580243..24a8ab21dc 100644 --- a/packages/backend/server/src/models/workspace.ts +++ b/packages/backend/server/src/models/workspace.ts @@ -1,78 +1,25 @@ import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; -import { - type Workspace, - WorkspaceMemberStatus, - type WorkspaceUserPermission, -} from '@prisma/client'; -import { groupBy } from 'lodash-es'; +import { type Workspace } from '@prisma/client'; -import { EventBus } from '../base'; -import { WorkspaceRole } from '../core/permission'; +import { DocIsNotPublic, EventBus } from '../base'; import { BaseModel } from './base'; +import { DocRole, PublicDocMode } from './common'; declare global { interface Events { - 'workspace.members.reviewRequested': { inviteId: string }; - 'workspace.members.requestDeclined': { - userId: string; - workspaceId: string; - }; - 'workspace.members.requestApproved': { inviteId: string }; - 'workspace.members.roleChanged': { - userId: string; - workspaceId: string; - permission: number; - }; - 'workspace.members.ownershipTransferred': { - from: string; - to: string; - workspaceId: string; - }; - 'workspace.members.updated': { - workspaceId: string; - count: number; - }; - 'workspace.members.leave': { - user: { - id: string; - email: string; - }; - workspaceId: string; - }; - 'workspace.members.removed': { - workspaceId: string; - userId: string; - }; 'workspace.deleted': { id: string; }; - 'workspace.blob.delete': { - workspaceId: string; - key: string; - }; - 'workspace.blob.sync': { - workspaceId: string; - key: string; - }; } } -export { WorkspaceMemberStatus }; export type { Workspace }; export type UpdateWorkspaceInput = Pick< Partial, 'public' | 'enableAi' | 'enableUrlPreview' >; -export interface FindWorkspaceMembersOptions { - skip?: number; - /** - * Default to `8` - */ - take?: number; -} - @Injectable() export class WorkspaceModel extends BaseModel { constructor(private readonly event: EventBus) { @@ -80,25 +27,16 @@ export class WorkspaceModel extends BaseModel { } // #region workspace - /** * Create a new workspace for the user, default to private. */ + @Transactional() async create(userId: string) { const workspace = await this.db.workspace.create({ - data: { - public: false, - permissions: { - create: { - type: WorkspaceRole.Owner, - userId: userId, - accepted: true, - status: WorkspaceMemberStatus.Accepted, - }, - }, - }, + data: { public: false }, }); - this.logger.log(`Created workspace ${workspace.id} for user ${userId}`); + this.logger.log(`Workspace created with id ${workspace.id}`); + await this.models.workspaceUser.setOwner(workspace.id, userId); return workspace; } @@ -106,7 +44,7 @@ export class WorkspaceModel extends BaseModel { * Update the workspace with the given data. */ async update(workspaceId: string, data: UpdateWorkspaceInput) { - await this.db.workspace.update({ + const workspace = await this.db.workspace.update({ where: { id: workspaceId, }, @@ -115,6 +53,7 @@ export class WorkspaceModel extends BaseModel { this.logger.log( `Updated workspace ${workspaceId} with data ${JSON.stringify(data)}` ); + return workspace; } async get(workspaceId: string) { @@ -125,422 +64,104 @@ export class WorkspaceModel extends BaseModel { }); } + async findMany(ids: string[]) { + return await this.db.workspace.findMany({ + where: { + id: { in: ids }, + }, + }); + } + async delete(workspaceId: string) { - await this.db.workspace.deleteMany({ + const rawResult = await this.db.workspace.deleteMany({ where: { id: workspaceId, }, }); - this.logger.log(`Deleted workspace ${workspaceId}`); + + if (rawResult.count > 0) { + this.event.emit('workspace.deleted', { id: workspaceId }); + this.logger.log(`Workspace [${workspaceId}] deleted`); + } } - /** - * Find the workspace ids that the user is a member of owner. - */ - async findOwnedIds(userId: string) { - const rows = await this.db.workspaceUserPermission.findMany({ - where: { - userId, - type: WorkspaceRole.Owner, - OR: this.acceptedCondition, - }, - select: { - workspaceId: true, - }, - }); - return rows.map(row => row.workspaceId); + async allowUrlPreview(workspaceId: string) { + const workspace = await this.get(workspaceId); + return workspace?.enableUrlPreview ?? false; } - - /** - * Find the accessible workspaces for the user. - */ - async findAccessibleWorkspaces(userId: string) { - return await this.db.workspaceUserPermission.findMany({ - where: { - userId, - OR: this.acceptedCondition, - }, - include: { - workspace: true, - }, - }); - } - // #endregion - // #region workspace member and permission - - /** - * Grant the workspace member with the given permission and status. - */ - @Transactional() - async grantMember( - workspaceId: string, - userId: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator, - status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending - ): Promise { - const data = await this.db.workspaceUserPermission.findUnique({ + // #region doc + async getDoc(workspaceId: string, docId: string) { + return await this.db.workspaceDoc.findUnique({ where: { - workspaceId_userId: { - workspaceId, - userId, - }, - }, - }); - - if (!data) { - // Create a new permission - const created = await this.db.workspaceUserPermission.create({ - data: { - workspaceId, - userId, - type: permission, - status: - permission === WorkspaceRole.Owner - ? WorkspaceMemberStatus.Accepted - : status, - }, - }); - this.logger.log( - `Granted workspace ${workspaceId} member ${userId} with permission ${WorkspaceRole[permission]}` - ); - await this.notifyMembersUpdated(workspaceId); - return created; - } - - // If the user is already accepted and the new permission is owner, we need to revoke old owner - if (data.status === WorkspaceMemberStatus.Accepted || data.accepted) { - const updated = await this.db.workspaceUserPermission.update({ - where: { - workspaceId_userId: { workspaceId, userId }, - }, - data: { type: permission }, - }); - // If the new permission is owner, we need to revoke old owner - if (permission === WorkspaceRole.Owner) { - await this.db.workspaceUserPermission.updateMany({ - where: { - workspaceId, - type: WorkspaceRole.Owner, - userId: { not: userId }, - }, - data: { type: WorkspaceRole.Admin }, - }); - this.logger.log( - `Change owner of workspace ${workspaceId} to ${userId}` - ); - } - return updated; - } - - // If the user is not accepted, we can update the status directly - const allowedStatus = this.getAllowedStatusSource(data.status); - if (allowedStatus.includes(status)) { - const updated = await this.db.workspaceUserPermission.update({ - where: { workspaceId_userId: { workspaceId, userId } }, - data: { - status, - // TODO(fengmk2): should we update the permission here? - // type: permission, - }, - }); - return updated; - } - - // nothing to do - return data; - } - - /** - * Get the workspace member invitation. - */ - async getMemberInvitation(invitationId: string) { - return await this.db.workspaceUserPermission.findUnique({ - where: { - id: invitationId, + workspaceId_docId: { workspaceId, docId }, }, }); } - /** - * Accept the workspace member invitation. - * @param status: the status to update to, default to `Accepted`. Can be `Accepted` or `UnderReview`. - */ - async acceptMemberInvitation( - invitationId: string, + async isPublicPage(workspaceId: string, docId: string) { + const doc = await this.getDoc(workspaceId, docId); + if (doc?.public) { + return true; + } + + const workspace = await this.get(workspaceId); + return workspace?.public ?? false; + } + + async publishDoc( workspaceId: string, - status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted + docId: string, + mode: PublicDocMode = PublicDocMode.Page ) { - const { count } = await this.db.workspaceUserPermission.updateMany({ - where: { - id: invitationId, - workspaceId: workspaceId, - // TODO(fengmk2): should we check the status here? - AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }], - }, - data: { accepted: true, status }, + return await this.db.workspaceDoc.upsert({ + where: { workspaceId_docId: { workspaceId, docId } }, + update: { public: true, mode }, + create: { workspaceId, docId, public: true, mode }, }); + } + + @Transactional() + async revokePublicDoc(workspaceId: string, docId: string) { + const doc = await this.getDoc(workspaceId, docId); + + if (!doc?.public) { + throw new DocIsNotPublic(); + } + + return await this.db.workspaceDoc.update({ + where: { workspaceId_docId: { workspaceId, docId } }, + data: { public: false }, + }); + } + + async hasPublicDoc(workspaceId: string) { + const count = await this.db.workspaceDoc.count({ + where: { + workspaceId, + public: true, + }, + }); + return count > 0; } - /** - * Get a workspace member in accepted status by workspace id and user id. - */ - async getMember(workspaceId: string, userId: string) { - return await this.db.workspaceUserPermission.findUnique({ - where: { - workspaceId_userId: { - workspaceId, - userId, - }, - OR: this.acceptedCondition, - }, - }); - } - - /** - * Get a workspace member in any status by workspace id and user id. - */ - async getMemberInAnyStatus(workspaceId: string, userId: string) { - return await this.db.workspaceUserPermission.findUnique({ - where: { - workspaceId_userId: { - workspaceId, - userId, - }, - }, - }); - } - - /** - * Returns whether a given user is a member of a workspace and has the given or higher permission. - * Default to read permission. - */ - async isMember( - workspaceId: string, - userId: string, - permission: WorkspaceRole = WorkspaceRole.Collaborator - ) { - const count = await this.db.workspaceUserPermission.count({ + async getPublicDocs(workspaceId: string) { + return await this.db.workspaceDoc.findMany({ where: { workspaceId, - userId, - OR: this.acceptedCondition, - type: { - gte: permission, - }, - }, - }); - return count > 0; - } - - /** - * Get the workspace owner. - */ - async getOwner(workspaceId: string) { - return await this.db.workspaceUserPermission.findFirst({ - where: { - workspaceId, - type: WorkspaceRole.Owner, - OR: this.acceptedCondition, - }, - include: { - user: true, + public: true, }, }); } - /** - * Find the workspace admins. - */ - async findAdmins(workspaceId: string) { - return await this.db.workspaceUserPermission.findMany({ - where: { - workspaceId, - type: WorkspaceRole.Admin, - OR: this.acceptedCondition, - }, - include: { - user: true, - }, + async setDocDefaultRole(workspaceId: string, docId: string, role: DocRole) { + await this.db.workspaceDoc.upsert({ + where: { workspaceId_docId: { workspaceId, docId } }, + update: { defaultRole: role }, + create: { workspaceId, docId, defaultRole: role }, }); } - - /** - * Find the workspace members. - */ - async findMembers( - workspaceId: string, - options: FindWorkspaceMembersOptions = {} - ) { - return await this.db.workspaceUserPermission.findMany({ - where: { - workspaceId, - }, - skip: options.skip, - take: options.take || 8, - orderBy: [{ createdAt: 'asc' }, { type: 'desc' }], - include: { - user: true, - }, - }); - } - - /** - * Delete a workspace member by workspace id and user id. - * Except the owner, the owner can't be deleted. - */ - async deleteMember(workspaceId: string, userId: string) { - const member = await this.getMemberInAnyStatus(workspaceId, userId); - - // We shouldn't revoke owner permission - // should auto deleted by workspace/user delete cascading - if (!member || member.type === WorkspaceRole.Owner) { - return false; - } - - await this.db.workspaceUserPermission.deleteMany({ - where: { - workspaceId, - userId, - }, - }); - this.logger.log( - `Deleted workspace member ${userId} from workspace ${workspaceId}` - ); - - await this.notifyMembersUpdated(workspaceId); - - if ( - member.status === WorkspaceMemberStatus.UnderReview || - member.status === WorkspaceMemberStatus.NeedMoreSeatAndReview - ) { - this.event.emit('workspace.members.requestDeclined', { - workspaceId, - userId, - }); - } - - return true; - } - - private async notifyMembersUpdated(workspaceId: string) { - const count = await this.getMemberTotalCount(workspaceId); - this.event.emit('workspace.members.updated', { - workspaceId, - count, - }); - } - - /** - * Get the workspace member total count, including pending and accepted. - */ - async getMemberTotalCount(workspaceId: string) { - return await this.db.workspaceUserPermission.count({ - where: { - workspaceId, - }, - }); - } - - /** - * Get the workspace member used count, only count the accepted member - */ - async getMemberUsedCount(workspaceId: string) { - return await this.db.workspaceUserPermission.count({ - where: { - workspaceId, - OR: this.acceptedCondition, - }, - }); - } - - /** - * Refresh the workspace member seat status. - */ - @Transactional() - async refreshMemberSeatStatus(workspaceId: string, memberLimit: number) { - const usedCount = await this.getMemberUsedCount(workspaceId); - const availableCount = memberLimit - usedCount; - if (availableCount <= 0) { - return; - } - - const members = await this.db.workspaceUserPermission.findMany({ - select: { id: true, status: true }, - where: { - workspaceId, - status: { - in: [ - WorkspaceMemberStatus.NeedMoreSeat, - WorkspaceMemberStatus.NeedMoreSeatAndReview, - ], - }, - }, - // find the oldest members first - orderBy: { createdAt: 'asc' }, - }); - - const needChange = members.slice(0, availableCount); - const groups = groupBy(needChange, m => m.status); - - const toPendings = groups.NeedMoreSeat; - if (toPendings) { - // NeedMoreSeat => Pending - await this.db.workspaceUserPermission.updateMany({ - where: { id: { in: toPendings.map(m => m.id) } }, - data: { status: WorkspaceMemberStatus.Pending }, - }); - } - - const toUnderReviews = groups.NeedMoreSeatAndReview; - if (toUnderReviews) { - // NeedMoreSeatAndReview => UnderReview - await this.db.workspaceUserPermission.updateMany({ - where: { id: { in: toUnderReviews.map(m => m.id) } }, - data: { status: WorkspaceMemberStatus.UnderReview }, - }); - } - } - - /** - * Accepted condition for workspace member. - */ - private get acceptedCondition() { - return [ - { - // keep compatibility with old data - accepted: true, - }, - { - status: WorkspaceMemberStatus.Accepted, - }, - ]; - } - - /** - * NeedMoreSeat => Pending - * - * NeedMoreSeatAndReview => UnderReview - * - * Pending | UnderReview => Accepted - */ - private getAllowedStatusSource( - to: WorkspaceMemberStatus - ): WorkspaceMemberStatus[] { - switch (to) { - case WorkspaceMemberStatus.NeedMoreSeat: - return [WorkspaceMemberStatus.Pending]; - case WorkspaceMemberStatus.NeedMoreSeatAndReview: - return [WorkspaceMemberStatus.UnderReview]; - case WorkspaceMemberStatus.Pending: - case WorkspaceMemberStatus.UnderReview: // need admin to review in team workspace - return [WorkspaceMemberStatus.Accepted]; - default: - return []; - } - } - // #endregion } diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index 879f31a103..de5cc7e495 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -31,7 +31,7 @@ import { } from '../../base'; import { CurrentUser } from '../../core/auth'; import { Admin } from '../../core/common'; -import { PermissionService } from '../../core/permission'; +import { AccessController } from '../../core/permission'; import { UserType } from '../../core/user'; import { PromptService } from './prompt'; import { ChatSessionService } from './session'; @@ -299,7 +299,7 @@ export class CopilotType { @Resolver(() => CopilotType) export class CopilotResolver { constructor( - private readonly permissions: PermissionService, + private readonly ac: AccessController, private readonly mutex: RequestMutex, private readonly chatSession: ChatSessionService, private readonly storage: CopilotStorage @@ -339,7 +339,11 @@ export class CopilotResolver { @Args('options', { nullable: true }) options?: QueryChatSessionsInput ) { if (!copilot.workspaceId) return []; - await this.permissions.checkCloudWorkspace(copilot.workspaceId, user.id); + await this.ac + .user(user.id) + .workspace(copilot.workspaceId) + .allowLocal() + .assert('Workspace.Copilot'); return await this.chatSession.listSessions( user.id, copilot.workspaceId, @@ -360,14 +364,17 @@ export class CopilotResolver { if (!workspaceId) { return []; } else if (docId) { - await this.permissions.checkCloudPagePermission( - workspaceId, - docId, - 'Doc.Read', - user.id - ); + await this.ac + .user(user.id) + .doc({ workspaceId, docId }) + .allowLocal() + .assert('Doc.Read'); } else { - await this.permissions.checkCloudWorkspace(workspaceId, user.id); + await this.ac + .user(user.id) + .workspace(workspaceId) + .allowLocal() + .assert('Workspace.Copilot'); } const histories = await this.chatSession.listHistories( @@ -393,12 +400,7 @@ export class CopilotResolver { @Args({ name: 'options', type: () => CreateChatSessionInput }) options: CreateChatSessionInput ) { - await this.permissions.checkCloudPagePermission( - options.workspaceId, - options.docId, - 'Doc.Update', - user.id - ); + await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update'); const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { @@ -432,12 +434,11 @@ export class CopilotResolver { throw new CopilotSessionNotFound(); } const { workspaceId, docId } = session.config; - await this.permissions.checkCloudPagePermission( - workspaceId, - docId, - 'Doc.Update', - user.id - ); + await this.ac + .user(user.id) + .doc(workspaceId, docId) + .allowLocal() + .assert('Doc.Update'); const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { @@ -460,12 +461,7 @@ export class CopilotResolver { @Args({ name: 'options', type: () => ForkChatSessionInput }) options: ForkChatSessionInput ) { - await this.permissions.checkCloudPagePermission( - options.workspaceId, - options.docId, - 'Doc.Update', - user.id - ); + await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update'); const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`; await using lock = await this.mutex.acquire(lockFlag); if (!lock) { @@ -494,12 +490,7 @@ export class CopilotResolver { @Args({ name: 'options', type: () => DeleteSessionInput }) options: DeleteSessionInput ) { - await this.permissions.checkCloudPagePermission( - options.workspaceId, - options.docId, - 'Doc.Update', - user.id - ); + await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update'); if (!options.sessionIds.length) { return new NotFoundException('Session not found'); } @@ -567,7 +558,7 @@ export class CopilotResolver { @Throttle() @Resolver(() => UserType) export class UserCopilotResolver { - constructor(private readonly permissions: PermissionService) {} + constructor(private readonly ac: AccessController) {} @ResolveField(() => CopilotType) async copilot( @@ -575,7 +566,11 @@ export class UserCopilotResolver { @Args('workspaceId', { nullable: true }) workspaceId?: string ) { if (workspaceId) { - await this.permissions.checkCloudWorkspace(workspaceId, user.id); + await this.ac + .user(user.id) + .workspace(workspaceId) + .allowLocal() + .assert('Workspace.Copilot'); } return { workspaceId }; } diff --git a/packages/backend/server/src/plugins/license/resolver.ts b/packages/backend/server/src/plugins/license/resolver.ts index 450430ef69..0cb78a4e91 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 { PermissionService, WorkspaceRole } from '../../core/permission'; +import { AccessController } from '../../core/permission'; import { WorkspaceType } from '../../core/workspaces'; import { SubscriptionRecurring } from '../payment/types'; import { LicenseService } from './service'; @@ -39,7 +39,7 @@ export class LicenseResolver { constructor( private readonly config: Config, private readonly service: LicenseService, - private readonly permission: PermissionService + private readonly ac: AccessController ) {} @ResolveField(() => License, { @@ -58,12 +58,10 @@ export class LicenseResolver { return null; } - await this.permission.checkWorkspaceIs( - workspace.id, - user.id, - WorkspaceRole.Owner - ); - + await this.ac + .user(user.id) + .workspace(workspace.id) + .assert('Workspace.Payment.Manage'); return this.service.getLicense(workspace.id); } @@ -77,11 +75,10 @@ export class LicenseResolver { throw new ActionForbidden(); } - await this.permission.checkWorkspaceIs( - workspaceId, - user.id, - WorkspaceRole.Owner - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Payment.Manage'); return this.service.activateTeamLicense(workspaceId, license); } @@ -95,11 +92,10 @@ export class LicenseResolver { throw new ActionForbidden(); } - await this.permission.checkWorkspaceIs( - workspaceId, - user.id, - WorkspaceRole.Owner - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Payment.Manage'); return this.service.deactivateTeamLicense(workspaceId); } @@ -113,11 +109,10 @@ export class LicenseResolver { throw new ActionForbidden(); } - await this.permission.checkWorkspaceIs( - workspaceId, - user.id, - WorkspaceRole.Owner - ); + await this.ac + .user(user.id) + .workspace(workspaceId) + .assert('Workspace.Payment.Manage'); const { url } = await this.service.createCustomerPortal(workspaceId); diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index 0675ca2709..0a17cd98c5 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -11,7 +11,6 @@ import { UserFriendlyError, WorkspaceLicenseAlreadyExists, } from '../../base'; -import { PermissionService } from '../../core/permission'; import { Models } from '../../models'; import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types'; @@ -30,7 +29,6 @@ export class LicenseService implements OnModuleInit { private readonly config: Config, private readonly db: PrismaClient, private readonly event: EventBus, - private readonly permission: PermissionService, private readonly models: Models ) {} @@ -63,7 +61,7 @@ export class LicenseService implements OnModuleInit { memberLimit: quantity, } ); - await this.permission.refreshSeatStatus(workspaceId, quantity); + await this.models.workspaceUser.refresh(workspaceId, quantity); break; default: break; diff --git a/packages/backend/server/src/plugins/payment/manager/workspace.ts b/packages/backend/server/src/plugins/payment/manager/workspace.ts index ed031211ef..3e5d28c459 100644 --- a/packages/backend/server/src/plugins/payment/manager/workspace.ts +++ b/packages/backend/server/src/plugins/payment/manager/workspace.ts @@ -11,6 +11,7 @@ import { SubscriptionPlanNotFound, URLHelper, } from '../../../base'; +import { Models } from '../../../models'; import { KnownStripeInvoice, KnownStripePrice, @@ -48,7 +49,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager { stripe: Stripe, db: PrismaClient, private readonly url: URLHelper, - private readonly event: EventBus + private readonly event: EventBus, + private readonly models: Models ) { super(stripe, db); } @@ -101,11 +103,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager { return { allow_promotion_codes: true }; })(); - const count = await this.db.workspaceUserPermission.count({ - where: { - workspaceId: args.workspaceId, - }, - }); + const count = await this.models.workspaceUser.count(args.workspaceId); return this.stripe.checkout.sessions.create({ customer: customer.stripeCustomerId, diff --git a/packages/backend/server/src/plugins/payment/quota.ts b/packages/backend/server/src/plugins/payment/quota.ts index 82aca644d1..7eb35f3fa9 100644 --- a/packages/backend/server/src/plugins/payment/quota.ts +++ b/packages/backend/server/src/plugins/payment/quota.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '../../base'; -import { PermissionService } from '../../core/permission'; import { WorkspaceService } from '../../core/workspaces/resolvers'; import { Models } from '../../models'; import { SubscriptionPlan } from './types'; @@ -9,7 +8,6 @@ import { SubscriptionPlan } from './types'; @Injectable() export class QuotaOverride { constructor( - private readonly permission: PermissionService, private readonly workspace: WorkspaceService, private readonly models: Models ) {} @@ -32,7 +30,7 @@ export class QuotaOverride { memberLimit: quantity, } ); - await this.permission.refreshSeatStatus(workspaceId, quantity); + await this.models.workspaceUser.refresh(workspaceId, quantity); if (!isTeam) { // this event will triggered when subscription is activated or changed // we only send emails when the team workspace is activated diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 59f78e70e0..7e27afd2d5 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 { PermissionService, WorkspaceRole } from '../../core/permission'; +import { AccessController } from '../../core/permission'; import { UserType } from '../../core/user'; import { WorkspaceType } from '../../core/workspaces'; import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager'; @@ -520,7 +520,7 @@ export class WorkspaceSubscriptionResolver { constructor( private readonly service: WorkspaceSubscriptionManager, private readonly db: PrismaClient, - private readonly permission: PermissionService + private readonly ac: AccessController ) {} @ResolveField(() => SubscriptionType, { @@ -542,11 +542,11 @@ export class WorkspaceSubscriptionResolver { @CurrentUser() me: CurrentUser, @Parent() workspace: WorkspaceType ) { - await this.permission.checkWorkspace( - workspace.id, - me.id, - WorkspaceRole.Owner - ); + await this.ac + .user(me.id) + .workspace(workspace.id) + .assert('Workspace.Payment.Manage'); + return this.db.invoice.count({ where: { targetId: workspace.id, @@ -562,11 +562,10 @@ export class WorkspaceSubscriptionResolver { take: number, @Args('skip', { type: () => Int, nullable: true }) skip?: number ) { - await this.permission.checkWorkspace( - workspace.id, - me.id, - WorkspaceRole.Owner - ); + await this.ac + .user(me.id) + .workspace(workspace.id) + .assert('Workspace.Payment.Manage'); return this.db.invoice.findMany({ where: { diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 1d4342faa9..7179ef7a38 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -339,6 +339,7 @@ enum ErrorNames { CANNOT_DELETE_OWN_ACCOUNT CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS + CAN_NOT_REVOKE_YOURSELF CAPTCHA_VERIFICATION_FAILED COPILOT_ACTION_TAKEN COPILOT_CONTEXT_FILE_NOT_SUPPORTED @@ -399,6 +400,7 @@ enum ErrorNames { NO_COPILOT_PROVIDER_AVAILABLE OAUTH_ACCOUNT_ALREADY_CONNECTED OAUTH_STATE_EXPIRED + OWNER_CAN_NOT_LEAVE_WORKSPACE PASSWORD_REQUIRED QUERY_TOO_LONG RUNTIME_CONFIG_NOT_FOUND @@ -677,7 +679,7 @@ type Mutation { """add a doc to context""" addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! - approveMember(userId: String!, workspaceId: String!): String! + approveMember(userId: String!, workspaceId: String!): Boolean! cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! changeEmail(email: String!, token: String!): UserType! changePassword(newPassword: String!, token: String!, userId: String): Boolean! @@ -725,7 +727,7 @@ type Mutation { forkCopilotSession(options: ForkChatSessionInput!): String! generateLicenseKey(sessionId: String!): String! grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean! - grantMember(permission: Permission!, userId: String!, workspaceId: String!): String! + grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean! invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean! @@ -854,13 +856,10 @@ type Query { getInviteInfo(inviteId: String!): InvitationType! """Get is admin of workspace""" - isAdmin(workspaceId: String!): Boolean! + isAdmin(workspaceId: String!): Boolean! @deprecated(reason: "use WorkspaceType[role] instead") """Get is owner of workspace""" - isOwner(workspaceId: String!): Boolean! - - """List blobs of workspace""" - listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead") + isOwner(workspaceId: String!): Boolean! @deprecated(reason: "use WorkspaceType[role] instead") """List all copilot prompts""" listCopilotPrompts: [CopilotPromptType!]! @@ -892,7 +891,7 @@ type Query { workspace(id: String!): WorkspaceType! """Get workspace role permissions""" - workspaceRolePermissions(id: String!): WorkspaceRolePermissions! + workspaceRolePermissions(id: String!): WorkspaceRolePermissions! @deprecated(reason: "use WorkspaceType[permissions] instead") """Get all accessible workspaces for current user""" workspaces: [WorkspaceType!]! @@ -1266,13 +1265,20 @@ type WorkspacePermissionNotFoundDataType { } type WorkspacePermissions { + Workspace_Adminitrators_Manage: Boolean! + Workspace_Blobs_List: Boolean! + Workspace_Blobs_Read: Boolean! + Workspace_Blobs_Write: Boolean! + Workspace_Copilot: Boolean! Workspace_CreateDoc: Boolean! Workspace_Delete: Boolean! Workspace_Organize_Read: Boolean! + Workspace_Payment_Manage: Boolean! Workspace_Properties_Create: Boolean! Workspace_Properties_Delete: Boolean! Workspace_Properties_Read: Boolean! Workspace_Properties_Update: Boolean! + Workspace_Read: Boolean! Workspace_Settings_Read: Boolean! Workspace_Settings_Update: Boolean! Workspace_Sync: Boolean! @@ -1354,6 +1360,9 @@ type WorkspaceType { """Cloud page metadata of workspace""" pageMeta(pageId: String!): WorkspacePageMeta! + """map of action permissions""" + permissions: WorkspacePermissions! + """is Public workspace""" public: Boolean! diff --git a/tests/kit/src/utils/cloud.ts b/tests/kit/src/utils/cloud.ts index e94270cfb6..ecd4cfec58 100644 --- a/tests/kit/src/utils/cloud.ts +++ b/tests/kit/src/utils/cloud.ts @@ -92,7 +92,7 @@ export async function addUserToWorkspace( if (workspace == null) { throw new Error(`workspace ${workspaceId} not found`); } - await client.workspaceUserPermission.create({ + await client.workspaceUserRole.create({ data: { workspaceId: workspace.id, userId,