feat: workspace level share settings (#14201)

fix #13698
This commit is contained in:
DarkSky
2026-01-03 01:13:27 +08:00
committed by GitHub
parent 60de882a30
commit 9a7f8e7d4d
36 changed files with 560 additions and 34 deletions
@@ -100,6 +100,11 @@ export class DocRendererController {
workspaceId: string,
docId: string
): Promise<RenderOptions | null> {
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
if (!allowSharing) {
return null;
}
let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId);
if (!allowUrlPreview) {
@@ -118,6 +123,11 @@ export class DocRendererController {
private async getWorkspaceContent(
workspaceId: string
): Promise<RenderOptions | null> {
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
if (!allowSharing) {
return null;
}
const allowUrlPreview =
await this.models.workspace.allowUrlPreview(workspaceId);
@@ -21,7 +21,7 @@ let ws: Workspace;
test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models);
ac = new DocAccessController();
ac = module.get(DocAccessController);
});
test.beforeEach(async () => {
@@ -80,6 +80,20 @@ test('should fallback to [External] if workspace is public', async t => {
t.is(role, WorkspaceRole.External);
});
test('should return null if workspace is public but sharing disabled', async t => {
await models.workspace.update(ws.id, {
public: true,
enableSharing: false,
});
const role = await ac.getRole({
workspaceId: ws.id,
userId: 'random-user-id',
});
t.is(role, null);
});
test('should return null even workspace has public doc', async t => {
await models.doc.publish(ws.id, 'doc1');
@@ -91,6 +105,18 @@ test('should return null even workspace has public doc', async t => {
t.is(role, null);
});
test('should return null even workspace has public doc when sharing disabled', async t => {
await models.doc.publish(ws.id, 'doc1');
await models.workspace.update(ws.id, { enableSharing: false });
const role = await ac.getRole({
workspaceId: ws.id,
userId: 'random-user-id',
});
t.is(role, null);
});
test('should return mapped external permission for workspace has public docs', async t => {
await models.doc.publish(ws.id, 'doc1');
@@ -105,6 +131,24 @@ test('should return mapped external permission for workspace has public docs', a
);
});
test('should reject external doc roles when sharing disabled', async t => {
await models.workspace.update(ws.id, {
public: true,
enableSharing: false,
});
const [docRole] = await ac.docRoles(
{
workspaceId: ws.id,
userId: 'random-user-id',
},
['doc1']
);
t.is(docRole.role, null);
t.false(docRole.permissions['Doc.Read']);
});
test('should return mapped permissions', async t => {
const { permissions } = await ac.role({
workspaceId: ws.id,
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DocActionDenied } from '../../base';
import { Models } from '../../models';
import { AccessController, getAccessController } from './controller';
import type { Resource } from './resource';
import {
@@ -14,14 +15,21 @@ import { WorkspaceAccessController } from './workspace';
@Injectable()
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);
const permissions = mapDocRoleToPermissions(role);
const sharingAllowed = await this.models.workspace.allowSharing(
resource.workspaceId
);
if (!sharingAllowed) {
permissions['Doc.Publish'] = false;
}
return {
role,
permissions: mapDocRoleToPermissions(role),
};
return { role, permissions };
}
async can(resource: Resource<'doc'>, action: DocAction) {
@@ -27,7 +27,11 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
// NOTE(@forehalo): special case for public page
// Currently, we can not only load binary of a public Doc to render in a shared page,
// so we need to ensure anyone has basic 'read' permission to a workspace that has public pages.
if (!role && (await this.models.doc.hasPublic(resource.workspaceId))) {
if (
!role &&
(await this.models.workspace.allowSharing(resource.workspaceId)) &&
(await this.models.doc.hasPublic(resource.workspaceId))
) {
role = WorkspaceRole.External;
}
@@ -92,6 +96,15 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
}
const workspaceRole = await this.getRole(payload);
const sharingAllowed = await this.models.workspace.allowSharing(
payload.workspaceId
);
if (
!sharingAllowed &&
(workspaceRole === null || workspaceRole === WorkspaceRole.External)
) {
return docIds.map(() => null);
}
const userRoles = await this.models.docUser.findMany(
payload.workspaceId,
@@ -190,7 +203,8 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
}
if (ws.public) {
return WorkspaceRole.External;
const sharingAllowed = await this.models.workspace.allowSharing(ws.id);
return sharingAllowed ? WorkspaceRole.External : null;
}
return null;
@@ -56,6 +56,21 @@ class ListWorkspaceInput {
@Field(() => AdminWorkspaceSort, { nullable: true })
orderBy?: AdminWorkspaceSort;
@Field({ nullable: true })
public?: boolean;
@Field({ nullable: true })
enableAi?: boolean;
@Field({ nullable: true })
enableSharing?: boolean;
@Field({ nullable: true })
enableUrlPreview?: boolean;
@Field({ nullable: true })
enableDocEmbedding?: boolean;
}
@ObjectType()
@@ -111,6 +126,9 @@ export class AdminWorkspace {
@Field()
enableAi!: boolean;
@Field()
enableSharing!: boolean;
@Field()
enableUrlPreview!: boolean;
@@ -150,6 +168,7 @@ class AdminUpdateWorkspaceInput extends PartialType(
PickType(AdminWorkspace, [
'public',
'enableAi',
'enableSharing',
'enableUrlPreview',
'enableDocEmbedding',
'name',
@@ -183,6 +202,13 @@ export class AdminWorkspaceResolver {
keyword: filter.keyword,
features: filter.features,
order: this.mapSort(filter.orderBy),
flags: {
public: filter.public ?? undefined,
enableAi: filter.enableAi ?? undefined,
enableSharing: filter.enableSharing ?? undefined,
enableUrlPreview: filter.enableUrlPreview ?? undefined,
enableDocEmbedding: filter.enableDocEmbedding ?? undefined,
},
includeTotal: false,
});
return rows;
@@ -196,6 +222,13 @@ export class AdminWorkspaceResolver {
const total = await this.models.workspace.adminCountWorkspaces({
keyword: filter.keyword,
features: filter.features,
flags: {
public: filter.public ?? undefined,
enableAi: filter.enableAi ?? undefined,
enableSharing: filter.enableSharing ?? undefined,
enableUrlPreview: filter.enableUrlPreview ?? undefined,
enableDocEmbedding: filter.enableDocEmbedding ?? undefined,
},
});
return total;
}
@@ -79,6 +79,9 @@ export class WorkspaceType extends WorkspaceFeatureType {
@Field({ description: 'Enable AI' })
enableAi!: boolean;
@Field({ description: 'Enable workspace sharing' })
enableSharing!: boolean;
@Field({ description: 'Enable url previous when sharing' })
enableUrlPreview!: boolean;
@@ -130,7 +133,13 @@ export class InvitationType {
@InputType()
export class UpdateWorkspaceInput extends PickType(
PartialType(WorkspaceType),
['public', 'enableAi', 'enableUrlPreview', 'enableDocEmbedding'],
[
'public',
'enableAi',
'enableSharing',
'enableUrlPreview',
'enableDocEmbedding',
],
InputType
) {
@Field(() => ID)