mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 16:19:43 +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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user