feat(server): improve subscription sync stability (#14703)

This commit is contained in:
DarkSky
2026-03-23 21:13:00 +08:00
committed by GitHub
parent dcf041a3f2
commit 5d124ee55b
31 changed files with 1538 additions and 66 deletions
@@ -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);