mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-22 23:30:36 +08:00
feat: improve downgrade check
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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<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 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));
|
||||
});
|
||||
@@ -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,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,
|
||||
|
||||
328
packages/backend/server/src/core/permission/policy.ts
Normal file
328
packages/backend/server/src/core/permission/policy.ts
Normal file
@@ -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<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(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);
|
||||
}
|
||||
}
|
||||
@@ -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,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],
|
||||
})
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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<Feature, z.ZodObject<any>>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<OAuthAccount> {
|
||||
const user = await this.fetchJson<UserInfo>('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<UserEmailInfo[]>(
|
||||
'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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user