From 1e7774929cc27b210cf61e5345a864441795d699 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 19 May 2025 03:28:22 +0000 Subject: [PATCH] feat(server): filter docs by access role (#12311) close CLOUD-208 ## Summary by CodeRabbit - **New Features** - Enhanced document access control with batch permission checks, enabling efficient filtering of documents based on user roles and permissions. - Added detailed document-level role and permission management for workspace users. - **Bug Fixes** - Improved accuracy in filtering search results to only display documents users have permission to read. - **Tests** - Added comprehensive tests for document-level permission filtering and search result accuracy. - Introduced new mock utilities to support permission-related test scenarios. - **Refactor** - Simplified and optimized permission logic for determining user roles and document access. - **Documentation** - Updated type definitions for improved clarity in permission handling. --- .../indexer/__snapshots__/search.spec.ts.md | 104 +++++++++++ .../indexer/__snapshots__/search.spec.ts.snap | Bin 440 -> 1107 bytes .../src/__tests__/e2e/indexer/search.spec.ts | 170 ++++++++++++++++++ .../src/__tests__/mocks/doc-user.mock.ts | 16 ++ .../server/src/__tests__/mocks/index.ts | 2 + .../__tests__/__snapshots__/docs.spec.ts.md | 62 +++++++ .../__tests__/__snapshots__/docs.spec.ts.snap | Bin 0 -> 358 bytes .../src/core/permission/__tests__/doc.spec.ts | 2 +- .../core/permission/__tests__/docs.spec.ts | 144 +++++++++++++++ .../server/src/core/permission/builder.ts | 23 +++ .../backend/server/src/core/permission/doc.ts | 59 +----- .../server/src/core/permission/types.ts | 24 ++- .../server/src/core/permission/workspace.ts | 102 ++++++++++- .../backend/server/src/models/doc-user.ts | 12 ++ packages/backend/server/src/models/doc.ts | 42 ++++- .../server/src/plugins/indexer/resolver.ts | 28 +-- 16 files changed, 703 insertions(+), 87 deletions(-) create mode 100644 packages/backend/server/src/__tests__/mocks/doc-user.mock.ts create mode 100644 packages/backend/server/src/core/permission/__tests__/__snapshots__/docs.spec.ts.md create mode 100644 packages/backend/server/src/core/permission/__tests__/__snapshots__/docs.spec.ts.snap create mode 100644 packages/backend/server/src/core/permission/__tests__/docs.spec.ts diff --git a/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search.spec.ts.md b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search.spec.ts.md index bad36d4a8..e975fdabd 100644 --- a/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search.spec.ts.md +++ b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search.spec.ts.md @@ -34,3 +34,107 @@ Generated by [AVA](https://avajs.dev). highlights: null, }, ] + +## should filter no read permission docs on team workspace + +> Snapshot 1 + + [ + { + fields: { + blockId: [ + 'block-0', + ], + docId: [ + 'doc-0', + ], + }, + highlights: null, + }, + { + fields: { + blockId: [ + 'block-2', + ], + docId: [ + 'doc-2', + ], + ref: [ + '{"foo": "bar1"}', + '{"foo": "bar3"}', + ], + refDocId: [ + 'doc-0', + 'doc-2', + ], + }, + highlights: null, + }, + { + fields: { + blockId: [ + 'block-1', + ], + docId: [ + 'doc-1', + ], + ref: [ + '{"foo": "bar1"}', + ], + refDocId: [ + 'doc-0', + ], + }, + highlights: null, + }, + ] + +> Snapshot 2 + + [ + { + fields: { + blockId: [ + 'block-0', + ], + docId: [ + 'doc-0', + ], + }, + highlights: null, + }, + { + fields: { + blockId: [ + 'block-1', + ], + docId: [ + 'doc-1', + ], + ref: [ + '{"foo": "bar1"}', + ], + refDocId: [ + 'doc-0', + ], + }, + highlights: null, + }, + ] + +## should return empty results when search not match any docs + +> Snapshot 1 + + { + workspace: { + search: { + nodes: [], + pagination: { + count: 0, + hasMore: false, + nextCursor: null, + }, + }, + }, + } diff --git a/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search.spec.ts.snap b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search.spec.ts.snap index a1ceaa40cab388cd072b7618c95e777d47a740e9..ec5c80acce1bdd18574de72bd4210ca3a7864bc6 100644 GIT binary patch literal 1107 zcmV-Z1g!f(RzVgMg3g9UR%~jit`Sh)A5JymU0wUcp7AO>+8!A}-$Mg=hh42TOCB7TCRtGM+~AmGx4@e>JJ)zv-K zuj``;0T=eBs@^;2-dp|NIq%kHd%5FVN5fOE%gBv7^4QMM@p~QH2&EZVjAG$8OPP|9J1_7r@DU^@B(^&$}6L3L= z)(hV0`%F!6(>&9fZR|aBmFA z3nOgbx^r%foDAQSP^da{9;zRBptZfJ*4l0-l7Zp*Mj%bw=*nQ#4MW%W4BNLt!}p9x znybc|9~=$4rX{ze_*^Q*+Ehl0+5&(@00#gZs*)p5MEJx|j?brZoFqCQ-?C(JzXtFl zfZwZ?xr+dUfcw;4TpNQf5pbA*H5IzOV8freF%f@qWArQ6>jAqNaQENxc#u_jfH=$m z$$%3Kc$oobRZ4w~@+kwpV8A5?{K0@GS13~j|K@JK^*;6F9EdgFAbK=25pUVlKeS{r zEMlF>VKs<8;5mMCnHQNAOpU#v0dH&NhQ>b9fX}oFGy7Hp zE@>5Jc2xsNl$n`M{1yS+CV+hka6$z>DF9ml&nUo2HSLQ6cvS&4RM0yDcuxT}RnV6L zxTt_yD(Ghc{HlPaRl6Cp#Qy5WZd+`arOX0D&Y1Oo=$J@ro-e7nVD8@>FoOo|-2V+) zwK*Rr;1mI`RW@kiT>{=G;41=tBH-FTZPa$M0z2EsN}P7Ni1k7at3h-wud};Y)Y-8s z4EVF6t79`9*u^VMaE=3ec!deZhstB69i2|>2nV{n+yL2W4!pw4%&B!m||?FpTq3CQhwidZN47LoNXLMh(OZMD$xGknnt4hRN_u zM)(t5bH(+{$n`ytUb?fj{RA8&K&JX1sX>g@z!f(Umf!QDD2!sFt literal 440 zcmV;p0Z0BpRzVlD|sBP#DF}x!1O_TGLVo!NJ;HXcZKk6kOE7O>j)}r-=rm zNvf9O&^PcE>LNJ#27(~?6zb#~I0+(6ZEe$t;9c&=xxe%M;kzBH=6d>3(7rZ9Cv?q= zxxn@su2Bk1&DZVHxf9x@(}w9co#0qI32ZM6+8x8tLhV4VNo3GHR46L|Rsn1PC^B7O zYSZ_%CfScvRRy!40H?^^o^gsi>)woVbO77{xbNpk@6`@XJ){_f`FuV?Uje)U_>80J zQ8X77oFNwo%u<4>0@Idbx<&x1C{py*tnJio*QwiK5J@sOWZ(IhU7>6cXc4$#tWh~O z(=CBJ0*`St^UF*xLrqEFv?5UvKceEB)|RJl8$+h5NHFaun-t2H0NMiRh|#@HjOuRfPd0u(FRIX`$wS4*gP9_2? iT_7n<%JB51%9uHlmm>@RuAD^m5`O`Z=+_r@0{{R&Zp%Ob diff --git a/packages/backend/server/src/__tests__/e2e/indexer/search.spec.ts b/packages/backend/server/src/__tests__/e2e/indexer/search.spec.ts index 3a53a8c55..ab136518a 100644 --- a/packages/backend/server/src/__tests__/e2e/indexer/search.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/indexer/search.spec.ts @@ -5,6 +5,7 @@ import { SearchTable, } from '@affine/graphql'; +import { DocRole } from '../../../models'; import { IndexerService } from '../../../plugins/indexer/service'; import { Mockers } from '../../mocks'; import { app, e2e } from '../test'; @@ -106,3 +107,172 @@ e2e('should search with query', async t => { t.is(result.workspace.search.nodes.length, 2); t.snapshot(result.workspace.search.nodes); }); + +e2e('should filter no read permission docs on team workspace', async t => { + const owner = await app.signup(); + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + await app.create(Mockers.TeamWorkspace, { + id: workspace.id, + }); + + const indexerService = app.get(IndexerService); + await indexerService.write( + SearchTable.block, + [ + { + docId: 'doc-0', + workspaceId: workspace.id, + content: 'test1', + flavour: 'markdown', + blockId: 'block-0', + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2025-04-22T00:00:00.000Z'), + updatedAt: new Date('2025-04-22T00:00:00.000Z'), + }, + { + docId: 'doc-1', + workspaceId: workspace.id, + content: 'test2', + flavour: 'markdown', + blockId: 'block-1', + refDocId: ['doc-0'], + ref: ['{"foo": "bar1"}'], + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2021-04-22T00:00:00.000Z'), + updatedAt: new Date('2021-04-22T00:00:00.000Z'), + }, + { + docId: 'doc-2', + workspaceId: workspace.id, + content: 'test3', + flavour: 'markdown', + blockId: 'block-2', + refDocId: ['doc-0', 'doc-2'], + ref: ['{"foo": "bar1"}', '{"foo": "bar3"}'], + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2025-03-22T00:00:00.000Z'), + updatedAt: new Date('2025-03-22T00:00:00.000Z'), + }, + ], + { + refresh: true, + } + ); + // set all docs to no access + await app.create(Mockers.DocMeta, { + workspaceId: workspace.id, + docId: 'doc-0', + defaultRole: DocRole.None, + }); + await app.create(Mockers.DocMeta, { + workspaceId: workspace.id, + docId: 'doc-1', + defaultRole: DocRole.None, + }); + await app.create(Mockers.DocMeta, { + workspaceId: workspace.id, + docId: 'doc-2', + defaultRole: DocRole.None, + }); + + // owner can read all docs + const result = await app.gql({ + query: indexerSearchQuery, + variables: { + id: workspace.id, + input: { + table: SearchTable.block, + query: { + type: SearchQueryType.match, + field: 'workspaceId', + match: workspace.id, + }, + options: { + fields: ['docId', 'blockId', 'refDocId', 'ref'], + pagination: { + limit: 100, + }, + }, + }, + }, + }); + + t.snapshot(result.workspace.search.nodes); + + // other user can only read docs that they have read permission + const other = await app.signup(); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: other.id, + }); + await app.create(Mockers.DocUser, { + workspaceId: workspace.id, + docId: 'doc-0', + userId: other.id, + type: DocRole.Reader, + }); + await app.create(Mockers.DocUser, { + workspaceId: workspace.id, + docId: 'doc-1', + userId: other.id, + type: DocRole.Manager, + }); + + const otherResult = await app.gql({ + query: indexerSearchQuery, + variables: { + id: workspace.id, + input: { + table: SearchTable.block, + query: { + type: SearchQueryType.match, + field: 'workspaceId', + match: workspace.id, + }, + options: { + fields: ['docId', 'blockId', 'refDocId', 'ref'], + pagination: { + limit: 100, + }, + }, + }, + }, + }); + + t.snapshot(otherResult.workspace.search.nodes); +}); + +e2e('should return empty results when search not match any docs', async t => { + const owner = await app.signup(); + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + + const result = await app.gql({ + query: indexerSearchQuery, + variables: { + id: workspace.id, + input: { + table: SearchTable.block, + query: { + type: SearchQueryType.match, + field: 'workspaceId', + match: workspace.id, + }, + options: { + fields: ['docId', 'blockId', 'refDocId', 'ref'], + pagination: { + limit: 100, + }, + }, + }, + }, + }); + + t.snapshot(result); +}); diff --git a/packages/backend/server/src/__tests__/mocks/doc-user.mock.ts b/packages/backend/server/src/__tests__/mocks/doc-user.mock.ts new file mode 100644 index 000000000..da6fa3623 --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/doc-user.mock.ts @@ -0,0 +1,16 @@ +import type { WorkspaceDocUserRole } from '@prisma/client'; +import { Prisma } from '@prisma/client'; + +import { Mocker } from './factory'; + +export type MockDocUserInput = Prisma.WorkspaceDocUserRoleUncheckedCreateInput; + +export type MockedDocUser = WorkspaceDocUserRole; + +export class MockDocUser extends Mocker { + override async create(input: MockDocUserInput) { + return await this.db.workspaceDocUserRole.create({ + data: input, + }); + } +} diff --git a/packages/backend/server/src/__tests__/mocks/index.ts b/packages/backend/server/src/__tests__/mocks/index.ts index b6abf141c..bc5b4d424 100644 --- a/packages/backend/server/src/__tests__/mocks/index.ts +++ b/packages/backend/server/src/__tests__/mocks/index.ts @@ -7,6 +7,7 @@ export * from './workspace-user.mock'; import { MockCopilotProvider } from './copilot.mock'; import { MockDocMeta } from './doc-meta.mock'; import { MockDocSnapshot } from './doc-snapshot.mock'; +import { MockDocUser } from './doc-user.mock'; import { MockEventBus } from './eventbus.mock'; import { MockMailer } from './mailer.mock'; import { MockJobQueue } from './queue.mock'; @@ -24,6 +25,7 @@ export const Mockers = { UserSettings: MockUserSettings, DocMeta: MockDocMeta, DocSnapshot: MockDocSnapshot, + DocUser: MockDocUser, }; export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer }; diff --git a/packages/backend/server/src/core/permission/__tests__/__snapshots__/docs.spec.ts.md b/packages/backend/server/src/core/permission/__tests__/__snapshots__/docs.spec.ts.md new file mode 100644 index 000000000..70353c89f --- /dev/null +++ b/packages/backend/server/src/core/permission/__tests__/__snapshots__/docs.spec.ts.md @@ -0,0 +1,62 @@ +# Snapshot report for `src/core/permission/__tests__/docs.spec.ts` + +The actual snapshot is saved in `docs.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should filter docs by Doc.Read + +> Snapshot 1 + + [ + { + docId: 'doc1', + }, + { + docId: 'doc2', + }, + { + docId: 'doc3', + }, + ] + +> Snapshot 2 + + [ + { + docId: 'doc1', + }, + { + docId: 'doc2', + }, + { + docId: 'doc3', + }, + ] + +## should filter docs by Doc.Publish + +> Snapshot 1 + + [ + { + docId: 'doc1', + }, + { + docId: 'doc2', + }, + { + docId: 'doc3', + }, + ] + +> Snapshot 2 + + [ + { + docId: 'doc2', + }, + { + docId: 'doc3', + }, + ] diff --git a/packages/backend/server/src/core/permission/__tests__/__snapshots__/docs.spec.ts.snap b/packages/backend/server/src/core/permission/__tests__/__snapshots__/docs.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..739edc5d49b5f756368e5448176f5c189d3ccf03 GIT binary patch literal 358 zcmV-s0h#_mRzVuuNLeJ%u&c z!MlYL;~$F%00000000B+l0QqsKp4iK_mZ^5B1vgyjf1QB4+kfOf`~)KN!^mWlthCk zxhvSwev3|0C%=Z1qmv)PPv9iTMN_mQ=}@=ca?iow8f3yAY2kMA`X;B3L E07}=V4*&oF literal 0 HcmV?d00001 diff --git a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts index 88440e7ee..b7c387f56 100644 --- a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts @@ -21,7 +21,7 @@ let ws: Workspace; test.before(async () => { module = await createTestingModule({ imports: [PermissionModule] }); models = module.get(Models); - ac = new DocAccessController(models); + ac = new DocAccessController(); }); test.beforeEach(async () => { diff --git a/packages/backend/server/src/core/permission/__tests__/docs.spec.ts b/packages/backend/server/src/core/permission/__tests__/docs.spec.ts new file mode 100644 index 000000000..5ba1431ff --- /dev/null +++ b/packages/backend/server/src/core/permission/__tests__/docs.spec.ts @@ -0,0 +1,144 @@ +import test from 'ava'; + +import { createModule } from '../../../__tests__/create-module'; +import { Mockers } from '../../../__tests__/mocks'; +import { DocRole, PermissionModule, WorkspaceRole } from '..'; +import { AccessControllerBuilder } from '../builder'; + +const module = await createModule({ + imports: [PermissionModule], +}); + +const builder = module.get(AccessControllerBuilder); + +test.after.always(async () => { + await module.close(); +}); + +test('should filter docs by Doc.Read', async t => { + const owner = await module.create(Mockers.User); + const workspace = await module.create(Mockers.Workspace, { + owner, + }); + + const docs1 = await builder + .user(owner.id) + .workspace(workspace.id) + .docs( + [{ docId: 'doc1' }, { docId: 'doc2' }, { docId: 'doc3' }], + 'Doc.Read' + ); + + t.is(docs1.length, 3); + t.snapshot(docs1); + + // member should have access to the docs + const member = await module.create(Mockers.User); + await module.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: member.id, + type: WorkspaceRole.Collaborator, + }); + + await module.create(Mockers.DocUser, { + workspaceId: workspace.id, + docId: 'doc1', + userId: member.id, + type: DocRole.Reader, + }); + + await module.create(Mockers.DocUser, { + workspaceId: workspace.id, + docId: 'doc2', + userId: member.id, + type: DocRole.Manager, + }); + + const docs2 = await builder + .user(member.id) + .workspace(workspace.id) + .docs( + [{ docId: 'doc1' }, { docId: 'doc2' }, { docId: 'doc3' }], + 'Doc.Read' + ); + + t.is(docs2.length, 3); + t.snapshot(docs2); + + // other user should not have access to the docs + const other = await module.create(Mockers.User); + + const docs3 = await builder + .user(other.id) + .workspace(workspace.id) + .docs( + [{ docId: 'doc1' }, { docId: 'doc2' }, { docId: 'doc3' }], + 'Doc.Read' + ); + + t.is(docs3.length, 0); +}); + +test('should filter docs by Doc.Publish', async t => { + const owner = await module.create(Mockers.User); + const workspace = await module.create(Mockers.Workspace, { + owner, + }); + + const docs1 = await builder + .user(owner.id) + .workspace(workspace.id) + .docs( + [{ docId: 'doc1' }, { docId: 'doc2' }, { docId: 'doc3' }], + 'Doc.Publish' + ); + + t.is(docs1.length, 3); + t.snapshot(docs1); + + // member should have access to the docs + const member = await module.create(Mockers.User); + await module.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: member.id, + type: WorkspaceRole.Collaborator, + }); + + await module.create(Mockers.DocUser, { + workspaceId: workspace.id, + docId: 'doc1', + userId: member.id, + type: DocRole.Reader, + }); + + await module.create(Mockers.DocUser, { + workspaceId: workspace.id, + docId: 'doc2', + userId: member.id, + type: DocRole.Manager, + }); + + const docs2 = await builder + .user(member.id) + .workspace(workspace.id) + .docs( + [{ docId: 'doc1' }, { docId: 'doc2' }, { docId: 'doc3' }], + 'Doc.Publish' + ); + + t.is(docs2.length, 2); + t.snapshot(docs2); + + // other user should not have access to the docs + const other = await module.create(Mockers.User); + + const docs3 = await builder + .user(other.id) + .workspace(workspace.id) + .docs( + [{ docId: 'doc1' }, { docId: 'doc2' }, { docId: 'doc3' }], + 'Doc.Publish' + ); + + t.is(docs3.length, 0); +}); diff --git a/packages/backend/server/src/core/permission/builder.ts b/packages/backend/server/src/core/permission/builder.ts index ab391a07d..b7ff4a9cd 100644 --- a/packages/backend/server/src/core/permission/builder.ts +++ b/packages/backend/server/src/core/permission/builder.ts @@ -4,6 +4,7 @@ import { DocID } from '../utils/doc'; import { getAccessController } from './controller'; import { Resource } from './resource'; import { DocAction, WorkspaceAction } from './types'; +import { WorkspaceAccessController } from './workspace'; @Injectable() export class AccessControllerBuilder { @@ -67,6 +68,28 @@ class WorkspaceAccessControllerBuilder { }); } + /** + * Filter items by doc access permission + * @param items - items to filter + * @param action - action to check + * @returns filtered items + */ + async docs( + items: T[], + action: DocAction + ): Promise { + const docIds = items.map(item => item.docId); + const checker = getAccessController('ws') as WorkspaceAccessController; + const docRoles = await checker.docRoles(this.data, docIds); + const docRolesMap = new Map( + docRoles.map((role, index) => [docIds[index], role]) + ); + + return items.filter(item => { + return docRolesMap.get(item.docId)?.permissions[action]; + }); + } + async assert(action: WorkspaceAction) { const checker = getAccessController('ws'); await checker.assert(this.data, action); diff --git a/packages/backend/server/src/core/permission/doc.ts b/packages/backend/server/src/core/permission/doc.ts index 9f930baf9..47d0dfe8d 100644 --- a/packages/backend/server/src/core/permission/doc.ts +++ b/packages/backend/server/src/core/permission/doc.ts @@ -1,16 +1,13 @@ 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'; @@ -18,10 +15,6 @@ import { WorkspaceAccessController } from './workspace'; 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); @@ -63,55 +56,9 @@ export class DocAccessController extends AccessController<'doc'> { const workspaceController = getAccessController( 'ws' ) as WorkspaceAccessController; - const workspaceRole = await workspaceController.getRole(payload); - - const userRole = await this.models.docUser.get( - payload.workspaceId, + const docRoles = await workspaceController.getDocRoles(payload, [ 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 !== null && workspaceRole !== WorkspaceRole.External) { - docRole = - defaultDocRole.external !== null - ? // edgecase: when doc role set to [None] for workspace member, but doc is public, we should fallback to external role - Math.max(defaultDocRole.workspace, defaultDocRole.external) - : 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 - const role = fixupDocRole(workspaceRole, docRole); - - // never return [None] - return role === DocRole.None ? null : role; - } - - private async defaultDocRole(workspaceId: string, docId: string) { - const doc = await this.models.doc.getMeta(workspaceId, docId, { - select: { - public: true, - defaultRole: true, - }, - }); - return { - external: doc?.public ? DocRole.External : null, - workspace: doc?.defaultRole ?? DocRole.Manager, - }; + ]); + return docRoles[0]; } } diff --git a/packages/backend/server/src/core/permission/types.ts b/packages/backend/server/src/core/permission/types.ts index 8934ddb37..f9a452d1d 100644 --- a/packages/backend/server/src/core/permission/types.ts +++ b/packages/backend/server/src/core/permission/types.ts @@ -150,6 +150,8 @@ type ResourceActionName = export type WorkspaceAction = ResourceActionName<'Workspace'>; export type DocAction = ResourceActionName<'Doc'>; export type Action = WorkspaceAction | DocAction; +export type WorkspaceActionPermissions = Record; +export type DocActionPermissions = Record; const cache = new WeakMap(); const buildPathReader = ( @@ -194,13 +196,10 @@ export const DOC_ACTIONS = RoleActionsMap.DocRole[DocRole.Owner]; export function mapWorkspaceRoleToPermissions( workspaceRole: WorkspaceRole | null ) { - const permissions = WORKSPACE_ACTIONS.reduce( - (map, action) => { - map[action] = false; - return map; - }, - {} as Record - ); + const permissions = WORKSPACE_ACTIONS.reduce((map, action) => { + map[action] = false; + return map; + }, {} as WorkspaceActionPermissions); if (workspaceRole === null) { return permissions; @@ -214,13 +213,10 @@ export function mapWorkspaceRoleToPermissions( } export function mapDocRoleToPermissions(docRole: DocRole | null) { - const permissions = DOC_ACTIONS.reduce( - (map, action) => { - map[action] = false; - return map; - }, - {} as Record - ); + const permissions = DOC_ACTIONS.reduce((map, action) => { + map[action] = false; + return map; + }, {} as DocActionPermissions); if (docRole === null || docRole === DocRole.None) { return permissions; diff --git a/packages/backend/server/src/core/permission/workspace.ts b/packages/backend/server/src/core/permission/workspace.ts index 59b384df8..b492f39a0 100644 --- a/packages/backend/server/src/core/permission/workspace.ts +++ b/packages/backend/server/src/core/permission/workspace.ts @@ -1,10 +1,12 @@ import { Injectable } from '@nestjs/common'; import { SpaceAccessDenied } from '../../base'; -import { Models } from '../../models'; +import { DocRole, Models } from '../../models'; import { AccessController } from './controller'; import type { Resource } from './resource'; import { + fixupDocRole, + mapDocRoleToPermissions, mapWorkspaceRoleToPermissions, WorkspaceAction, workspaceActionRequiredRole, @@ -74,6 +76,104 @@ export class WorkspaceAccessController extends AccessController<'ws'> { return role; } + async docRoles(payload: Resource<'ws'>, docIds: string[]) { + const docRoles = await this.getDocRoles(payload, docIds); + return docRoles.map(role => ({ + role, + permissions: mapDocRoleToPermissions(role), + })); + } + + async getDocRoles(payload: Resource<'ws'>, docIds: string[]) { + const docRoles: (DocRole | null)[] = []; + + if (docIds.length === 0) { + return docRoles; + } + + const workspaceRole = await this.getRole(payload); + + const userRoles = await this.models.docUser.findMany( + payload.workspaceId, + docIds, + payload.userId + ); + const userRolesMap = new Map(userRoles.map(role => [role.docId, role])); + + const noUserRoleDocIds = docIds.filter(docId => { + const userRole = userRolesMap.get(docId); + return (userRole?.type ?? null) === null; + }); + const defaultDocRoles = + noUserRoleDocIds.length > 0 + ? await this.getDocDefaultRoles( + payload, + noUserRoleDocIds, + workspaceRole + ) + : []; + const defaultDocRolesMap = new Map( + defaultDocRoles.map((role, index) => [noUserRoleDocIds[index], role]) + ); + + for (const docId of docIds) { + const userRole = userRolesMap.get(docId); + + let docRole: DocRole | null = userRole?.type ?? null; + + // fallback logic + if (docRole === null) { + docRole = defaultDocRolesMap.get(docId) ?? null; + } + + // 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 + const role = fixupDocRole(workspaceRole, docRole); + + // never return [None] + docRoles.push(role === DocRole.None ? null : role); + } + + return docRoles; + } + + private async getDocDefaultRoles( + payload: Resource<'ws'>, + docIds: string[], + workspaceRole: WorkspaceRole | null + ) { + const fallbackDocRoles: (DocRole | null)[] = []; + + if (docIds.length === 0) { + return fallbackDocRoles; + } + + const defaultDocRoles = await this.models.doc.findDefaultRoles( + payload.workspaceId, + docIds + ); + + for (const defaultDocRole of defaultDocRoles) { + let docRole: DocRole | null; + // if user is in workspace but doc role is not set, fallback to default doc role + if (workspaceRole !== null && workspaceRole !== WorkspaceRole.External) { + docRole = + defaultDocRole.external !== null + ? // edgecase: when doc role set to [None] for workspace member, but doc is public, we should fallback to external role + Math.max(defaultDocRole.workspace, defaultDocRole.external) + : defaultDocRole.workspace; + } else { + // else fallback to external doc role + docRole = defaultDocRole.external; + } + + fallbackDocRoles.push(docRole); + } + + return fallbackDocRoles; + } + private async defaultWorkspaceRole(payload: Resource<'ws'>) { const ws = await this.models.workspace.get(payload.workspaceId); diff --git a/packages/backend/server/src/models/doc-user.ts b/packages/backend/server/src/models/doc-user.ts index 8695160a2..d8a71e3bd 100644 --- a/packages/backend/server/src/models/doc-user.ts +++ b/packages/backend/server/src/models/doc-user.ts @@ -174,6 +174,18 @@ export class DocUserModel extends BaseModel { }); } + async findMany(workspaceId: string, docIds: string[], userId: string) { + return await this.db.workspaceDocUserRole.findMany({ + where: { + workspaceId, + docId: { + in: docIds, + }, + userId, + }, + }); + } + count(workspaceId: string, docId: string) { return this.db.workspaceDocUserRole.count({ where: { diff --git a/packages/backend/server/src/models/doc.ts b/packages/backend/server/src/models/doc.ts index d013261bc..148a9e0d8 100644 --- a/packages/backend/server/src/models/doc.ts +++ b/packages/backend/server/src/models/doc.ts @@ -378,6 +378,26 @@ export class DocModel extends BaseModel { }); } + async findDefaultRoles(workspaceId: string, docIds: string[]) { + const docs = await this.findMetas( + docIds.map(docId => ({ + workspaceId, + docId, + })), + { + select: { + defaultRole: true, + public: true, + }, + } + ); + + return docs.map(doc => ({ + external: doc?.public ? DocRole.External : null, + workspace: doc?.defaultRole ?? DocRole.Manager, + })); + } + async findAuthors(ids: { workspaceId: string; docId: string }[]) { const rows = await this.db.snapshot.findMany({ where: { @@ -401,13 +421,29 @@ export class DocModel extends BaseModel { ); } - async findMetas(ids: { workspaceId: string; docId: string }[]) { - const rows = await this.db.workspaceDoc.findMany({ + async findMetas