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:
fengmk2
2025-05-19 03:28:22 +00:00
parent 85bb728ca8
commit 1e7774929c
16 changed files with 703 additions and 87 deletions
@@ -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,
},
},
},
}
@@ -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 };
@@ -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',
},
]
@@ -21,7 +21,7 @@ let ws: Workspace;
test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models);
ac = new DocAccessController(models);
ac = new DocAccessController();
});
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 { 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<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) {
const checker = getAccessController('ws');
await checker.assert(this.data, action);
@@ -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];
}
}
@@ -150,6 +150,8 @@ type ResourceActionName<T extends keyof typeof Actions> =
export type WorkspaceAction = ResourceActionName<'Workspace'>;
export type DocAction = ResourceActionName<'Doc'>;
export type Action = WorkspaceAction | DocAction;
export type WorkspaceActionPermissions = Record<WorkspaceAction, boolean>;
export type DocActionPermissions = Record<DocAction, boolean>;
const cache = new WeakMap<object, any>();
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<WorkspaceAction, boolean>
);
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<DocAction, boolean>
);
const permissions = DOC_ACTIONS.reduce((map, action) => {
map[action] = false;
return map;
}, {} as DocActionPermissions);
if (docRole === null || docRole === DocRole.None) {
return permissions;
@@ -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);
@@ -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: {
+39 -3
View File
@@ -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<Select extends Prisma.WorkspaceDocSelect>(
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: {
workspaceId: { in: ids.map(id => id.workspaceId) },
docId: { in: ids.map(id => id.docId) },
},
});
select,
})) as (Prisma.WorkspaceDocGetPayload<{ select: Select }> & {
workspaceId: string;
docId: string;
})[];
const resultMap = new Map(
rows.map(row => [`${row.workspaceId}-${row.docId}`, row])
);
@@ -113,6 +113,10 @@ export class IndexerResolver {
user: UserType,
nodes: SearchNodeWithMeta[]
) {
if (nodes.length === 0) {
return nodes;
}
const isTeamWorkspace = await this.models.workspaceFeature.has(
workspace.id,
'team_plan_v1'
@@ -120,17 +124,17 @@ export class IndexerResolver {
if (!isTeamWorkspace) {
return nodes;
}
const needs: SearchNodeWithMeta[] = [];
// TODO(@fengmk2): CLOUD-208 support batch check
for (const node of nodes) {
const canRead = await this.ac
.user(user.id)
.doc(node._source.workspaceId, node._source.docId)
.can('Doc.Read');
if (canRead) {
needs.push(node);
}
}
return needs;
const needs = await this.ac
.user(user.id)
.workspace(workspace.id)
.docs(
nodes.map(node => ({
node,
docId: node._source.docId,
})),
'Doc.Read'
);
return needs.map(node => node.node);
}
}