mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 03:56:23 +08:00
feat(server): improve subscription sync stability (#14703)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import test from 'ava';
|
||||
|
||||
import { createTestingModule, TestingModule } from '../../../__tests__/utils';
|
||||
@@ -10,11 +12,13 @@ import {
|
||||
} from '../../../models';
|
||||
import { DocAccessController } from '../doc';
|
||||
import { PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
import { DocRole, mapDocRoleToPermissions } from '../types';
|
||||
|
||||
let module: TestingModule;
|
||||
let models: Models;
|
||||
let ac: DocAccessController;
|
||||
let policy: WorkspacePolicyService;
|
||||
let user: User;
|
||||
let ws: Workspace;
|
||||
|
||||
@@ -22,11 +26,12 @@ test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
models = module.get<Models>(Models);
|
||||
ac = module.get(DocAccessController);
|
||||
policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await module.initTestingDB();
|
||||
user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
user = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
ws = await models.workspace.create(user.id);
|
||||
});
|
||||
|
||||
@@ -45,7 +50,7 @@ test('should get null role', async t => {
|
||||
});
|
||||
|
||||
test('should return null if workspace role is not accepted', async t => {
|
||||
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
@@ -162,7 +167,7 @@ test('should assert action', async t => {
|
||||
)
|
||||
);
|
||||
|
||||
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
|
||||
await t.throwsAsync(
|
||||
ac.assert(
|
||||
@@ -184,3 +189,37 @@ test('should assert action', async t => {
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('should apply readonly doc restrictions while keeping cleanup actions', async t => {
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const member = await models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await models.workspaceUser.set(
|
||||
ws.id,
|
||||
member.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
}
|
||||
await policy.reconcileWorkspaceQuotaState(ws.id);
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
workspaceId: ws.id,
|
||||
docId: 'doc1',
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
t.false(permissions['Doc.Update']);
|
||||
t.false(permissions['Doc.Publish']);
|
||||
t.false(permissions['Doc.Duplicate']);
|
||||
t.false(permissions['Doc.Comments.Create']);
|
||||
t.false(permissions['Doc.Comments.Update']);
|
||||
t.false(permissions['Doc.Comments.Resolve']);
|
||||
t.true(permissions['Doc.Read']);
|
||||
t.true(permissions['Doc.Delete']);
|
||||
t.true(permissions['Doc.Trash']);
|
||||
t.true(permissions['Doc.TransferOwner']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { SpaceAccessDenied } from '../../../base';
|
||||
import {
|
||||
Models,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { QuotaService } from '../../quota/service';
|
||||
import { QuotaServiceModule } from '../../quota/service.module';
|
||||
import { PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
models: Models;
|
||||
policy: WorkspacePolicyService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
const READONLY_FEATURE = 'quota_exceeded_readonly_workspace_v1' as const;
|
||||
type WorkspaceQuotaSnapshot = Awaited<
|
||||
ReturnType<QuotaService['getWorkspaceQuotaWithUsage']>
|
||||
> & {
|
||||
ownerQuota?: string;
|
||||
};
|
||||
async function addAcceptedMembers(
|
||||
models: Models,
|
||||
workspaceId: string,
|
||||
count: number
|
||||
) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
const member = await models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await models.workspaceUser.set(
|
||||
workspaceId,
|
||||
member.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let owner: User;
|
||||
let workspace: Workspace;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [PermissionModule] });
|
||||
t.context.module = module;
|
||||
t.context.models = module.get(Models);
|
||||
t.context.policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
Sinon.restore();
|
||||
await t.context.module.initTestingDB();
|
||||
owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
workspace = await t.context.models.workspace.create(owner.id);
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should reuse quota service exported by quota service module', async t => {
|
||||
const module = await createTestingModule(
|
||||
{ imports: [PermissionModule, QuotaServiceModule] },
|
||||
false
|
||||
);
|
||||
|
||||
try {
|
||||
const quota = module.select(QuotaServiceModule).get(QuotaService, {
|
||||
strict: true,
|
||||
});
|
||||
const policy = module.select(PermissionModule).get(WorkspacePolicyService, {
|
||||
strict: true,
|
||||
});
|
||||
|
||||
t.is(Reflect.get(policy, 'quota'), quota);
|
||||
} finally {
|
||||
await module.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('should keep owned workspace writable when quota is within limit', async t => {
|
||||
const state = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.false(state.isReadonly);
|
||||
t.deepEqual(state.readonlyReasons, []);
|
||||
t.false(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
});
|
||||
|
||||
test('should enter readonly mode when fallback owner member quota overflows', async t => {
|
||||
await addAcceptedMembers(t.context.models, workspace.id, 10);
|
||||
|
||||
const state = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.true(state.isReadonly);
|
||||
t.true(state.canRecoverByRemovingMembers);
|
||||
t.false(state.canRecoverByDeletingBlobs);
|
||||
t.deepEqual(state.readonlyReasons, ['member_overflow']);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
await t.throwsAsync(t.context.policy.assertCanInviteMembers(workspace.id), {
|
||||
instanceOf: SpaceAccessDenied,
|
||||
});
|
||||
});
|
||||
|
||||
test('should deny blob uploads when user no longer has write access', async t => {
|
||||
const external = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
external.id,
|
||||
WorkspaceRole.External,
|
||||
{ status: WorkspaceMemberStatus.Accepted }
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.policy.assertCanUploadBlob(external.id, workspace.id),
|
||||
{ instanceOf: SpaceAccessDenied }
|
||||
);
|
||||
});
|
||||
|
||||
test('should enter readonly mode when fallback owner storage quota overflows', async t => {
|
||||
const quota = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quota') as QuotaService,
|
||||
'getWorkspaceQuotaWithUsage'
|
||||
);
|
||||
quota.resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 2,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 2,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
|
||||
const state = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.true(state.isReadonly);
|
||||
t.false(state.canRecoverByRemovingMembers);
|
||||
t.true(state.canRecoverByDeletingBlobs);
|
||||
t.deepEqual(state.readonlyReasons, ['storage_overflow']);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
});
|
||||
|
||||
test('should leave readonly mode after workspace usage recovers', async t => {
|
||||
const quota = Sinon.stub(
|
||||
Reflect.get(t.context.policy, 'quota') as QuotaService,
|
||||
'getWorkspaceQuotaWithUsage'
|
||||
);
|
||||
quota.onFirstCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 2,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 2,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
quota.onSecondCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 0,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 0,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
quota.onThirdCall().resolves({
|
||||
name: 'Free',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
usedStorageQuota: 0,
|
||||
historyPeriod: 1,
|
||||
memberLimit: 3,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 0,
|
||||
ownerQuota: owner.id,
|
||||
} satisfies WorkspaceQuotaSnapshot);
|
||||
|
||||
await t.context.policy.reconcileWorkspaceQuotaState(workspace.id);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
|
||||
const recovered = await t.context.policy.reconcileWorkspaceQuotaState(
|
||||
workspace.id
|
||||
);
|
||||
|
||||
t.false(recovered.isReadonly);
|
||||
t.deepEqual(recovered.readonlyReasons, []);
|
||||
t.false(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, READONLY_FEATURE)
|
||||
);
|
||||
await t.notThrowsAsync(t.context.policy.assertCanInviteMembers(workspace.id));
|
||||
});
|
||||
|
||||
test('should roll back team cancellation cleanup when cleanup fails', async t => {
|
||||
const pending = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const admin = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
pending.id,
|
||||
WorkspaceRole.Collaborator
|
||||
);
|
||||
await t.context.models.workspaceUser.set(
|
||||
workspace.id,
|
||||
admin.id,
|
||||
WorkspaceRole.Admin,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await t.context.models.workspaceFeature.add(
|
||||
workspace.id,
|
||||
'team_plan_v1',
|
||||
'test team workspace',
|
||||
{
|
||||
memberLimit: 20,
|
||||
}
|
||||
);
|
||||
|
||||
const failure = new Error('cleanup failed');
|
||||
Sinon.stub(t.context.models.workspaceFeature, 'remove').rejects(failure);
|
||||
|
||||
const error = await t.throwsAsync(
|
||||
t.context.policy.handleTeamPlanCanceled(workspace.id),
|
||||
{
|
||||
is: failure,
|
||||
}
|
||||
);
|
||||
|
||||
t.is(error, failure);
|
||||
t.truthy(await t.context.models.workspaceUser.get(workspace.id, pending.id));
|
||||
t.is(
|
||||
(await t.context.models.workspaceUser.get(workspace.id, admin.id))?.type,
|
||||
WorkspaceRole.Admin
|
||||
);
|
||||
t.true(
|
||||
await t.context.models.workspaceFeature.has(workspace.id, 'team_plan_v1')
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import test from 'ava';
|
||||
|
||||
import { createTestingModule, TestingModule } from '../../../__tests__/utils';
|
||||
@@ -9,24 +11,27 @@ import {
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { PermissionModule } from '../index';
|
||||
import { WorkspacePolicyService } from '../policy';
|
||||
import { mapWorkspaceRoleToPermissions } from '../types';
|
||||
import { WorkspaceAccessController } from '../workspace';
|
||||
|
||||
let module: TestingModule;
|
||||
let models: Models;
|
||||
let ac: WorkspaceAccessController;
|
||||
let policy: WorkspacePolicyService;
|
||||
let user: User;
|
||||
let ws: Workspace;
|
||||
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({ imports: [PermissionModule] });
|
||||
models = module.get<Models>(Models);
|
||||
ac = new WorkspaceAccessController(models);
|
||||
ac = module.get(WorkspaceAccessController);
|
||||
policy = module.get(WorkspacePolicyService);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await module.initTestingDB();
|
||||
user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
user = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
ws = await models.workspace.create(user.id);
|
||||
});
|
||||
|
||||
@@ -44,7 +49,7 @@ test('should get null role', async t => {
|
||||
});
|
||||
|
||||
test('should return null if role is not accepted', async t => {
|
||||
const u2 = await models.user.create({ email: 'u2@affine.pro' });
|
||||
const u2 = await models.user.create({ email: `${randomUUID()}@affine.pro` });
|
||||
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
@@ -183,3 +188,38 @@ test('should assert action', async t => {
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('should apply readonly workspace restrictions while keeping cleanup actions', async t => {
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const member = await models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await models.workspaceUser.set(
|
||||
ws.id,
|
||||
member.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
}
|
||||
await policy.reconcileWorkspaceQuotaState(ws.id);
|
||||
|
||||
const { permissions } = await ac.role({
|
||||
workspaceId: ws.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
t.false(permissions['Workspace.CreateDoc']);
|
||||
t.false(permissions['Workspace.Settings.Update']);
|
||||
t.false(permissions['Workspace.Properties.Create']);
|
||||
t.false(permissions['Workspace.Properties.Update']);
|
||||
t.false(permissions['Workspace.Properties.Delete']);
|
||||
t.false(permissions['Workspace.Blobs.Write']);
|
||||
t.true(permissions['Workspace.Read']);
|
||||
t.true(permissions['Workspace.Sync']);
|
||||
t.true(permissions['Workspace.Users.Manage']);
|
||||
t.true(permissions['Workspace.Blobs.List']);
|
||||
t.true(permissions['Workspace.TransferOwner']);
|
||||
t.true(permissions['Workspace.Payment.Manage']);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DocActionDenied } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { AccessController, getAccessController } from './controller';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import type { Resource } from './resource';
|
||||
import {
|
||||
DocAction,
|
||||
@@ -15,13 +16,19 @@ import { WorkspaceAccessController } from './workspace';
|
||||
@Injectable()
|
||||
export class DocAccessController extends AccessController<'doc'> {
|
||||
protected readonly type = 'doc';
|
||||
constructor(private readonly models: Models) {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async role(resource: Resource<'doc'>) {
|
||||
const role = await this.getRole(resource);
|
||||
const permissions = mapDocRoleToPermissions(role);
|
||||
const permissions = await this.policy.applyDocPermissions(
|
||||
resource.workspaceId,
|
||||
mapDocRoleToPermissions(role)
|
||||
);
|
||||
const sharingAllowed = await this.models.workspace.allowSharing(
|
||||
resource.workspaceId
|
||||
);
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QuotaServiceModule } from '../quota/service.module';
|
||||
import { AccessControllerBuilder } from './builder';
|
||||
import { DocAccessController } from './doc';
|
||||
import { EventsListener } from './event';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import { WorkspaceAccessController } from './workspace';
|
||||
|
||||
@Module({
|
||||
imports: [QuotaServiceModule],
|
||||
providers: [
|
||||
WorkspaceAccessController,
|
||||
DocAccessController,
|
||||
AccessControllerBuilder,
|
||||
EventsListener,
|
||||
WorkspacePolicyService,
|
||||
],
|
||||
exports: [AccessControllerBuilder],
|
||||
exports: [AccessControllerBuilder, WorkspacePolicyService],
|
||||
})
|
||||
export class PermissionModule {}
|
||||
|
||||
export { AccessControllerBuilder as AccessController } from './builder';
|
||||
export { WorkspacePolicyService } from './policy';
|
||||
export {
|
||||
DOC_ACTIONS,
|
||||
type DocAction,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
|
||||
import { DocActionDenied, OnEvent, SpaceAccessDenied } from '../../base';
|
||||
import { Models, WorkspaceRole } from '../../models';
|
||||
import { QuotaService } from '../quota/service';
|
||||
import { getAccessController } from './controller';
|
||||
import type { Resource } from './resource';
|
||||
import {
|
||||
type DocAction,
|
||||
type DocActionPermissions,
|
||||
mapWorkspaceRoleToPermissions,
|
||||
type WorkspaceAction,
|
||||
type WorkspaceActionPermissions,
|
||||
} from './types';
|
||||
|
||||
export type WorkspaceReadonlyReason = 'member_overflow' | 'storage_overflow';
|
||||
type WorkspaceQuotaSnapshot = Awaited<
|
||||
ReturnType<QuotaService['getWorkspaceQuotaWithUsage']>
|
||||
> & {
|
||||
ownerQuota?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
isTeamWorkspace: boolean;
|
||||
isReadonly: boolean;
|
||||
readonlyReasons: WorkspaceReadonlyReason[];
|
||||
canRecoverByRemovingMembers: boolean;
|
||||
canRecoverByDeletingBlobs: boolean;
|
||||
usesFallbackOwnerQuota: boolean;
|
||||
};
|
||||
|
||||
const READONLY_WORKSPACE_ACTIONS: WorkspaceAction[] = [
|
||||
'Workspace.CreateDoc',
|
||||
'Workspace.Settings.Update',
|
||||
'Workspace.Properties.Create',
|
||||
'Workspace.Properties.Update',
|
||||
'Workspace.Properties.Delete',
|
||||
'Workspace.Blobs.Write',
|
||||
];
|
||||
|
||||
const READONLY_DOC_ACTIONS: DocAction[] = [
|
||||
'Doc.Update',
|
||||
'Doc.Duplicate',
|
||||
'Doc.Publish',
|
||||
'Doc.Comments.Create',
|
||||
'Doc.Comments.Update',
|
||||
'Doc.Comments.Resolve',
|
||||
];
|
||||
|
||||
const READONLY_WORKSPACE_FEATURE =
|
||||
'quota_exceeded_readonly_workspace_v1' as const;
|
||||
|
||||
type WorkspaceRoleChecker = {
|
||||
getRole(resource: Resource<'ws'>): Promise<WorkspaceRole | null>;
|
||||
docRoles(
|
||||
resource: Resource<'ws'>,
|
||||
docIds: string[]
|
||||
): Promise<Array<{ role: unknown; permissions: Record<DocAction, boolean> }>>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'workspace.blobs.updated': {
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspacePolicyService {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly quota: QuotaService
|
||||
) {}
|
||||
|
||||
async getWorkspaceState(workspaceId: string): Promise<WorkspaceState> {
|
||||
const [isTeamWorkspace, isUnlimitedWorkspace, quota] = await Promise.all([
|
||||
this.models.workspace.isTeamWorkspace(workspaceId),
|
||||
this.models.workspaceFeature.has(workspaceId, 'unlimited_workspace'),
|
||||
this.quota.getWorkspaceQuotaWithUsage(workspaceId),
|
||||
]);
|
||||
const quotaSnapshot = quota as WorkspaceQuotaSnapshot;
|
||||
|
||||
const readonlyReasons: WorkspaceReadonlyReason[] = [];
|
||||
const usesFallbackOwnerQuota =
|
||||
!!quotaSnapshot.ownerQuota && !isUnlimitedWorkspace;
|
||||
|
||||
if (usesFallbackOwnerQuota && quotaSnapshot.overcapacityMemberCount > 0) {
|
||||
readonlyReasons.push('member_overflow');
|
||||
}
|
||||
|
||||
if (
|
||||
usesFallbackOwnerQuota &&
|
||||
quotaSnapshot.usedStorageQuota > quotaSnapshot.storageQuota
|
||||
) {
|
||||
readonlyReasons.push('storage_overflow');
|
||||
}
|
||||
|
||||
return {
|
||||
isTeamWorkspace,
|
||||
isReadonly: readonlyReasons.length > 0,
|
||||
readonlyReasons,
|
||||
canRecoverByRemovingMembers: readonlyReasons.includes('member_overflow'),
|
||||
canRecoverByDeletingBlobs: readonlyReasons.includes('storage_overflow'),
|
||||
usesFallbackOwnerQuota,
|
||||
};
|
||||
}
|
||||
|
||||
async reconcileOwnedWorkspaces(userId: string) {
|
||||
const workspaces = await this.models.workspaceUser.getUserActiveRoles(
|
||||
userId,
|
||||
{ role: WorkspaceRole.Owner }
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
workspaces.map(({ workspaceId }) =>
|
||||
this.reconcileWorkspaceQuotaState(workspaceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async reconcileWorkspaceQuotaState(workspaceId: string) {
|
||||
const [state, isReadonlyFeatureEnabled] = await Promise.all([
|
||||
this.getWorkspaceState(workspaceId),
|
||||
this.models.workspaceFeature.has(workspaceId, READONLY_WORKSPACE_FEATURE),
|
||||
]);
|
||||
|
||||
if (state.isReadonly && !isReadonlyFeatureEnabled) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
READONLY_WORKSPACE_FEATURE,
|
||||
`workspace recovery mode: ${state.readonlyReasons.join(',')}`
|
||||
);
|
||||
} else if (!state.isReadonly && isReadonlyFeatureEnabled) {
|
||||
await this.models.workspaceFeature.remove(
|
||||
workspaceId,
|
||||
READONLY_WORKSPACE_FEATURE
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async isWorkspaceReadonly(workspaceId: string) {
|
||||
const hasReadonlyFeature = await this.models.workspaceFeature.has(
|
||||
workspaceId,
|
||||
READONLY_WORKSPACE_FEATURE
|
||||
);
|
||||
|
||||
if (!hasReadonlyFeature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = await this.getWorkspaceState(workspaceId);
|
||||
if (!state.isReadonly) {
|
||||
await this.models.workspaceFeature.remove(
|
||||
workspaceId,
|
||||
READONLY_WORKSPACE_FEATURE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async applyWorkspacePermissions(
|
||||
workspaceId: string,
|
||||
permissions: WorkspaceActionPermissions
|
||||
) {
|
||||
if (!(await this.isWorkspaceReadonly(workspaceId))) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const next = { ...permissions };
|
||||
READONLY_WORKSPACE_ACTIONS.forEach(action => {
|
||||
next[action] = false;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async applyDocPermissions(
|
||||
workspaceId: string,
|
||||
permissions: DocActionPermissions
|
||||
) {
|
||||
if (!(await this.isWorkspaceReadonly(workspaceId))) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const next = { ...permissions };
|
||||
READONLY_DOC_ACTIONS.forEach(action => {
|
||||
next[action] = false;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async assertWorkspaceActionAllowed(
|
||||
workspaceId: string,
|
||||
action: WorkspaceAction
|
||||
) {
|
||||
if (
|
||||
READONLY_WORKSPACE_ACTIONS.includes(action) &&
|
||||
(await this.isWorkspaceReadonly(workspaceId))
|
||||
) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async assertDocActionAllowed(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
action: DocAction
|
||||
) {
|
||||
if (
|
||||
READONLY_DOC_ACTIONS.includes(action) &&
|
||||
(await this.isWorkspaceReadonly(workspaceId))
|
||||
) {
|
||||
throw new DocActionDenied({
|
||||
action,
|
||||
docId,
|
||||
spaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertWorkspaceRoleAction(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
action: WorkspaceAction
|
||||
) {
|
||||
const checker = getAccessController(
|
||||
'ws'
|
||||
) as unknown as WorkspaceRoleChecker;
|
||||
const role = await checker.getRole({ userId, workspaceId });
|
||||
const permissions = mapWorkspaceRoleToPermissions(role);
|
||||
|
||||
if (!permissions[action]) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async assertDocRoleAction(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
action: DocAction
|
||||
) {
|
||||
const checker = getAccessController(
|
||||
'ws'
|
||||
) as unknown as WorkspaceRoleChecker;
|
||||
const [role] = await checker.docRoles({ userId, workspaceId }, [docId]);
|
||||
|
||||
if (!role?.permissions[action]) {
|
||||
throw new DocActionDenied({
|
||||
action,
|
||||
docId,
|
||||
spaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanUploadBlob(userId: string, workspaceId: string) {
|
||||
await this.assertWorkspaceRoleAction(
|
||||
userId,
|
||||
workspaceId,
|
||||
'Workspace.Blobs.Write'
|
||||
);
|
||||
await this.assertWorkspaceActionAllowed(
|
||||
workspaceId,
|
||||
'Workspace.Blobs.Write'
|
||||
);
|
||||
}
|
||||
|
||||
async assertCanDeleteBlob(userId: string, workspaceId: string) {
|
||||
await this.assertWorkspaceRoleAction(
|
||||
userId,
|
||||
workspaceId,
|
||||
'Workspace.Blobs.Write'
|
||||
);
|
||||
}
|
||||
|
||||
async assertCanInviteMembers(workspaceId: string) {
|
||||
if (await this.isWorkspaceReadonly(workspaceId)) {
|
||||
throw new SpaceAccessDenied({ spaceId: workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanRevokeMember(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
role: WorkspaceRole
|
||||
) {
|
||||
await this.assertWorkspaceRoleAction(
|
||||
userId,
|
||||
workspaceId,
|
||||
role === WorkspaceRole.Admin
|
||||
? 'Workspace.Administrators.Manage'
|
||||
: 'Workspace.Users.Manage'
|
||||
);
|
||||
}
|
||||
|
||||
async assertCanPublishDoc(workspaceId: string, docId: string) {
|
||||
await this.assertDocActionAllowed(workspaceId, docId, 'Doc.Publish');
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async handleTeamPlanCanceled(workspaceId: string) {
|
||||
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
|
||||
await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId);
|
||||
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
|
||||
return await this.reconcileWorkspaceQuotaState(workspaceId);
|
||||
}
|
||||
|
||||
async assertCanUnpublishDoc(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
) {
|
||||
await this.assertDocRoleAction(userId, workspaceId, docId, 'Doc.Publish');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated')
|
||||
async onWorkspaceMembersUpdated({
|
||||
workspaceId,
|
||||
}: Events['workspace.members.updated']) {
|
||||
await this.reconcileWorkspaceQuotaState(workspaceId);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.owner.changed')
|
||||
async onWorkspaceOwnerChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.owner.changed']) {
|
||||
await this.reconcileWorkspaceQuotaState(workspaceId);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.blobs.updated')
|
||||
async onWorkspaceBlobsUpdated({
|
||||
workspaceId,
|
||||
}: Events['workspace.blobs.updated']) {
|
||||
await this.reconcileWorkspaceQuotaState(workspaceId);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { SpaceAccessDenied } from '../../base';
|
||||
import { DocRole, Models } from '../../models';
|
||||
import { AccessController } from './controller';
|
||||
import { WorkspacePolicyService } from './policy';
|
||||
import type { Resource } from './resource';
|
||||
import {
|
||||
fixupDocRole,
|
||||
@@ -17,7 +18,10 @@ import {
|
||||
export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
protected readonly type = 'ws';
|
||||
|
||||
constructor(private readonly models: Models) {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -37,7 +41,10 @@ export class WorkspaceAccessController extends AccessController<'ws'> {
|
||||
|
||||
return {
|
||||
role,
|
||||
permissions: mapWorkspaceRoleToPermissions(role),
|
||||
permissions: await this.policy.applyWorkspacePermissions(
|
||||
resource.workspaceId,
|
||||
mapWorkspaceRoleToPermissions(role)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PermissionModule } from '../permission';
|
||||
import { StorageModule } from '../storage';
|
||||
import { QuotaResolver } from './resolver';
|
||||
import { QuotaService } from './service';
|
||||
import { QuotaServiceModule } from './service.module';
|
||||
|
||||
/**
|
||||
* Quota module provider pre-user quota management.
|
||||
@@ -12,11 +11,12 @@ import { QuotaService } from './service';
|
||||
* - quota statistics
|
||||
*/
|
||||
@Module({
|
||||
imports: [StorageModule, PermissionModule],
|
||||
providers: [QuotaService, QuotaResolver],
|
||||
exports: [QuotaService],
|
||||
imports: [QuotaServiceModule],
|
||||
providers: [QuotaResolver],
|
||||
exports: [QuotaServiceModule],
|
||||
})
|
||||
export class QuotaModule {}
|
||||
|
||||
export { QuotaService };
|
||||
export { QuotaServiceModule };
|
||||
export { WorkspaceQuotaHumanReadableType, WorkspaceQuotaType } from './types';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { StorageModule } from '../storage';
|
||||
import { QuotaService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
providers: [QuotaService],
|
||||
exports: [QuotaService],
|
||||
})
|
||||
export class QuotaServiceModule {}
|
||||
@@ -20,7 +20,10 @@ type UserQuotaWithUsage = Omit<UserQuotaType, 'humanReadable'>;
|
||||
type WorkspaceQuota = Omit<BaseWorkspaceQuota, 'seatQuota'> & {
|
||||
ownerQuota?: string;
|
||||
};
|
||||
type WorkspaceQuotaWithUsage = Omit<WorkspaceQuotaType, 'humanReadable'>;
|
||||
export type WorkspaceQuotaWithUsage = Omit<
|
||||
WorkspaceQuotaType,
|
||||
'humanReadable'
|
||||
> & { ownerQuota?: string };
|
||||
|
||||
@Injectable()
|
||||
export class QuotaService {
|
||||
|
||||
@@ -26,6 +26,9 @@ declare global {
|
||||
workspaceId: string;
|
||||
key: string;
|
||||
};
|
||||
'workspace.blobs.updated': {
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +258,9 @@ export class WorkspaceBlobStorage {
|
||||
await this.provider.delete(`${workspaceId}/${key}`);
|
||||
}
|
||||
await this.models.blob.delete(workspaceId, key, permanently);
|
||||
if (!permanently) {
|
||||
await this.event.emitAsync('workspace.blobs.updated', { workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
async release(workspaceId: string) {
|
||||
@@ -270,6 +276,8 @@ export class WorkspaceBlobStorage {
|
||||
this.logger.log(
|
||||
`released ${deletedBlobs.length} blobs for workspace ${workspaceId}`
|
||||
);
|
||||
|
||||
await this.event.emitAsync('workspace.blobs.updated', { workspaceId });
|
||||
}
|
||||
|
||||
async totalSize(workspaceId: string) {
|
||||
|
||||
@@ -624,6 +624,7 @@ export class SpaceSyncGateway
|
||||
const { spaceType, spaceId, docId, update } = message;
|
||||
const adapter = this.selectAdapter(client, spaceType);
|
||||
|
||||
// Quota recovery mode is intentionally not applied to sync in this phase.
|
||||
// TODO(@forehalo): enable after frontend supporting doc revert
|
||||
// await this.ac.user(user.id).doc(spaceId, docId).assert('Doc.Update');
|
||||
const timestamp = await adapter.push(
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { AccessController } from '../../permission';
|
||||
import { AccessController, WorkspacePolicyService } from '../../permission';
|
||||
import { QuotaService } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import {
|
||||
@@ -126,6 +126,7 @@ export class WorkspaceBlobResolver {
|
||||
logger = new Logger(WorkspaceBlobResolver.name);
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly storage: WorkspaceBlobStorage,
|
||||
private readonly models: Models
|
||||
@@ -466,10 +467,7 @@ export class WorkspaceBlobResolver {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Blobs.Write');
|
||||
await this.policy.assertCanDeleteBlob(user.id, workspaceId);
|
||||
|
||||
await this.storage.delete(workspaceId, key, permanently);
|
||||
|
||||
@@ -481,10 +479,7 @@ export class WorkspaceBlobResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Blobs.Write');
|
||||
await this.policy.assertCanDeleteBlob(user.id, workspaceId);
|
||||
|
||||
await this.storage.release(workspaceId);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
DOC_ACTIONS,
|
||||
DocAction,
|
||||
DocRole,
|
||||
WorkspacePolicyService,
|
||||
} from '../../permission';
|
||||
import { PublicUserType, WorkspaceUserType } from '../../user';
|
||||
import { WorkspaceType } from '../types';
|
||||
@@ -295,6 +296,7 @@ export class WorkspaceDocResolver {
|
||||
*/
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly ac: AccessController,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly models: Models,
|
||||
private readonly cache: Cache
|
||||
) {}
|
||||
@@ -437,7 +439,7 @@ export class WorkspaceDocResolver {
|
||||
throw new ExpectToRevokePublicDoc('Expect doc not to be workspace');
|
||||
}
|
||||
|
||||
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||
await this.policy.assertCanUnpublishDoc(user.id, workspaceId, docId);
|
||||
|
||||
const doc = await this.models.doc.unpublish(workspaceId, docId);
|
||||
|
||||
|
||||
@@ -36,7 +36,11 @@ import {
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
import { AccessController, WorkspaceRole } from '../../permission';
|
||||
import {
|
||||
AccessController,
|
||||
WorkspacePolicyService,
|
||||
WorkspaceRole,
|
||||
} from '../../permission';
|
||||
import { QuotaService } from '../../quota';
|
||||
import { UserType } from '../../user';
|
||||
import { validators } from '../../utils/validators';
|
||||
@@ -64,6 +68,7 @@ export class WorkspaceMemberResolver {
|
||||
private readonly ac: AccessController,
|
||||
private readonly models: Models,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly quota: QuotaService
|
||||
) {}
|
||||
@@ -304,10 +309,11 @@ export class WorkspaceMemberResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
await this.policy.assertWorkspaceRoleAction(
|
||||
user.id,
|
||||
workspaceId,
|
||||
'Workspace.Users.Manage'
|
||||
);
|
||||
|
||||
const cacheId = `workspace:inviteLink:${workspaceId}`;
|
||||
return await this.cache.delete(cacheId);
|
||||
@@ -359,6 +365,7 @@ export class WorkspaceMemberResolver {
|
||||
role.id,
|
||||
me.id
|
||||
);
|
||||
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
@@ -453,14 +460,7 @@ export class WorkspaceMemberResolver {
|
||||
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
|
||||
}
|
||||
|
||||
await this.ac
|
||||
.user(me.id)
|
||||
.workspace(workspaceId)
|
||||
.assert(
|
||||
role.type === WorkspaceRole.Admin
|
||||
? 'Workspace.Administrators.Manage'
|
||||
: 'Workspace.Users.Manage'
|
||||
);
|
||||
await this.policy.assertCanRevokeMember(me.id, workspaceId, role.type);
|
||||
|
||||
await this.models.workspaceUser.delete(workspaceId, userId);
|
||||
|
||||
@@ -480,6 +480,7 @@ export class WorkspaceMemberResolver {
|
||||
this.event.emit('workspace.members.updated', {
|
||||
workspaceId,
|
||||
});
|
||||
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -580,11 +581,20 @@ export class WorkspaceMemberResolver {
|
||||
this.event.emit('workspace.members.updated', {
|
||||
workspaceId,
|
||||
});
|
||||
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async acceptInvitationByEmail(role: WorkspaceUserRole) {
|
||||
await this.policy.assertCanInviteMembers(role.workspaceId);
|
||||
|
||||
const hasSeat = await this.quota.tryCheckSeat(role.workspaceId, true);
|
||||
|
||||
if (!hasSeat) {
|
||||
throw new NoMoreSeat({ spaceId: role.workspaceId });
|
||||
}
|
||||
|
||||
await this.models.workspaceUser.setStatus(
|
||||
role.workspaceId,
|
||||
role.userId,
|
||||
@@ -596,6 +606,7 @@ export class WorkspaceMemberResolver {
|
||||
(await this.models.workspaceUser.getOwner(role.workspaceId)).id,
|
||||
role.id
|
||||
);
|
||||
await this.policy.reconcileWorkspaceQuotaState(role.workspaceId);
|
||||
}
|
||||
|
||||
private async acceptInvitationByLink(
|
||||
@@ -603,6 +614,8 @@ export class WorkspaceMemberResolver {
|
||||
workspaceId: string,
|
||||
inviterId: string
|
||||
) {
|
||||
await this.policy.assertCanInviteMembers(workspaceId);
|
||||
|
||||
let inviter = await this.models.user.getPublicUser(inviterId);
|
||||
if (!inviter) {
|
||||
inviter = await this.models.workspaceUser.getOwner(workspaceId);
|
||||
|
||||
Reference in New Issue
Block a user