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

View File

@@ -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',
},
]

View File

@@ -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 () => {

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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];
}
}

View File

@@ -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;

View File

@@ -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);