feat(server): filter docs by access role (#12311)
close CLOUD-208 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -34,3 +34,107 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
highlights: null,
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -5,6 +5,7 @@ import {
|
|||||||
SearchTable,
|
SearchTable,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
|
|
||||||
|
import { DocRole } from '../../../models';
|
||||||
import { IndexerService } from '../../../plugins/indexer/service';
|
import { IndexerService } from '../../../plugins/indexer/service';
|
||||||
import { Mockers } from '../../mocks';
|
import { Mockers } from '../../mocks';
|
||||||
import { app, e2e } from '../test';
|
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.is(result.workspace.search.nodes.length, 2);
|
||||||
t.snapshot(result.workspace.search.nodes);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
16
packages/backend/server/src/__tests__/mocks/doc-user.mock.ts
Normal file
16
packages/backend/server/src/__tests__/mocks/doc-user.mock.ts
Normal file
@@ -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<MockDocUserInput, MockedDocUser> {
|
||||||
|
override async create(input: MockDocUserInput) {
|
||||||
|
return await this.db.workspaceDocUserRole.create({
|
||||||
|
data: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export * from './workspace-user.mock';
|
|||||||
import { MockCopilotProvider } from './copilot.mock';
|
import { MockCopilotProvider } from './copilot.mock';
|
||||||
import { MockDocMeta } from './doc-meta.mock';
|
import { MockDocMeta } from './doc-meta.mock';
|
||||||
import { MockDocSnapshot } from './doc-snapshot.mock';
|
import { MockDocSnapshot } from './doc-snapshot.mock';
|
||||||
|
import { MockDocUser } from './doc-user.mock';
|
||||||
import { MockEventBus } from './eventbus.mock';
|
import { MockEventBus } from './eventbus.mock';
|
||||||
import { MockMailer } from './mailer.mock';
|
import { MockMailer } from './mailer.mock';
|
||||||
import { MockJobQueue } from './queue.mock';
|
import { MockJobQueue } from './queue.mock';
|
||||||
@@ -24,6 +25,7 @@ export const Mockers = {
|
|||||||
UserSettings: MockUserSettings,
|
UserSettings: MockUserSettings,
|
||||||
DocMeta: MockDocMeta,
|
DocMeta: MockDocMeta,
|
||||||
DocSnapshot: MockDocSnapshot,
|
DocSnapshot: MockDocSnapshot,
|
||||||
|
DocUser: MockDocUser,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer };
|
export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer };
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
Binary file not shown.
@@ -21,7 +21,7 @@ let ws: Workspace;
|
|||||||
test.before(async () => {
|
test.before(async () => {
|
||||||
module = await createTestingModule({ imports: [PermissionModule] });
|
module = await createTestingModule({ imports: [PermissionModule] });
|
||||||
models = module.get<Models>(Models);
|
models = module.get<Models>(Models);
|
||||||
ac = new DocAccessController(models);
|
ac = new DocAccessController();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { DocID } from '../utils/doc';
|
|||||||
import { getAccessController } from './controller';
|
import { getAccessController } from './controller';
|
||||||
import { Resource } from './resource';
|
import { Resource } from './resource';
|
||||||
import { DocAction, WorkspaceAction } from './types';
|
import { DocAction, WorkspaceAction } from './types';
|
||||||
|
import { WorkspaceAccessController } from './workspace';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessControllerBuilder {
|
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<T extends { docId: string }>(
|
||||||
|
items: T[],
|
||||||
|
action: DocAction
|
||||||
|
): Promise<T[]> {
|
||||||
|
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) {
|
async assert(action: WorkspaceAction) {
|
||||||
const checker = getAccessController('ws');
|
const checker = getAccessController('ws');
|
||||||
await checker.assert(this.data, action);
|
await checker.assert(this.data, action);
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { DocActionDenied } from '../../base';
|
import { DocActionDenied } from '../../base';
|
||||||
import { Models } from '../../models';
|
|
||||||
import { AccessController, getAccessController } from './controller';
|
import { AccessController, getAccessController } from './controller';
|
||||||
import type { Resource } from './resource';
|
import type { Resource } from './resource';
|
||||||
import {
|
import {
|
||||||
DocAction,
|
DocAction,
|
||||||
docActionRequiredRole,
|
docActionRequiredRole,
|
||||||
DocRole,
|
DocRole,
|
||||||
fixupDocRole,
|
|
||||||
mapDocRoleToPermissions,
|
mapDocRoleToPermissions,
|
||||||
WorkspaceRole,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { WorkspaceAccessController } from './workspace';
|
import { WorkspaceAccessController } from './workspace';
|
||||||
|
|
||||||
@@ -18,10 +15,6 @@ import { WorkspaceAccessController } from './workspace';
|
|||||||
export class DocAccessController extends AccessController<'doc'> {
|
export class DocAccessController extends AccessController<'doc'> {
|
||||||
protected readonly type = 'doc';
|
protected readonly type = 'doc';
|
||||||
|
|
||||||
constructor(private readonly models: Models) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async role(resource: Resource<'doc'>) {
|
async role(resource: Resource<'doc'>) {
|
||||||
const role = await this.getRole(resource);
|
const role = await this.getRole(resource);
|
||||||
|
|
||||||
@@ -63,55 +56,9 @@ export class DocAccessController extends AccessController<'doc'> {
|
|||||||
const workspaceController = getAccessController(
|
const workspaceController = getAccessController(
|
||||||
'ws'
|
'ws'
|
||||||
) as WorkspaceAccessController;
|
) as WorkspaceAccessController;
|
||||||
const workspaceRole = await workspaceController.getRole(payload);
|
const docRoles = await workspaceController.getDocRoles(payload, [
|
||||||
|
|
||||||
const userRole = await this.models.docUser.get(
|
|
||||||
payload.workspaceId,
|
|
||||||
payload.docId,
|
payload.docId,
|
||||||
payload.userId
|
]);
|
||||||
);
|
return docRoles[0];
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ type ResourceActionName<T extends keyof typeof Actions> =
|
|||||||
export type WorkspaceAction = ResourceActionName<'Workspace'>;
|
export type WorkspaceAction = ResourceActionName<'Workspace'>;
|
||||||
export type DocAction = ResourceActionName<'Doc'>;
|
export type DocAction = ResourceActionName<'Doc'>;
|
||||||
export type Action = WorkspaceAction | DocAction;
|
export type Action = WorkspaceAction | DocAction;
|
||||||
|
export type WorkspaceActionPermissions = Record<WorkspaceAction, boolean>;
|
||||||
|
export type DocActionPermissions = Record<DocAction, boolean>;
|
||||||
|
|
||||||
const cache = new WeakMap<object, any>();
|
const cache = new WeakMap<object, any>();
|
||||||
const buildPathReader = (
|
const buildPathReader = (
|
||||||
@@ -194,13 +196,10 @@ export const DOC_ACTIONS = RoleActionsMap.DocRole[DocRole.Owner];
|
|||||||
export function mapWorkspaceRoleToPermissions(
|
export function mapWorkspaceRoleToPermissions(
|
||||||
workspaceRole: WorkspaceRole | null
|
workspaceRole: WorkspaceRole | null
|
||||||
) {
|
) {
|
||||||
const permissions = WORKSPACE_ACTIONS.reduce(
|
const permissions = WORKSPACE_ACTIONS.reduce((map, action) => {
|
||||||
(map, action) => {
|
map[action] = false;
|
||||||
map[action] = false;
|
return map;
|
||||||
return map;
|
}, {} as WorkspaceActionPermissions);
|
||||||
},
|
|
||||||
{} as Record<WorkspaceAction, boolean>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (workspaceRole === null) {
|
if (workspaceRole === null) {
|
||||||
return permissions;
|
return permissions;
|
||||||
@@ -214,13 +213,10 @@ export function mapWorkspaceRoleToPermissions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapDocRoleToPermissions(docRole: DocRole | null) {
|
export function mapDocRoleToPermissions(docRole: DocRole | null) {
|
||||||
const permissions = DOC_ACTIONS.reduce(
|
const permissions = DOC_ACTIONS.reduce((map, action) => {
|
||||||
(map, action) => {
|
map[action] = false;
|
||||||
map[action] = false;
|
return map;
|
||||||
return map;
|
}, {} as DocActionPermissions);
|
||||||
},
|
|
||||||
{} as Record<DocAction, boolean>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (docRole === null || docRole === DocRole.None) {
|
if (docRole === null || docRole === DocRole.None) {
|
||||||
return permissions;
|
return permissions;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { SpaceAccessDenied } from '../../base';
|
import { SpaceAccessDenied } from '../../base';
|
||||||
import { Models } from '../../models';
|
import { DocRole, Models } from '../../models';
|
||||||
import { AccessController } from './controller';
|
import { AccessController } from './controller';
|
||||||
import type { Resource } from './resource';
|
import type { Resource } from './resource';
|
||||||
import {
|
import {
|
||||||
|
fixupDocRole,
|
||||||
|
mapDocRoleToPermissions,
|
||||||
mapWorkspaceRoleToPermissions,
|
mapWorkspaceRoleToPermissions,
|
||||||
WorkspaceAction,
|
WorkspaceAction,
|
||||||
workspaceActionRequiredRole,
|
workspaceActionRequiredRole,
|
||||||
@@ -74,6 +76,104 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
|
|||||||
return role;
|
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'>) {
|
private async defaultWorkspaceRole(payload: Resource<'ws'>) {
|
||||||
const ws = await this.models.workspace.get(payload.workspaceId);
|
const ws = await this.models.workspace.get(payload.workspaceId);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
count(workspaceId: string, docId: string) {
|
||||||
return this.db.workspaceDocUserRole.count({
|
return this.db.workspaceDocUserRole.count({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -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 }[]) {
|
async findAuthors(ids: { workspaceId: string; docId: string }[]) {
|
||||||
const rows = await this.db.snapshot.findMany({
|
const rows = await this.db.snapshot.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -401,13 +421,29 @@ export class DocModel extends BaseModel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findMetas(ids: { workspaceId: string; docId: string }[]) {
|
async findMetas<Select extends Prisma.WorkspaceDocSelect>(
|
||||||
const rows = await this.db.workspaceDoc.findMany({
|
ids: { workspaceId: string; docId: string }[],
|
||||||
|
options?: { select?: Select }
|
||||||
|
) {
|
||||||
|
let select = options?.select;
|
||||||
|
if (select) {
|
||||||
|
// add workspaceId and docId to the select
|
||||||
|
select = {
|
||||||
|
...select,
|
||||||
|
workspaceId: true,
|
||||||
|
docId: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rows = (await this.db.workspaceDoc.findMany({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: { in: ids.map(id => id.workspaceId) },
|
workspaceId: { in: ids.map(id => id.workspaceId) },
|
||||||
docId: { in: ids.map(id => id.docId) },
|
docId: { in: ids.map(id => id.docId) },
|
||||||
},
|
},
|
||||||
});
|
select,
|
||||||
|
})) as (Prisma.WorkspaceDocGetPayload<{ select: Select }> & {
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
})[];
|
||||||
const resultMap = new Map(
|
const resultMap = new Map(
|
||||||
rows.map(row => [`${row.workspaceId}-${row.docId}`, row])
|
rows.map(row => [`${row.workspaceId}-${row.docId}`, row])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ export class IndexerResolver {
|
|||||||
user: UserType,
|
user: UserType,
|
||||||
nodes: SearchNodeWithMeta[]
|
nodes: SearchNodeWithMeta[]
|
||||||
) {
|
) {
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
const isTeamWorkspace = await this.models.workspaceFeature.has(
|
const isTeamWorkspace = await this.models.workspaceFeature.has(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
'team_plan_v1'
|
'team_plan_v1'
|
||||||
@@ -120,17 +124,17 @@ export class IndexerResolver {
|
|||||||
if (!isTeamWorkspace) {
|
if (!isTeamWorkspace) {
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
const needs: SearchNodeWithMeta[] = [];
|
|
||||||
// TODO(@fengmk2): CLOUD-208 support batch check
|
const needs = await this.ac
|
||||||
for (const node of nodes) {
|
.user(user.id)
|
||||||
const canRead = await this.ac
|
.workspace(workspace.id)
|
||||||
.user(user.id)
|
.docs(
|
||||||
.doc(node._source.workspaceId, node._source.docId)
|
nodes.map(node => ({
|
||||||
.can('Doc.Read');
|
node,
|
||||||
if (canRead) {
|
docId: node._source.docId,
|
||||||
needs.push(node);
|
})),
|
||||||
}
|
'Doc.Read'
|
||||||
}
|
);
|
||||||
return needs;
|
return needs.map(node => node.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user