feat: improve downgrade check

This commit is contained in:
DarkSky
2026-03-22 22:09:15 +08:00
parent adf8955e3f
commit ad6470db82
26 changed files with 1141 additions and 57 deletions

View File

@@ -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);

View File

@@ -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'
)
);
}
);

View File

@@ -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 => {

View File

@@ -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;

View File

@@ -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']);
});

View File

@@ -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));
});

View File

@@ -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']);
});

View File

@@ -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
);

View File

@@ -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,

View 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);
}
}

View File

@@ -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)
),
};
}

View File

@@ -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],
})

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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 =

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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
);
}
}

View File

@@ -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;
}