mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-21 00:07:01 +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:
@@ -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 () => {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user