From ad6470db8277e2ede57008a0e7011b30dff155f5 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Sun, 22 Mar 2026 22:09:15 +0800 Subject: [PATCH] feat: improve downgrade check --- .../__tests__/e2e/workspace/member.spec.ts | 122 +++++++ .../src/__tests__/e2e/workspace/team.spec.ts | 123 +++++++ .../__tests__/models/workspace-user.spec.ts | 22 ++ .../src/__tests__/oauth/controller.spec.ts | 95 ++++- .../src/core/permission/__tests__/doc.spec.ts | 45 ++- .../core/permission/__tests__/policy.spec.ts | 198 +++++++++++ .../permission/__tests__/workspace.spec.ts | 46 ++- .../backend/server/src/core/permission/doc.ts | 11 +- .../server/src/core/permission/index.ts | 9 +- .../server/src/core/permission/policy.ts | 328 ++++++++++++++++++ .../server/src/core/permission/workspace.ts | 11 +- .../backend/server/src/core/quota/index.ts | 3 +- .../backend/server/src/core/quota/service.ts | 5 +- .../server/src/core/storage/wrappers/blob.ts | 8 + .../backend/server/src/core/sync/gateway.ts | 1 + .../src/core/workspaces/resolvers/blob.ts | 13 +- .../src/core/workspaces/resolvers/doc.ts | 4 +- .../src/core/workspaces/resolvers/member.ts | 33 +- .../server/src/models/common/feature.ts | 7 +- .../server/src/models/workspace-user.ts | 24 +- .../src/plugins/copilot/context/resolver.ts | 7 +- .../server/src/plugins/copilot/resolver.ts | 11 +- .../src/plugins/copilot/workspace/resolver.ts | 15 +- .../server/src/plugins/license/service.ts | 7 +- .../src/plugins/oauth/providers/github.ts | 40 ++- .../server/src/plugins/payment/event.ts | 10 +- 26 files changed, 1141 insertions(+), 57 deletions(-) create mode 100644 packages/backend/server/src/core/permission/__tests__/policy.spec.ts create mode 100644 packages/backend/server/src/core/permission/policy.ts diff --git a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts index 02314bd943..2b35ad004d 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts @@ -2,10 +2,12 @@ import { acceptInviteByInviteIdMutation, approveWorkspaceTeamMemberMutation, createInviteLinkMutation, + deleteBlobMutation, getInviteInfoQuery, getMembersByWorkspaceIdQuery, inviteByEmailsMutation, leaveWorkspaceMutation, + releaseDeletedBlobsMutation, revokeMemberPermissionMutation, WorkspaceInviteLinkExpireTime, WorkspaceMemberStatus, @@ -13,6 +15,11 @@ import { import { faker } from '@faker-js/faker'; import { Models } from '../../../models'; +import { FeatureConfigs } from '../../../models/common/feature'; +import { + SubscriptionPlan, + SubscriptionRecurring, +} from '../../../plugins/payment/types'; import { Mockers } from '../../mocks'; import { app, e2e } from '../test'; @@ -135,6 +142,121 @@ e2e('should re-check seat when accepting an email invitation', async t => { t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending); }); +e2e.serial( + 'should block accepting pending invitations in readonly mode and recover after blob cleanup', + async t => { + const { owner, workspace } = await createWorkspace(); + const member = await app.create(Mockers.User); + const freeStorageQuota = FeatureConfigs.free_plan_v1.configs.storageQuota; + const lifetimeStorageQuota = + FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota; + + FeatureConfigs.free_plan_v1.configs.storageQuota = 1; + FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota = 2; + t.teardown(() => { + FeatureConfigs.free_plan_v1.configs.storageQuota = freeStorageQuota; + FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota = + lifetimeStorageQuota; + }); + + await app.models.userFeature.switchQuota( + owner.id, + 'lifetime_pro_plan_v1', + 'test setup' + ); + + await app.login(owner); + const invite = await app.gql({ + query: inviteByEmailsMutation, + variables: { + emails: [member.email], + workspaceId: workspace.id, + }, + }); + + await app.models.blob.upsert({ + workspaceId: workspace.id, + key: 'overflow-blob', + mime: 'application/octet-stream', + size: 2, + status: 'completed', + uploadId: null, + }); + + await app.eventBus.emitAsync('user.subscription.canceled', { + userId: owner.id, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Lifetime, + }); + + t.true( + await app.models.workspaceFeature.has( + workspace.id, + 'quota_exceeded_readonly_workspace_v1' + ) + ); + + await app.login(member); + await t.throwsAsync( + app.gql({ + query: acceptInviteByInviteIdMutation, + variables: { + workspaceId: workspace.id, + inviteId: invite.inviteMembers[0].inviteId!, + }, + }) + ); + + const { getInviteInfo: pendingInvite } = await app.gql({ + query: getInviteInfoQuery, + variables: { + inviteId: invite.inviteMembers[0].inviteId!, + }, + }); + t.is(pendingInvite.status, WorkspaceMemberStatus.Pending); + + await app.login(owner); + await app.gql({ + query: deleteBlobMutation, + variables: { + workspaceId: workspace.id, + key: 'overflow-blob', + permanently: false, + }, + }); + await app.gql({ + query: releaseDeletedBlobsMutation, + variables: { + workspaceId: workspace.id, + }, + }); + + t.false( + await app.models.workspaceFeature.has( + workspace.id, + 'quota_exceeded_readonly_workspace_v1' + ) + ); + + await app.login(member); + await app.gql({ + query: acceptInviteByInviteIdMutation, + variables: { + workspaceId: workspace.id, + inviteId: invite.inviteMembers[0].inviteId!, + }, + }); + + const { getInviteInfo: acceptedInvite } = await app.gql({ + query: getInviteInfoQuery, + variables: { + inviteId: invite.inviteMembers[0].inviteId!, + }, + }); + t.is(acceptedInvite.status, WorkspaceMemberStatus.Accepted); + } +); + e2e('should leave a workspace', async t => { const { owner, workspace } = await createWorkspace(); const u2 = await app.create(Mockers.User); diff --git a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts index 8cf6945a97..37ee910f8d 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts @@ -1,9 +1,13 @@ import { getInviteInfoQuery, inviteByEmailsMutation, + publishPageMutation, + revokeMemberPermissionMutation, + revokePublicPageMutation, WorkspaceMemberStatus, } from '@affine/graphql'; +import { QuotaService } from '../../../core/quota/service'; import { WorkspaceRole } from '../../../models'; import { SubscriptionPlan, @@ -58,6 +62,42 @@ const getInvitationInfo = async (inviteId: string) => { return result.getInviteInfo; }; +const publishDoc = async (workspaceId: string, docId: string) => { + const { publishDoc } = await app.gql({ + query: publishPageMutation, + variables: { + workspaceId, + pageId: docId, + }, + }); + + return publishDoc; +}; + +const revokePublicDoc = async (workspaceId: string, docId: string) => { + const { revokePublicDoc } = await app.gql({ + query: revokePublicPageMutation, + variables: { + workspaceId, + pageId: docId, + }, + }); + + return revokePublicDoc; +}; + +const revokeMember = async (workspaceId: string, userId: string) => { + const { revokeMember } = await app.gql({ + query: revokeMemberPermissionMutation, + variables: { + workspaceId, + userId, + }, + }); + + return revokeMember; +}; + e2e('should set new invited users to AllocatingSeat', async t => { const { owner, workspace } = await createTeamWorkspace(); await app.login(owner); @@ -219,3 +259,86 @@ e2e( t.false(await app.models.workspace.isTeamWorkspace(workspace.id)); } ); + +e2e( + 'should demote accepted admins and keep workspace writable when downgrade stays within owner quota', + async t => { + const { workspace, owner, admin } = await createTeamWorkspace(); + + await app.eventBus.emitAsync('workspace.subscription.canceled', { + workspaceId: workspace.id, + plan: SubscriptionPlan.Team, + recurring: SubscriptionRecurring.Monthly, + }); + + t.false(await app.models.workspace.isTeamWorkspace(workspace.id)); + t.false( + await app.models.workspaceFeature.has( + workspace.id, + 'quota_exceeded_readonly_workspace_v1' + ) + ); + t.is( + (await app.models.workspaceUser.get(workspace.id, admin.id))?.type, + WorkspaceRole.Collaborator + ); + + await app.login(owner); + await t.notThrowsAsync(publishDoc(workspace.id, 'doc-1')); + } +); + +e2e( + 'should enter readonly mode on over-quota team downgrade and recover through cleanup actions', + async t => { + const { workspace, owner, admin } = await createTeamWorkspace(20); + const extraMembers = await Promise.all( + Array.from({ length: 8 }).map(async () => { + const member = await app.create(Mockers.User); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: member.id, + }); + return member; + }) + ); + + await app.login(owner); + await publishDoc(workspace.id, 'published-doc'); + + await app.eventBus.emitAsync('workspace.subscription.canceled', { + workspaceId: workspace.id, + plan: SubscriptionPlan.Team, + recurring: SubscriptionRecurring.Monthly, + }); + + t.false(await app.models.workspace.isTeamWorkspace(workspace.id)); + t.true( + await app.models.workspaceFeature.has( + workspace.id, + 'quota_exceeded_readonly_workspace_v1' + ) + ); + t.is( + (await app.models.workspaceUser.get(workspace.id, admin.id))?.type, + WorkspaceRole.Collaborator + ); + + await t.throwsAsync(publishDoc(workspace.id, 'blocked-doc')); + await t.notThrowsAsync(revokePublicDoc(workspace.id, 'published-doc')); + + const quota = await app + .get(QuotaService) + .getWorkspaceQuotaWithUsage(workspace.id); + for (const member of extraMembers.slice(0, quota.overcapacityMemberCount)) { + await revokeMember(workspace.id, member.id); + } + + t.false( + await app.models.workspaceFeature.has( + workspace.id, + 'quota_exceeded_readonly_workspace_v1' + ) + ); + } +); diff --git a/packages/backend/server/src/__tests__/models/workspace-user.spec.ts b/packages/backend/server/src/__tests__/models/workspace-user.spec.ts index 6b4971134a..4c3a3a36c3 100644 --- a/packages/backend/server/src/__tests__/models/workspace-user.spec.ts +++ b/packages/backend/server/src/__tests__/models/workspace-user.spec.ts @@ -65,6 +65,28 @@ test('should transfer workespace owner', async t => { const owner2 = await models.workspaceUser.getOwner(workspace.id); t.is(owner2.id, user2.id); + const oldOwnerRole = await models.workspaceUser.get(workspace.id, user.id); + t.is(oldOwnerRole?.type, WorkspaceRole.Collaborator); +}); + +test('should keep old owner as admin when transferring a team workspace', async t => { + const [user, user2] = await module.create(Mockers.User, 2); + const workspace = await module.create(Mockers.Workspace, { + owner: { id: user.id }, + }); + await module.create(Mockers.TeamWorkspace, { + id: workspace.id, + quantity: 10, + }); + await module.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: user2.id, + }); + + await models.workspaceUser.setOwner(workspace.id, user2.id); + + const oldOwnerRole = await models.workspaceUser.get(workspace.id, user.id); + t.is(oldOwnerRole?.type, WorkspaceRole.Admin); }); test('should throw if transfer owner to non-active member', async t => { diff --git a/packages/backend/server/src/__tests__/oauth/controller.spec.ts b/packages/backend/server/src/__tests__/oauth/controller.spec.ts index 1e89e0fc3e..bbc573389a 100644 --- a/packages/backend/server/src/__tests__/oauth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/oauth/controller.spec.ts @@ -14,6 +14,7 @@ import { ServerFeature } from '../../core/config/types'; import { Models } from '../../models'; import { OAuthProviderName } from '../../plugins/oauth/config'; import { OAuthProviderFactory } from '../../plugins/oauth/factory'; +import { GithubOAuthProvider } from '../../plugins/oauth/providers/github'; import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google'; import { OIDCProvider } from '../../plugins/oauth/providers/oidc'; import { OAuthService } from '../../plugins/oauth/service'; @@ -38,6 +39,10 @@ test.before(async t => { clientId: 'google-client-id', clientSecret: 'google-client-secret', }, + github: { + clientId: 'github-client-id', + clientSecret: 'github-client-secret', + }, oidc: { clientId: '', clientSecret: '', @@ -293,7 +298,7 @@ test('should be able to get registered oauth providers', async t => { const providers = oauth.availableOAuthProviders(); - t.deepEqual(providers, [OAuthProviderName.Google]); + t.deepEqual(providers, [OAuthProviderName.Google, OAuthProviderName.GitHub]); }); test('should throw if code is missing in callback uri', async t => { @@ -441,6 +446,24 @@ function mockOAuthProvider( return clientNonce; } +function mockGithubOAuthProvider( + app: TestingApp, + clientNonce: string = randomUUID() +) { + const provider = app.get(GithubOAuthProvider); + const oauth = app.get(OAuthService); + + Sinon.stub(oauth, 'isValidState').resolves(true); + Sinon.stub(oauth, 'getOAuthState').resolves({ + provider: OAuthProviderName.GitHub, + clientNonce, + }); + + Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' }); + + return { provider, clientNonce }; +} + function mockOidcProvider( provider: OIDCProvider, { @@ -645,6 +668,76 @@ test('should be able to fullfil user with oauth sign in', async t => { t.is(account!.user.id, u3.id); }); +test('github oauth should resolve private email from emails api', async t => { + const { app, db } = t.context; + + const email = 'github-private@affine.pro'; + const { clientNonce, provider } = mockGithubOAuthProvider(app); + const fetchJson = Sinon.stub(provider as any, 'fetchJson'); + + fetchJson.onFirstCall().resolves({ + login: 'github-user', + email: null, + avatar_url: 'avatar', + name: 'DarkSky', + }); + fetchJson.onSecondCall().resolves([ + { email: 'unverified@affine.pro', primary: true, verified: false }, + { email, primary: false, verified: true }, + ]); + + await app + .POST('/api/oauth/callback') + .send({ code: '1', state: '1', client_nonce: clientNonce }) + .expect(HttpStatus.OK); + + const sessionUser = await currentUser(app); + t.truthy(sessionUser); + t.is(sessionUser!.email, email); + + const user = await db.user.findFirst({ + select: { + email: true, + connectedAccounts: true, + }, + where: { + email, + }, + }); + + t.truthy(user); + t.is(user!.connectedAccounts[0].provider, OAuthProviderName.GitHub); + t.is(user!.connectedAccounts[0].providerAccountId, 'github-user'); +}); + +test('github oauth should reject responses without a verified email', async t => { + const { app } = t.context; + + const provider = app.get(GithubOAuthProvider); + const fetchJson = Sinon.stub(provider as any, 'fetchJson'); + + fetchJson.onFirstCall().resolves({ + login: 'github-user', + email: null, + avatar_url: 'avatar', + name: 'DarkSky', + }); + fetchJson + .onSecondCall() + .resolves([ + { email: 'private@affine.pro', primary: true, verified: false }, + ]); + + const error = await t.throwsAsync( + provider.getUser( + { accessToken: 'token' }, + { token: 'state', provider: OAuthProviderName.GitHub } + ) + ); + + t.true(error instanceof InvalidOauthResponse); +}); + test('oidc should accept email from id token when userinfo email is missing', async t => { const { app } = t.context; diff --git a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts index e3b710c5eb..21a592e3cb 100644 --- a/packages/backend/server/src/core/permission/__tests__/doc.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/doc.spec.ts @@ -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); 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']); +}); diff --git a/packages/backend/server/src/core/permission/__tests__/policy.spec.ts b/packages/backend/server/src/core/permission/__tests__/policy.spec.ts new file mode 100644 index 0000000000..5a52b6e93d --- /dev/null +++ b/packages/backend/server/src/core/permission/__tests__/policy.spec.ts @@ -0,0 +1,198 @@ +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 { PermissionModule } from '../index'; +import { WorkspacePolicyService } from '../policy'; + +interface Context { + module: TestingModule; + models: Models; + policy: WorkspacePolicyService; +} + +const test = ava as TestFn; + +const READONLY_FEATURE = 'quota_exceeded_readonly_workspace_v1' as const; +type WorkspaceQuotaSnapshot = Awaited< + ReturnType +> & { + 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 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 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)); +}); diff --git a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts index d359572d2a..002520d87d 100644 --- a/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/workspace.spec.ts @@ -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); - 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']); +}); diff --git a/packages/backend/server/src/core/permission/doc.ts b/packages/backend/server/src/core/permission/doc.ts index 75a3bd7abf..579cc613ab 100644 --- a/packages/backend/server/src/core/permission/doc.ts +++ b/packages/backend/server/src/core/permission/doc.ts @@ -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 ); diff --git a/packages/backend/server/src/core/permission/index.ts b/packages/backend/server/src/core/permission/index.ts index 35d61b7106..c1d1146b1a 100644 --- a/packages/backend/server/src/core/permission/index.ts +++ b/packages/backend/server/src/core/permission/index.ts @@ -1,22 +1,29 @@ import { Module } from '@nestjs/common'; +import { QuotaService } from '../quota/service'; +import { StorageModule } from '../storage'; import { AccessControllerBuilder } from './builder'; import { DocAccessController } from './doc'; import { EventsListener } from './event'; +import { WorkspacePolicyService } from './policy'; import { WorkspaceAccessController } from './workspace'; @Module({ + imports: [StorageModule], providers: [ + QuotaService, 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, diff --git a/packages/backend/server/src/core/permission/policy.ts b/packages/backend/server/src/core/permission/policy.ts new file mode 100644 index 0000000000..439cc02c01 --- /dev/null +++ b/packages/backend/server/src/core/permission/policy.ts @@ -0,0 +1,328 @@ +import { Injectable } from '@nestjs/common'; + +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 +> & { + 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; + docRoles( + resource: Resource<'ws'>, + docIds: string[] + ): Promise }>>; +}; + +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 { + 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(workspaceId: string) { + 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'); + } + + 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); + } +} diff --git a/packages/backend/server/src/core/permission/workspace.ts b/packages/backend/server/src/core/permission/workspace.ts index dbd523dc52..e0cf50b0ff 100644 --- a/packages/backend/server/src/core/permission/workspace.ts +++ b/packages/backend/server/src/core/permission/workspace.ts @@ -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) + ), }; } diff --git a/packages/backend/server/src/core/quota/index.ts b/packages/backend/server/src/core/quota/index.ts index 1fc7791f85..f8a64735fe 100644 --- a/packages/backend/server/src/core/quota/index.ts +++ b/packages/backend/server/src/core/quota/index.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common'; -import { PermissionModule } from '../permission'; import { StorageModule } from '../storage'; import { QuotaResolver } from './resolver'; import { QuotaService } from './service'; @@ -12,7 +11,7 @@ import { QuotaService } from './service'; * - quota statistics */ @Module({ - imports: [StorageModule, PermissionModule], + imports: [StorageModule], providers: [QuotaService, QuotaResolver], exports: [QuotaService], }) diff --git a/packages/backend/server/src/core/quota/service.ts b/packages/backend/server/src/core/quota/service.ts index e5e796122b..eff27ddc41 100644 --- a/packages/backend/server/src/core/quota/service.ts +++ b/packages/backend/server/src/core/quota/service.ts @@ -20,7 +20,10 @@ type UserQuotaWithUsage = Omit; type WorkspaceQuota = Omit & { ownerQuota?: string; }; -type WorkspaceQuotaWithUsage = Omit; +export type WorkspaceQuotaWithUsage = Omit< + WorkspaceQuotaType, + 'humanReadable' +> & { ownerQuota?: string }; @Injectable() export class QuotaService { diff --git a/packages/backend/server/src/core/storage/wrappers/blob.ts b/packages/backend/server/src/core/storage/wrappers/blob.ts index c528f754e8..1386c791d1 100644 --- a/packages/backend/server/src/core/storage/wrappers/blob.ts +++ b/packages/backend/server/src/core/storage/wrappers/blob.ts @@ -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) { diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index 7854e356fd..fec4cd5e2b 100644 --- a/packages/backend/server/src/core/sync/gateway.ts +++ b/packages/backend/server/src/core/sync/gateway.ts @@ -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( diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index 2c6e548709..b026a2cef4 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -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); diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 026e727652..e16b4e204e 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -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); diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index f6be467815..4d26083e8f 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/member.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -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,14 @@ 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) { @@ -602,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( @@ -609,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); diff --git a/packages/backend/server/src/models/common/feature.ts b/packages/backend/server/src/models/common/feature.ts index ff2d2cc2b0..ca22e21637 100644 --- a/packages/backend/server/src/models/common/feature.ts +++ b/packages/backend/server/src/models/common/feature.ts @@ -53,6 +53,7 @@ export enum Feature { // workspace UnlimitedWorkspace = 'unlimited_workspace', TeamPlan = 'team_plan_v1', + QuotaExceededReadonlyWorkspace = 'quota_exceeded_readonly_workspace_v1', } // TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`? @@ -66,6 +67,7 @@ export const FeaturesShapes = { pro_plan_v1: UserPlanQuotaConfig, lifetime_pro_plan_v1: UserPlanQuotaConfig, team_plan_v1: WorkspaceQuotaConfig, + quota_exceeded_readonly_workspace_v1: EMPTY_CONFIG, } satisfies Record>; export type UserFeatureName = keyof Pick< @@ -80,7 +82,9 @@ export type UserFeatureName = keyof Pick< >; export type WorkspaceFeatureName = keyof Pick< typeof FeaturesShapes, - 'unlimited_workspace' | 'team_plan_v1' + | 'unlimited_workspace' + | 'team_plan_v1' + | 'quota_exceeded_readonly_workspace_v1' >; export type FeatureName = UserFeatureName | WorkspaceFeatureName; @@ -162,6 +166,7 @@ export const FeatureConfigs: { team_plan_v1: TeamFeature, early_access: WhitelistFeature, unlimited_workspace: EmptyFeature, + quota_exceeded_readonly_workspace_v1: EmptyFeature, unlimited_copilot: EmptyFeature, ai_early_access: EmptyFeature, administrator: EmptyFeature, diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts index 023e57e7b9..b46632cfc0 100644 --- a/packages/backend/server/src/models/workspace-user.ts +++ b/packages/backend/server/src/models/workspace-user.ts @@ -36,7 +36,8 @@ export class WorkspaceUserModel extends BaseModel { /** * Set or update the [Owner] of a workspace. - * The old [Owner] will be changed to [Admin] if there is already an [Owner]. + * The old [Owner] will be changed to [Admin] for team workspace and + * [Collaborator] for owned workspace if there is already an [Owner]. */ @Transactional() async setOwner(workspaceId: string, userId: string) { @@ -63,12 +64,18 @@ export class WorkspaceUserModel extends BaseModel { throw new NewOwnerIsNotActiveMember(); } + const fallbackRole = (await this.models.workspace.isTeamWorkspace( + workspaceId + )) + ? WorkspaceRole.Admin + : WorkspaceRole.Collaborator; + await this.db.workspaceUserRole.update({ where: { id: oldOwner.id, }, data: { - type: WorkspaceRole.Admin, + type: fallbackRole, }, }); await this.db.workspaceUserRole.update({ @@ -207,6 +214,19 @@ export class WorkspaceUserModel extends BaseModel { }); } + async demoteAcceptedAdmins(workspaceId: string) { + return await this.db.workspaceUserRole.updateMany({ + where: { + workspaceId, + status: WorkspaceMemberStatus.Accepted, + type: WorkspaceRole.Admin, + }, + data: { + type: WorkspaceRole.Collaborator, + }, + }); + } + async get(workspaceId: string, userId: string) { return await this.db.workspaceUserRole.findUnique({ where: { diff --git a/packages/backend/server/src/plugins/copilot/context/resolver.ts b/packages/backend/server/src/plugins/copilot/context/resolver.ts index 9296219b99..17d5db0cf3 100644 --- a/packages/backend/server/src/plugins/copilot/context/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/context/resolver.ts @@ -37,7 +37,10 @@ import { UserFriendlyError, } from '../../../base'; import { CurrentUser } from '../../../core/auth'; -import { AccessController } from '../../../core/permission'; +import { + AccessController, + WorkspacePolicyService, +} from '../../../core/permission'; import { ContextBlob, ContextCategories, @@ -408,6 +411,7 @@ export class CopilotContextRootResolver { export class CopilotContextResolver { constructor( private readonly ac: AccessController, + private readonly policy: WorkspacePolicyService, private readonly models: Models, private readonly mutex: RequestMutex, private readonly context: CopilotContextService, @@ -667,6 +671,7 @@ export class CopilotContextResolver { const blobId = createHash('sha256').update(buffer).digest('base64url'); const { filename, mimetype } = content; + await this.policy.assertCanUploadBlob(session.workspaceId); await this.storage.put(user.id, session.workspaceId, blobId, buffer); const file = await session.addFile( blobId, diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index 95dbc29add..ccfa2fa900 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -37,7 +37,11 @@ import { import { CurrentUser } from '../../core/auth'; import { Admin } from '../../core/common'; import { DocReader } from '../../core/doc'; -import { AccessController, DocAction } from '../../core/permission'; +import { + AccessController, + DocAction, + WorkspacePolicyService, +} from '../../core/permission'; import { UserType } from '../../core/user'; import type { ListSessionOptions, UpdateChatSession } from '../../models'; import { processImage } from '../../native'; @@ -378,6 +382,7 @@ export class CopilotResolver { constructor( private readonly ac: AccessController, private readonly mutex: RequestMutex, + private readonly policy: WorkspacePolicyService, private readonly prompt: PromptService, private readonly chatSession: ChatSessionService, private readonly storage: CopilotStorage, @@ -778,6 +783,10 @@ export class CopilotResolver { delete options.blob; delete options.blobs; + if (blobs.length) { + await this.policy.assertCanUploadBlob(workspaceId); + } + for (const blob of blobs) { const uploaded = await this.storage.handleUpload(user.id, blob); const detectedMime = diff --git a/packages/backend/server/src/plugins/copilot/workspace/resolver.ts b/packages/backend/server/src/plugins/copilot/workspace/resolver.ts index 936d931386..765b13af75 100644 --- a/packages/backend/server/src/plugins/copilot/workspace/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/workspace/resolver.ts @@ -24,7 +24,10 @@ import { UserFriendlyError, } from '../../../base'; import { CurrentUser } from '../../../core/auth'; -import { AccessController } from '../../../core/permission'; +import { + AccessController, + WorkspacePolicyService, +} from '../../../core/permission'; import { WorkspaceType } from '../../../core/workspaces'; import { COPILOT_LOCKER } from '../resolver'; import { MAX_EMBEDDABLE_SIZE } from '../utils'; @@ -72,6 +75,7 @@ export class CopilotWorkspaceEmbeddingConfigResolver { constructor( private readonly ac: AccessController, private readonly mutex: Mutex, + private readonly policy: WorkspacePolicyService, private readonly copilotWorkspace: CopilotWorkspaceService ) {} @@ -215,10 +219,11 @@ export class CopilotWorkspaceEmbeddingConfigResolver { @Args('fileId', { type: () => String }) fileId: string ): Promise { - await this.ac - .user(user.id) - .workspace(workspaceId) - .assert('Workspace.Settings.Update'); + await this.policy.assertWorkspaceRoleAction( + user.id, + workspaceId, + 'Workspace.Settings.Update' + ); return await this.copilotWorkspace.removeFile(workspaceId, fileId); } diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index f61bc3e045..6ef23657b7 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -16,6 +16,7 @@ import { UserFriendlyError, WorkspaceLicenseAlreadyExists, } from '../../base'; +import { WorkspacePolicyService } from '../../core/permission'; import { Models } from '../../models'; import { SubscriptionPlan, @@ -59,7 +60,8 @@ export class LicenseService { private readonly db: PrismaClient, private readonly event: EventBus, private readonly models: Models, - private readonly crypto: CryptoHelper + private readonly crypto: CryptoHelper, + private readonly policy: WorkspacePolicyService ) {} @OnEvent('workspace.subscription.activated') @@ -83,6 +85,7 @@ export class LicenseService { workspaceId, quantity, }); + await this.policy.reconcileWorkspaceQuotaState(workspaceId); break; default: break; @@ -97,7 +100,9 @@ export class LicenseService { switch (plan) { case SubscriptionPlan.SelfHostedTeam: await this.models.workspaceUser.deleteNonAccepted(workspaceId); + await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId); await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1'); + await this.policy.reconcileWorkspaceQuotaState(workspaceId); break; default: break; diff --git a/packages/backend/server/src/plugins/oauth/providers/github.ts b/packages/backend/server/src/plugins/oauth/providers/github.ts index 85fe970b14..4e1feec96d 100644 --- a/packages/backend/server/src/plugins/oauth/providers/github.ts +++ b/packages/backend/server/src/plugins/oauth/providers/github.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { URLHelper } from '../../../base'; +import { InvalidOauthResponse, URLHelper } from '../../../base'; import { OAuthProviderName } from '../config'; import type { OAuthState } from '../types'; import { OAuthAccount, OAuthProvider, Tokens } from './def'; @@ -13,11 +13,17 @@ interface AuthTokenResponse { export interface UserInfo { login: string; - email: string; + email: string | null; avatar_url: string; name: string; } +interface UserEmailInfo { + email: string; + primary: boolean; + verified: boolean; +} + @Injectable() export class GithubOAuthProvider extends OAuthProvider { provider = OAuthProviderName.GitHub; @@ -30,7 +36,7 @@ export class GithubOAuthProvider extends OAuthProvider { return `https://github.com/login/oauth/authorize?${this.url.stringify({ client_id: this.config.clientId, redirect_uri: this.url.link('/oauth/callback'), - scope: 'user', + scope: 'read:user user:email', ...this.config.args, state, })}`; @@ -56,16 +62,36 @@ export class GithubOAuthProvider extends OAuthProvider { async getUser(tokens: Tokens, _state: OAuthState): Promise { const user = await this.fetchJson('https://api.github.com/user', { method: 'GET', - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, + headers: { Authorization: `Bearer ${tokens.accessToken}` }, }); + const email = user.email ?? (await this.getVerifiedEmail(tokens)); + if (!email) { + throw new InvalidOauthResponse({ + reason: 'GitHub account did not have a verified email address.', + }); + } + return { id: user.login, avatarUrl: user.avatar_url, - email: user.email, + email, name: user.name, }; } + + private async getVerifiedEmail(tokens: Tokens) { + const emails = await this.fetchJson( + 'https://api.github.com/user/emails', + { + method: 'GET', + headers: { Authorization: `Bearer ${tokens.accessToken}` }, + } + ); + + return ( + emails.find(email => email.primary && email.verified)?.email ?? + emails.find(email => email.verified)?.email + ); + } } diff --git a/packages/backend/server/src/plugins/payment/event.ts b/packages/backend/server/src/plugins/payment/event.ts index 348ec12c88..212b4cd2be 100644 --- a/packages/backend/server/src/plugins/payment/event.ts +++ b/packages/backend/server/src/plugins/payment/event.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { EventBus, OnEvent } from '../../base'; +import { WorkspacePolicyService } from '../../core/permission'; import { WorkspaceService } from '../../core/workspaces'; import { Models } from '../../models'; import { SubscriptionPlan, SubscriptionRecurring } from './types'; @@ -10,7 +11,8 @@ export class PaymentEventHandlers { constructor( private readonly workspace: WorkspaceService, private readonly models: Models, - private readonly event: EventBus + private readonly event: EventBus, + private readonly policy: WorkspacePolicyService ) {} @OnEvent('workspace.subscription.activated') @@ -40,6 +42,7 @@ export class PaymentEventHandlers { // we only send emails when the team workspace is activated await this.workspace.sendTeamWorkspaceUpgradedEmail(workspaceId); } + await this.policy.reconcileWorkspaceQuotaState(workspaceId); break; } default: @@ -55,7 +58,9 @@ export class PaymentEventHandlers { switch (plan) { case SubscriptionPlan.Team: await this.models.workspaceUser.deleteNonAccepted(workspaceId); + await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId); await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1'); + await this.policy.reconcileWorkspaceQuotaState(workspaceId); break; default: break; @@ -82,6 +87,7 @@ export class PaymentEventHandlers { recurring === 'lifetime' ? 'lifetime_pro_plan_v1' : 'pro_plan_v1', 'subscription activated' ); + await this.policy.reconcileOwnedWorkspaces(userId); break; default: break; @@ -106,6 +112,7 @@ export class PaymentEventHandlers { 'free_plan_v1', 'lifetime subscription canceled' ); + await this.policy.reconcileOwnedWorkspaces(userId); break; } @@ -122,6 +129,7 @@ export class PaymentEventHandlers { 'free_plan_v1', 'subscription canceled' ); + await this.policy.reconcileOwnedWorkspaces(userId); } break; }