mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-05 16:30:33 +08:00
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,
|
||||
},
|
||||
]
|
||||
|
||||
## 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,
|
||||
} 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);
|
||||
});
|
||||
|
||||
@@ -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 { 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 };
|
||||
|
||||
Reference in New Issue
Block a user