refactor(server): permission (#10449)

This commit is contained in:
liuyi
2025-03-05 15:57:00 +08:00
committed by GitHub
parent bf7b1646b3
commit 61162c59fc
61 changed files with 2680 additions and 3562 deletions

View File

@@ -24,17 +24,17 @@ model User {
features UserFeature[] features UserFeature[]
userStripeCustomer UserStripeCustomer? userStripeCustomer UserStripeCustomer?
workspacePermissions WorkspaceUserPermission[] workspacePermissions WorkspaceUserRole[]
docPermissions WorkspaceDocUserPermission[] docPermissions WorkspaceDocUserRole[]
connectedAccounts ConnectedAccount[] connectedAccounts ConnectedAccount[]
sessions UserSession[] sessions UserSession[]
aiSessions AiSession[] aiSessions AiSession[]
updatedRuntimeConfigs RuntimeConfig[] updatedRuntimeConfigs RuntimeConfig[]
userSnapshots UserSnapshot[] userSnapshots UserSnapshot[]
createdSnapshot Snapshot[] @relation("createdSnapshot") createdSnapshot Snapshot[] @relation("createdSnapshot")
updatedSnapshot Snapshot[] @relation("updatedSnapshot") updatedSnapshot Snapshot[] @relation("updatedSnapshot")
createdUpdate Update[] @relation("createdUpdate") createdUpdate Update[] @relation("createdUpdate")
createdHistory SnapshotHistory[] @relation("createdHistory") createdHistory SnapshotHistory[] @relation("createdHistory")
@@index([email]) @@index([email])
@@map("users") @@map("users")
@@ -104,8 +104,8 @@ model Workspace {
features WorkspaceFeature[] features WorkspaceFeature[]
docs WorkspaceDoc[] docs WorkspaceDoc[]
permissions WorkspaceUserPermission[] permissions WorkspaceUserRole[]
docPermissions WorkspaceDocUserPermission[] docPermissions WorkspaceDocUserRole[]
blobs Blob[] blobs Blob[]
@@map("workspaces") @@map("workspaces")
@@ -139,7 +139,7 @@ enum WorkspaceMemberStatus {
Accepted // 4. old state accepted = true Accepted // 4. old state accepted = true
} }
model WorkspaceUserPermission { model WorkspaceUserRole {
id String @id @default(uuid()) @db.VarChar id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar workspaceId String @map("workspace_id") @db.VarChar
userId String @map("user_id") @db.VarChar userId String @map("user_id") @db.VarChar
@@ -162,7 +162,7 @@ model WorkspaceUserPermission {
@@map("workspace_user_permissions") @@map("workspace_user_permissions")
} }
model WorkspaceDocUserPermission { model WorkspaceDocUserRole {
workspaceId String @map("workspace_id") @db.VarChar workspaceId String @map("workspace_id") @db.VarChar
docId String @map("page_id") @db.VarChar docId String @map("page_id") @db.VarChar
userId String @map("user_id") @db.VarChar userId String @map("user_id") @db.VarChar

View File

@@ -0,0 +1,160 @@
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import Sinon from 'sinon';
import { EventBus } from '../../base';
import { DocRole, Models } from '../../models';
import { createTestingModule, TestingModule } from '../utils';
let db: PrismaClient;
let models: Models;
let module: TestingModule;
test.before(async () => {
module = await createTestingModule({
tapModule: m => {
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
},
});
models = module.get(Models);
db = module.get(PrismaClient);
});
test.beforeEach(async () => {
await module.initTestingDB();
Sinon.reset();
});
test.after(async () => {
await module.close();
});
async function create() {
return db.workspace.create({
data: { public: false },
});
}
test('should set doc owner', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.setOwner(workspace.id, docId, user.id);
const role = await models.docUser.get(workspace.id, docId, user.id);
t.is(role?.type, DocRole.Owner);
});
test('should transfer doc owner', async t => {
const user = await models.user.create({ email: 'u1@affine.pro' });
const user2 = await models.user.create({ email: 'u2@affine.pro' });
const workspace = await create();
const docId = 'fake-doc-id';
await models.docUser.setOwner(workspace.id, docId, user.id);
await models.docUser.setOwner(workspace.id, docId, user2.id);
const oldOwnerRole = await models.docUser.get(workspace.id, docId, user.id);
const newOwnerRole = await models.docUser.get(workspace.id, docId, user2.id);
t.is(oldOwnerRole?.type, DocRole.Manager);
t.is(newOwnerRole?.type, DocRole.Owner);
});
test('should set doc user role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
const role = await models.docUser.get(workspace.id, docId, user.id);
t.is(role?.type, DocRole.Manager);
});
test('should not allow setting doc owner through setDocUserRole', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await t.throwsAsync(
models.docUser.set(workspace.id, docId, user.id, DocRole.Owner),
{ message: 'Cannot set Owner role of a doc to a user.' }
);
});
test('should delete doc user role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const docId = 'fake-doc-id';
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
await models.docUser.delete(workspace.id, docId, user.id);
const role = await models.docUser.get(workspace.id, docId, user.id);
t.is(role, null);
});
test('should paginate doc user roles', async t => {
const workspace = await create();
const docId = 'fake-doc-id';
await db.user.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
id: String(i),
name: `u${i}`,
email: `${i}@affine.pro`,
})),
});
await db.workspaceDocUserRole.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
workspaceId: workspace.id,
docId,
userId: String(i),
type: DocRole.Editor,
createdAt: new Date(Date.now() + i * 1000),
})),
});
const [roles, total] = await models.docUser.paginate(workspace.id, docId, {
first: 10,
offset: 0,
});
t.is(roles.length, 10);
t.is(total, 200);
const [roles2] = await models.docUser.paginate(workspace.id, docId, {
after: roles.at(-1)?.createdAt.toISOString(),
first: 50,
offset: 0,
});
t.is(roles2.length, 50);
t.not(roles2[0].type, DocRole.Owner);
t.deepEqual(
roles2.map(r => r.userId),
roles2
.toSorted((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map(r => r.userId)
);
});
test('should count doc user roles', async t => {
const workspace = await create();
const docId = 'fake-doc-id';
const users = await Promise.all([
models.user.create({ email: 'u1@affine.pro' }),
models.user.create({ email: 'u2@affine.pro' }),
]);
await Promise.all(
users.map(user =>
models.docUser.set(workspace.id, docId, user.id, DocRole.Manager)
)
);
const count = await models.docUser.count(workspace.id, docId);
t.is(count, 2);
});

View File

@@ -1,232 +0,0 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { Config } from '../../base/config';
import { WorkspaceRole } from '../../core/permission';
import { PublicPageMode } from '../../models/common';
import { PageModel } from '../../models/page';
import { type User, UserModel } from '../../models/user';
import { type Workspace, WorkspaceModel } from '../../models/workspace';
import { createTestingModule, type TestingModule } from '../utils';
interface Context {
config: Config;
module: TestingModule;
db: PrismaClient;
user: UserModel;
workspace: WorkspaceModel;
page: PageModel;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule();
t.context.user = module.get(UserModel);
t.context.workspace = module.get(WorkspaceModel);
t.context.page = module.get(PageModel);
t.context.db = module.get(PrismaClient);
t.context.config = module.get(Config);
t.context.module = module;
});
let user: User;
let workspace: Workspace;
test.beforeEach(async t => {
await t.context.module.initTestingDB();
user = await t.context.user.create({
email: 'test@affine.pro',
});
workspace = await t.context.workspace.create(user.id);
});
test.after(async t => {
await t.context.module.close();
});
test('should create page with default mode and public false', async t => {
const page = await t.context.page.upsert(workspace.id, 'page1');
t.is(page.workspaceId, workspace.id);
t.is(page.docId, 'page1');
t.is(page.mode, PublicPageMode.Page);
t.is(page.public, false);
});
test('should update page', async t => {
const page = await t.context.page.upsert(workspace.id, 'page1');
const data = {
mode: PublicPageMode.Edgeless,
public: true,
};
await t.context.page.upsert(workspace.id, 'page1', data);
const page1 = await t.context.page.get(workspace.id, 'page1');
t.deepEqual(page1, {
...page,
...data,
});
});
test('should get null when page not exists', async t => {
const page = await t.context.page.get(workspace.id, 'page1');
t.is(page, null);
});
test('should get page by id and public flag', async t => {
await t.context.page.upsert(workspace.id, 'page1');
await t.context.page.upsert(workspace.id, 'page2', {
public: true,
});
let page1 = await t.context.page.get(workspace.id, 'page1');
t.is(page1!.public, false);
page1 = await t.context.page.get(workspace.id, 'page1', true);
t.is(page1, null);
let page2 = await t.context.page.get(workspace.id, 'page2', true);
t.is(page2!.public, true);
page2 = await t.context.page.get(workspace.id, 'page2', false);
t.is(page2, null);
});
test('should get public page count', async t => {
await t.context.page.upsert(workspace.id, 'page1', {
public: true,
});
await t.context.page.upsert(workspace.id, 'page2', {
public: true,
});
await t.context.page.upsert(workspace.id, 'page3');
const count = await t.context.page.getPublicsCount(workspace.id);
t.is(count, 2);
});
test('should get public pages of a workspace', async t => {
await t.context.page.upsert(workspace.id, 'page1', {
public: true,
});
await t.context.page.upsert(workspace.id, 'page2', {
public: true,
});
await t.context.page.upsert(workspace.id, 'page3');
const pages = await t.context.page.findPublics(workspace.id);
t.is(pages.length, 2);
t.deepEqual(pages.map(p => p.docId).sort(), ['page1', 'page2']);
});
test('should grant a member to access a page', async t => {
await t.context.page.upsert(workspace.id, 'page1', {
public: true,
});
const member = await t.context.user.create({
email: 'test1@affine.pro',
});
await t.context.page.grantMember(workspace.id, 'page1', member.id);
let hasAccess = await t.context.page.isMember(
workspace.id,
'page1',
member.id
);
t.true(hasAccess);
hasAccess = await t.context.page.isMember(
workspace.id,
'page1',
user.id,
WorkspaceRole.Collaborator
);
t.false(hasAccess);
// grant write permission
await t.context.page.grantMember(
workspace.id,
'page1',
user.id,
WorkspaceRole.Collaborator
);
hasAccess = await t.context.page.isMember(
workspace.id,
'page1',
user.id,
WorkspaceRole.Collaborator
);
t.true(hasAccess);
hasAccess = await t.context.page.isMember(
workspace.id,
'page1',
user.id,
WorkspaceRole.Collaborator
);
t.true(hasAccess);
// delete member
const count = await t.context.page.deleteMember(
workspace.id,
'page1',
user.id
);
t.is(count, 1);
hasAccess = await t.context.page.isMember(workspace.id, 'page1', user.id);
t.false(hasAccess);
});
test('should change the page owner', async t => {
await t.context.page.upsert(workspace.id, 'page1', {
public: true,
});
await t.context.page.grantMember(
workspace.id,
'page1',
user.id,
WorkspaceRole.Owner
);
t.true(
await t.context.page.isMember(
workspace.id,
'page1',
user.id,
WorkspaceRole.Owner
)
);
// change owner
const otherUser = await t.context.user.create({
email: 'test1@affine.pro',
});
await t.context.page.grantMember(
workspace.id,
'page1',
otherUser.id,
WorkspaceRole.Owner
);
t.true(
await t.context.page.isMember(
workspace.id,
'page1',
otherUser.id,
WorkspaceRole.Owner
)
);
t.false(
await t.context.page.isMember(
workspace.id,
'page1',
user.id,
WorkspaceRole.Owner
)
);
});
test('should not delete owner from page', async t => {
await t.context.page.upsert(workspace.id, 'page1', {
public: true,
});
await t.context.page.grantMember(
workspace.id,
'page1',
user.id,
WorkspaceRole.Owner
);
const count = await t.context.page.deleteMember(
workspace.id,
'page1',
user.id
);
t.is(count, 0);
});

View File

@@ -2,13 +2,13 @@ import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { EmailAlreadyUsed, EventBus } from '../../base'; import { EmailAlreadyUsed, EventBus } from '../../base';
import { WorkspaceRole } from '../../core/permission'; import { Models } from '../../models';
import { UserModel } from '../../models/user'; import { UserModel } from '../../models/user';
import { WorkspaceMemberStatus } from '../../models/workspace';
import { createTestingModule, sleep, type TestingModule } from '../utils'; import { createTestingModule, sleep, type TestingModule } from '../utils';
interface Context { interface Context {
module: TestingModule; module: TestingModule;
models: Models;
user: UserModel; user: UserModel;
} }
@@ -18,6 +18,7 @@ test.before(async t => {
const module = await createTestingModule({}); const module = await createTestingModule({});
t.context.user = module.get(UserModel); t.context.user = module.get(UserModel);
t.context.models = module.get(Models);
t.context.module = module; t.context.module = module;
}); });
@@ -253,24 +254,13 @@ test('should trigger user.deleted event', async t => {
const user = await t.context.user.create({ const user = await t.context.user.create({
email: 'test@affine.pro', email: 'test@affine.pro',
workspacePermissions: {
create: {
workspace: {
create: {
id: 'test-workspace',
public: false,
},
},
type: WorkspaceRole.Owner,
status: WorkspaceMemberStatus.Accepted,
},
},
}); });
const workspace = await t.context.models.workspace.create(user.id);
await t.context.user.delete(user.id); await t.context.user.delete(user.id);
t.true( t.true(
spy.calledOnceWithExactly({ ...user, ownedWorkspaces: ['test-workspace'] }) spy.calledOnceWithExactly({ ...user, ownedWorkspaces: [workspace.id] })
); );
// await for 'user.deleted' event to be emitted and executed // await for 'user.deleted' event to be emitted and executed
// avoid race condition cause database dead lock // avoid race condition cause database dead lock

View File

@@ -0,0 +1,255 @@
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import Sinon from 'sinon';
import { EventBus } from '../../base';
import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models';
import { createTestingModule, TestingModule } from '../utils';
let db: PrismaClient;
let models: Models;
let module: TestingModule;
let event: Sinon.SinonStubbedInstance<EventBus>;
test.before(async () => {
module = await createTestingModule({
tapModule: m => {
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
},
});
models = module.get(Models);
event = module.get(EventBus);
db = module.get(PrismaClient);
});
test.beforeEach(async () => {
await module.initTestingDB();
Sinon.reset();
});
test.after(async () => {
await module.close();
});
async function create() {
return db.workspace.create({
data: { public: false },
});
}
test('should set workspace owner', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.setOwner(workspace.id, user.id);
const owner = await models.workspaceUser.getOwner(workspace.id);
t.is(owner.id, user.id);
});
test('should transfer workespace owner', async t => {
const user = await models.user.create({ email: 'u1@affine.pro' });
const user2 = await models.user.create({ email: 'u2@affine.pro' });
const workspace = await models.workspace.create(user.id);
await models.workspaceUser.setOwner(workspace.id, user2.id);
t.true(
event.emit.lastCall.calledWith('workspace.owner.changed', {
workspaceId: workspace.id,
from: user.id,
to: user2.id,
})
);
const owner2 = await models.workspaceUser.getOwner(workspace.id);
t.is(owner2.id, user2.id);
});
test('should get user role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin);
const role = await models.workspaceUser.get(workspace.id, user.id);
t.is(role!.type, WorkspaceRole.Admin);
});
test('should get active workspace role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
const role = await models.workspaceUser.getActive(workspace.id, user.id);
t.is(role!.type, WorkspaceRole.Admin);
});
test('should not get inactive workspace role', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
let role = await models.workspaceUser.getActive(workspace.id, u1.id);
t.is(role, null);
await models.workspaceUser.setStatus(
workspace.id,
u1.id,
WorkspaceMemberStatus.UnderReview
);
role = await models.workspaceUser.getActive(workspace.id, u1.id);
t.is(role, null);
});
test('should update user role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
const role = await models.workspaceUser.get(workspace.id, user.id);
t.is(role!.type, WorkspaceRole.Admin);
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Collaborator
);
const role2 = await models.workspaceUser.get(workspace.id, user.id);
t.is(role2!.type, WorkspaceRole.Collaborator);
t.deepEqual(event.emit.lastCall.args, [
'workspace.members.roleChanged',
{
userId: user.id,
workspaceId: workspace.id,
role: WorkspaceRole.Collaborator,
},
]);
});
test('should return workspace role if status is Accepted', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
await models.workspaceUser.setStatus(
workspace.id,
u1.id,
WorkspaceMemberStatus.Accepted
);
const role = await models.workspaceUser.get(workspace.id, u1.id);
t.is(role!.type, WorkspaceRole.Admin);
});
test('should delete workspace user role', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
await models.workspaceUser.setStatus(
workspace.id,
u1.id,
WorkspaceMemberStatus.Accepted
);
let role = await models.workspaceUser.get(workspace.id, u1.id);
t.is(role!.type, WorkspaceRole.Admin);
await models.workspaceUser.delete(workspace.id, u1.id);
role = await models.workspaceUser.get(workspace.id, u1.id);
t.is(role, null);
});
test('should get user workspace roles with filter', async t => {
const ws1 = await create();
const ws2 = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await db.workspaceUserRole.createMany({
data: [
{
workspaceId: ws1.id,
userId: user.id,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
},
{
workspaceId: ws2.id,
userId: user.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Accepted,
},
],
});
let roles = await models.workspaceUser.getUserActiveRoles(user.id, {
role: WorkspaceRole.Admin,
});
t.is(roles.length, 1);
t.is(roles[0].type, WorkspaceRole.Admin);
roles = await models.workspaceUser.getUserActiveRoles(user.id);
t.is(roles.length, 2);
});
test('should paginate workspace user roles', async t => {
const workspace = await create();
await db.user.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
id: String(i),
name: `u${i}`,
email: `${i}@affine.pro`,
})),
});
await db.workspaceUserRole.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
workspaceId: workspace.id,
userId: String(i),
type: WorkspaceRole.Collaborator,
status: Object.values(WorkspaceMemberStatus)[
Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length)
],
createdAt: new Date(Date.now() + i * 1000),
})),
});
const [roles, total] = await models.workspaceUser.paginate(workspace.id, {
first: 10,
offset: 0,
});
t.is(roles.length, 10);
t.is(total, 200);
const [roles2] = await models.workspaceUser.paginate(workspace.id, {
after: roles.at(-1)?.createdAt.toISOString(),
first: 50,
offset: 0,
});
t.is(roles2.length, 50);
t.deepEqual(
roles2.map(r => r.id),
roles2
.toSorted((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map(r => r.id)
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ import { AppModule } from '../app.module';
import { EventBus } from '../base'; import { EventBus } from '../base';
import { AuthService } from '../core/auth'; import { AuthService } from '../core/auth';
import { DocReader } from '../core/doc'; import { DocReader } from '../core/doc';
import { DocRole, PermissionService, WorkspaceRole } from '../core/permission'; import { DocRole, WorkspaceRole } from '../core/permission';
import { WorkspaceType } from '../core/workspaces'; import { WorkspaceType } from '../core/workspaces';
import { Models } from '../models'; import { Models } from '../models';
import { import {
@@ -43,7 +43,6 @@ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
event: Sinon.SinonStubbedInstance<EventBus>; event: Sinon.SinonStubbedInstance<EventBus>;
models: Models; models: Models;
permissions: PermissionService;
}>; }>;
test.before(async t => { test.before(async t => {
@@ -68,7 +67,6 @@ test.before(async t => {
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.event = app.get(EventBus); t.context.event = app.get(EventBus);
t.context.models = app.get(Models); t.context.models = app.get(Models);
t.context.permissions = app.get(PermissionService);
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
@@ -256,7 +254,7 @@ test('should be able to invite multiple users', async t => {
}); });
test('should be able to check seat limit', async t => { test('should be able to check seat limit', async t => {
const { app, permissions, models } = t.context; const { app, models } = t.context;
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 5); const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 5);
{ {
@@ -284,10 +282,8 @@ test('should be able to check seat limit', async t => {
); );
t.is( t.is(
await permissions.getWorkspaceMemberStatus( (await models.workspaceUser.get(ws.id, (await members1)[0][0].id))
ws.id, ?.status,
(await members1)[0][0].id
),
WorkspaceMemberStatus.NeedMoreSeat, WorkspaceMemberStatus.NeedMoreSeat,
'should be able to check member status' 'should be able to check member status'
); );
@@ -295,18 +291,16 @@ test('should be able to check seat limit', async t => {
// refresh seat, fifo // refresh seat, fifo
await sleep(1000); await sleep(1000);
const [[members2]] = await inviteBatch(['member6@affine.pro']); const [[members2]] = await inviteBatch(['member6@affine.pro']);
await permissions.refreshSeatStatus(ws.id, 7); await models.workspaceUser.refresh(ws.id, 7);
t.is( t.is(
await permissions.getWorkspaceMemberStatus( (await models.workspaceUser.get(ws.id, (await members1)[0][0].id))
ws.id, ?.status,
(await members1)[0][0].id
),
WorkspaceMemberStatus.Pending, WorkspaceMemberStatus.Pending,
'should become accepted after refresh' 'should become accepted after refresh'
); );
t.is( t.is(
await permissions.getWorkspaceMemberStatus(ws.id, members2.id), (await models.workspaceUser.get(ws.id, members2.id))?.status,
WorkspaceMemberStatus.NeedMoreSeat, WorkspaceMemberStatus.NeedMoreSeat,
'should not change status' 'should not change status'
); );
@@ -314,7 +308,7 @@ test('should be able to check seat limit', async t => {
}); });
test('should be able to grant team member permission', async t => { test('should be able to grant team member permission', async t => {
const { app, permissions } = t.context; const { app, models } = t.context;
const { owner, teamWorkspace: ws, write, read } = await init(app); const { owner, teamWorkspace: ws, write, read } = await init(app);
app.switchUser(read); app.switchUser(read);
@@ -335,11 +329,8 @@ test('should be able to grant team member permission', async t => {
// owner should be able to grant permission // owner should be able to grant permission
app.switchUser(owner); app.switchUser(owner);
t.true( t.true(
await permissions.tryCheckWorkspaceIs( (await models.workspaceUser.get(ws.id, read.id))?.type ===
ws.id, WorkspaceRole.Collaborator,
read.id,
WorkspaceRole.Collaborator
),
'should be able to check permission' 'should be able to check permission'
); );
t.truthy( t.truthy(
@@ -347,11 +338,8 @@ test('should be able to grant team member permission', async t => {
'should be able to grant permission' 'should be able to grant permission'
); );
t.true( t.true(
await permissions.tryCheckWorkspaceIs( (await models.workspaceUser.get(ws.id, read.id))?.type ===
ws.id, WorkspaceRole.Admin,
read.id,
WorkspaceRole.Admin
),
'should be able to check permission' 'should be able to check permission'
); );
} }
@@ -362,10 +350,9 @@ test('should be able to leave workspace', async t => {
const { owner, teamWorkspace: ws, admin, write, read } = await init(app); const { owner, teamWorkspace: ws, admin, write, read } = await init(app);
app.switchUser(owner); app.switchUser(owner);
t.false( await t.throwsAsync(leaveWorkspace(app, ws.id), {
await leaveWorkspace(app, ws.id), message: 'Owner can not leave the workspace.',
'owner should not be able to leave workspace' });
);
app.switchUser(admin); app.switchUser(admin);
t.true( t.true(
@@ -425,10 +412,9 @@ test('should be able to revoke team member', async t => {
'owner should be able to revoke member' 'owner should be able to revoke member'
); );
t.false( await t.throwsAsync(revokeUser(app, ws.id, owner.id), {
await revokeUser(app, ws.id, owner.id), message: 'You can not revoke your own permission.',
'should not be able to revoke themselves' });
);
await revokeUser(app, ws.id, admin.id); await revokeUser(app, ws.id, admin.id);
app.switchUser(admin); app.switchUser(admin);
@@ -508,7 +494,7 @@ test('should be able to approve team member', async t => {
const memberInvite = members.find(m => m.id === member.id)!; const memberInvite = members.find(m => m.id === member.id)!;
t.is(memberInvite.status, 'UnderReview', 'should be under review'); t.is(memberInvite.status, 'UnderReview', 'should be under review');
t.is(await approveMember(app, tws.id, member.id), memberInvite.inviteId); t.true(await approveMember(app, tws.id, member.id));
} }
{ {
@@ -536,7 +522,7 @@ test('should be able to approve team member', async t => {
}); });
test('should be able to invite by link', async t => { test('should be able to invite by link', async t => {
const { app, permissions, models } = t.context; const { app, models } = t.context;
const { const {
createInviteLink, createInviteLink,
owner, owner,
@@ -562,10 +548,10 @@ test('should be able to invite by link', async t => {
// invite link // invite link
for (const [i] of Array.from({ length: 5 }).entries()) { for (const [i] of Array.from({ length: 5 }).entries()) {
const user = await invite(`test${i}@affine.pro`); const user = await invite(`test${i}@affine.pro`);
const status = await permissions.getWorkspaceMemberStatus(ws.id, user.id); const status = (await models.workspaceUser.get(ws.id, user.id))?.status;
t.is( t.is(
status, status,
WorkspaceMemberStatus.Accepted, WorkspaceMemberStatus.UnderReview,
'should be able to check status' 'should be able to check status'
); );
} }
@@ -587,12 +573,12 @@ test('should be able to invite by link', async t => {
const [m3, m4] = members; const [m3, m4] = members;
t.is( t.is(
await permissions.getWorkspaceMemberStatus(tws.id, m3.id), (await models.workspaceUser.get(tws.id, m3.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview, WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status' 'should not change status'
); );
t.is( t.is(
await permissions.getWorkspaceMemberStatus(tws.id, m4.id), (await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview, WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status' 'should not change status'
); );
@@ -600,14 +586,14 @@ test('should be able to invite by link', async t => {
models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', { models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
memberLimit: 6, memberLimit: 6,
}); });
await permissions.refreshSeatStatus(tws.id, 6); await models.workspaceUser.refresh(tws.id, 6);
t.is( t.is(
await permissions.getWorkspaceMemberStatus(tws.id, m3.id), (await models.workspaceUser.get(tws.id, m3.id))?.status,
WorkspaceMemberStatus.UnderReview, WorkspaceMemberStatus.UnderReview,
'should not change status' 'should not change status'
); );
t.is( t.is(
await permissions.getWorkspaceMemberStatus(tws.id, m4.id), (await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview, WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status' 'should not change status'
); );
@@ -615,9 +601,9 @@ test('should be able to invite by link', async t => {
models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', { models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
memberLimit: 7, memberLimit: 7,
}); });
await permissions.refreshSeatStatus(tws.id, 7); await models.workspaceUser.refresh(tws.id, 7);
t.is( t.is(
await permissions.getWorkspaceMemberStatus(tws.id, m4.id), (await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.UnderReview, WorkspaceMemberStatus.UnderReview,
'should not change status' 'should not change status'
); );
@@ -665,6 +651,7 @@ test('should be able to emit events', async t => {
const { teamWorkspace: tws, owner, createInviteLink } = await init(app); const { teamWorkspace: tws, owner, createInviteLink } = await init(app);
const [, invite] = await createInviteLink(tws); const [, invite] = await createInviteLink(tws);
const user = await invite('m3@affine.pro'); const user = await invite('m3@affine.pro');
app.switchUser(owner);
const { members } = await getWorkspace(app, tws.id); const { members } = await getWorkspace(app, tws.id);
const memberInvite = members.find(m => m.id === user.id)!; const memberInvite = members.find(m => m.id === user.id)!;
t.deepEqual( t.deepEqual(
@@ -698,7 +685,7 @@ test('should be able to emit events', async t => {
{ {
userId: read.id, userId: read.id,
workspaceId: tws.id, workspaceId: tws.id,
permission: WorkspaceRole.Admin, role: WorkspaceRole.Admin,
}, },
], ],
'should emit role changed event' 'should emit role changed event'
@@ -712,7 +699,7 @@ test('should be able to emit events', async t => {
t.deepEqual( t.deepEqual(
ownershipTransferred, ownershipTransferred,
[ [
'workspace.members.ownershipTransferred', 'workspace.owner.changed',
{ from: owner.id, to: read.id, workspaceId: tws.id }, { from: owner.id, to: read.id, workspaceId: tws.id },
], ],
'should emit owner transferred event' 'should emit owner transferred event'
@@ -880,20 +867,15 @@ test('should be able to grant and revoke doc user role', async t => {
grantDocUserRoles: true, grantDocUserRoles: true,
}); });
// external user now can manage the page // external user can never be able to manage the page
{ {
app.switchUser(external); app.switchUser(external);
const externalRes = await grantDocUserRoles( await t.throwsAsync(
app, grantDocUserRoles(app, ws.id, docId, [read.id], DocRole.Manager),
ws.id, {
docId, message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
[read.id], }
DocRole.Manager
); );
t.deepEqual(externalRes, {
grantDocUserRoles: true,
});
} }
// revoke the role of the external user // revoke the role of the external user

View File

@@ -1,15 +1,24 @@
import { type Blob } from '@prisma/client';
import { TestingApp } from './testing-app'; import { TestingApp } from './testing-app';
export async function listBlobs( export async function listBlobs(
app: TestingApp, app: TestingApp,
workspaceId: string workspaceId: string
): Promise<string[]> { ): Promise<Blob[]> {
const res = await app.gql(` const res = await app.gql(`
query { query {
listBlobs(workspaceId: "${workspaceId}") workspace(id: "${workspaceId}") {
blobs {
key
mime
size
createdAt
}
}
} }
`); `);
return res.listBlobs; return res.workspace.blobs;
} }
export async function getWorkspaceBlobsSize( export async function getWorkspaceBlobsSize(

View File

@@ -94,7 +94,7 @@ test('should visit public page', async t => {
const docs2 = await getWorkspacePublicDocs(app, workspace.id); const docs2 = await getWorkspacePublicDocs(app, workspace.id);
t.is(docs2.length, 0, 'failed to get shared docs'); t.is(docs2.length, 0, 'failed to get shared docs');
await t.throwsAsync(revokePublicDoc(app, workspace.id, 'doc3'), { await t.throwsAsync(revokePublicDoc(app, workspace.id, 'doc3'), {
message: 'Doc is not public', message: 'Doc is not public.',
}); });
const docs3 = await getWorkspacePublicDocs(app, workspace.id); const docs3 = await getWorkspacePublicDocs(app, workspace.id);

View File

@@ -77,7 +77,7 @@ test('should list blobs', async t => {
const ret = await listBlobs(app, workspace.id); const ret = await listBlobs(app, workspace.id);
t.is(ret.length, 2, 'failed to list blobs'); t.is(ret.length, 2, 'failed to list blobs');
// list blob result is not ordered // list blob result is not ordered
t.deepEqual(ret.sort(), [hash1, hash2].sort()); t.deepEqual(ret.map(x => x.key).sort(), [hash1, hash2].sort());
}); });
test('should auto delete blobs when workspace is deleted', async t => { test('should auto delete blobs when workspace is deleted', async t => {

View File

@@ -7,6 +7,7 @@ import Sinon from 'sinon';
import { PgWorkspaceDocStorageAdapter } from '../../core/doc'; import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { WorkspaceBlobStorage } from '../../core/storage'; import { WorkspaceBlobStorage } from '../../core/storage';
import { Models, WorkspaceRole } from '../../models';
import { createTestingApp, TestingApp, TestUser } from '../utils'; import { createTestingApp, TestingApp, TestUser } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
@@ -15,6 +16,7 @@ const test = ava as TestFn<{
u1: TestUser; u1: TestUser;
storage: Sinon.SinonStubbedInstance<WorkspaceBlobStorage>; storage: Sinon.SinonStubbedInstance<WorkspaceBlobStorage>;
workspace: Sinon.SinonStubbedInstance<PgWorkspaceDocStorageAdapter>; workspace: Sinon.SinonStubbedInstance<PgWorkspaceDocStorageAdapter>;
models: Models;
}>; }>;
test.before(async t => { test.before(async t => {
@@ -34,6 +36,7 @@ test.before(async t => {
t.context.app = app; t.context.app = app;
t.context.storage = app.get(WorkspaceBlobStorage); t.context.storage = app.get(WorkspaceBlobStorage);
t.context.workspace = app.get(PgWorkspaceDocStorageAdapter); t.context.workspace = app.get(PgWorkspaceDocStorageAdapter);
t.context.models = app.get(Models);
await db.workspaceDoc.create({ await db.workspaceDoc.create({
data: { data: {
@@ -155,17 +158,14 @@ test('should not be able to get private workspace with no public pages', async t
}); });
test('should be able to get permission granted workspace', async t => { test('should be able to get permission granted workspace', async t => {
const { app, db, storage } = t.context; const { app, storage } = t.context;
await db.workspaceUserPermission.create({ await t.context.models.workspaceUser.set(
data: { 'totally-private',
workspaceId: 'totally-private', t.context.u1.id,
userId: t.context.u1.id, WorkspaceRole.Collaborator,
type: 1, WorkspaceMemberStatus.Accepted
accepted: true, );
status: WorkspaceMemberStatus.Accepted,
},
});
storage.get.resolves(blob()); storage.get.resolves(blob());
await app.login(t.context.u1); await app.login(t.context.u1);

View File

@@ -419,6 +419,14 @@ export const USER_FRIENDLY_ERRORS = {
args: { spaceId: 'string' }, args: { spaceId: 'string' },
message: 'Space should have only one owner.', message: 'Space should have only one owner.',
}, },
owner_can_not_leave_workspace: {
type: 'action_forbidden',
message: 'Owner can not leave the workspace.',
},
can_not_revoke_yourself: {
type: 'action_forbidden',
message: 'You can not revoke your own permission.',
},
doc_not_found: { doc_not_found: {
type: 'resource_not_found', type: 'resource_not_found',
args: { spaceId: 'string', docId: 'string' }, args: { spaceId: 'string', docId: 'string' },

View File

@@ -298,6 +298,18 @@ export class SpaceShouldHaveOnlyOneOwner extends UserFriendlyError {
super('invalid_input', 'space_should_have_only_one_owner', message, args); super('invalid_input', 'space_should_have_only_one_owner', message, args);
} }
} }
export class OwnerCanNotLeaveWorkspace extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'owner_can_not_leave_workspace', message);
}
}
export class CanNotRevokeYourself extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'can_not_revoke_yourself', message);
}
}
@ObjectType() @ObjectType()
class DocNotFoundDataType { class DocNotFoundDataType {
@Field() spaceId!: string @Field() spaceId!: string
@@ -855,6 +867,8 @@ export enum ErrorNames {
SPACE_ACCESS_DENIED, SPACE_ACCESS_DENIED,
SPACE_OWNER_NOT_FOUND, SPACE_OWNER_NOT_FOUND,
SPACE_SHOULD_HAVE_ONLY_ONE_OWNER, SPACE_SHOULD_HAVE_ONLY_ONE_OWNER,
OWNER_CAN_NOT_LEAVE_WORKSPACE,
CAN_NOT_REVOKE_YOURSELF,
DOC_NOT_FOUND, DOC_NOT_FOUND,
DOC_ACTION_DENIED, DOC_ACTION_DENIED,
VERSION_REJECTED, VERSION_REJECTED,

View File

@@ -10,14 +10,12 @@ import { Config } from '../../../base';
import { ConfigModule } from '../../../base/config'; import { ConfigModule } from '../../../base/config';
import { Models } from '../../../models'; import { Models } from '../../../models';
import { PgWorkspaceDocStorageAdapter } from '../../doc'; import { PgWorkspaceDocStorageAdapter } from '../../doc';
import { PermissionService } from '../../permission';
const test = ava as TestFn<{ const test = ava as TestFn<{
models: Models; models: Models;
app: TestingApp; app: TestingApp;
config: Config; config: Config;
adapter: PgWorkspaceDocStorageAdapter; adapter: PgWorkspaceDocStorageAdapter;
permission: PermissionService;
}>; }>;
test.before(async t => { test.before(async t => {
@@ -38,7 +36,6 @@ test.before(async t => {
t.context.models = app.get(Models); t.context.models = app.get(Models);
t.context.config = app.get(Config); t.context.config = app.get(Config);
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter); t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
t.context.permission = app.get(PermissionService);
t.context.app = app; t.context.app = app;
}); });
@@ -60,7 +57,7 @@ test.after.always(async t => {
test('should render page success', async t => { test('should render page success', async t => {
const docId = randomUUID(); const docId = randomUUID();
const { app, adapter, permission } = t.context; const { app, adapter, models } = t.context;
const doc = new YDoc(); const doc = new YDoc();
const text = doc.getText('content'); const text = doc.getText('content');
@@ -75,7 +72,7 @@ test('should render page success', async t => {
text.insert(5, ' '); text.insert(5, ' ');
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id); await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
await permission.publishPage(workspace.id, docId); await models.workspace.publishDoc(workspace.id, docId);
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200); await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
t.pass(); t.pass();

View File

@@ -6,10 +6,10 @@ import type { Request, Response } from 'express';
import isMobile from 'is-mobile'; import isMobile from 'is-mobile';
import { Config, metrics } from '../../base'; import { Config, metrics } from '../../base';
import { Models } from '../../models';
import { htmlSanitize } from '../../native'; import { htmlSanitize } from '../../native';
import { Public } from '../auth'; import { Public } from '../auth';
import { DocReader } from '../doc'; import { DocReader } from '../doc';
import { PermissionService } from '../permission';
interface RenderOptions { interface RenderOptions {
title: string; title: string;
@@ -51,8 +51,8 @@ export class DocRendererController {
constructor( constructor(
private readonly doc: DocReader, private readonly doc: DocReader,
private readonly permission: PermissionService, private readonly config: Config,
private readonly config: Config private readonly models: Models
) { ) {
this.webAssets = this.readHtmlAssets( this.webAssets = this.readHtmlAssets(
join(this.config.projectRoot, 'static') join(this.config.projectRoot, 'static')
@@ -102,14 +102,15 @@ export class DocRendererController {
workspaceId: string, workspaceId: string,
docId: string docId: string
): Promise<RenderOptions | null> { ): Promise<RenderOptions | null> {
let allowUrlPreview = await this.permission.isPublicPage( let allowUrlPreview = await this.models.workspace.isPublicPage(
workspaceId, workspaceId,
docId docId
); );
if (!allowUrlPreview) { if (!allowUrlPreview) {
// if page is private, but workspace url preview is on // if page is private, but workspace url preview is on
allowUrlPreview = await this.permission.allowUrlPreview(workspaceId); allowUrlPreview =
await this.models.workspace.allowUrlPreview(workspaceId);
} }
if (allowUrlPreview) { if (allowUrlPreview) {
@@ -122,7 +123,8 @@ export class DocRendererController {
private async getWorkspaceContent( private async getWorkspaceContent(
workspaceId: string workspaceId: string
): Promise<RenderOptions | null> { ): Promise<RenderOptions | null> {
const allowUrlPreview = await this.permission.allowUrlPreview(workspaceId); const allowUrlPreview =
await this.models.workspace.allowUrlPreview(workspaceId);
if (allowUrlPreview) { if (allowUrlPreview) {
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);

View File

@@ -9,7 +9,6 @@ import {
metrics, metrics,
Runtime, Runtime,
} from '../../base'; } from '../../base';
import { PermissionService } from '../permission';
import { QuotaService } from '../quota'; import { QuotaService } from '../quota';
import { DocStorageOptions as IDocStorageOptions } from './storage'; import { DocStorageOptions as IDocStorageOptions } from './storage';
@@ -37,7 +36,6 @@ export class DocStorageOptions implements IDocStorageOptions {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly runtime: Runtime, private readonly runtime: Runtime,
private readonly permission: PermissionService,
private readonly quota: QuotaService private readonly quota: QuotaService
) {} ) {}
@@ -87,8 +85,7 @@ export class DocStorageOptions implements IDocStorageOptions {
}; };
historyMaxAge = async (spaceId: string) => { historyMaxAge = async (spaceId: string) => {
const owner = await this.permission.getWorkspaceOwner(spaceId); const quota = await this.quota.getWorkspaceQuota(spaceId);
const quota = await this.quota.getUserQuota(owner.id);
return quota.historyPeriod; return quota.historyPeriod;
}; };

View File

@@ -91,13 +91,20 @@ Generated by [AVA](https://avajs.dev).
> WorkspaceRole: External > WorkspaceRole: External
{ {
'Workspace.Adminitrators.Manage': false,
'Workspace.Blobs.List': false,
'Workspace.Blobs.Read': true,
'Workspace.Blobs.Write': false,
'Workspace.Copilot': false,
'Workspace.CreateDoc': false, 'Workspace.CreateDoc': false,
'Workspace.Delete': false, 'Workspace.Delete': false,
'Workspace.Organize.Read': true, 'Workspace.Organize.Read': true,
'Workspace.Payment.Manage': false,
'Workspace.Properties.Create': false, 'Workspace.Properties.Create': false,
'Workspace.Properties.Delete': false, 'Workspace.Properties.Delete': false,
'Workspace.Properties.Read': false, 'Workspace.Properties.Read': true,
'Workspace.Properties.Update': false, 'Workspace.Properties.Update': false,
'Workspace.Read': true,
'Workspace.Settings.Read': false, 'Workspace.Settings.Read': false,
'Workspace.Settings.Update': false, 'Workspace.Settings.Update': false,
'Workspace.Sync': false, 'Workspace.Sync': false,
@@ -109,13 +116,20 @@ Generated by [AVA](https://avajs.dev).
> WorkspaceRole: Collaborator > WorkspaceRole: Collaborator
{ {
'Workspace.Adminitrators.Manage': false,
'Workspace.Blobs.List': true,
'Workspace.Blobs.Read': true,
'Workspace.Blobs.Write': true,
'Workspace.Copilot': true,
'Workspace.CreateDoc': true, 'Workspace.CreateDoc': true,
'Workspace.Delete': false, 'Workspace.Delete': false,
'Workspace.Organize.Read': true, 'Workspace.Organize.Read': true,
'Workspace.Payment.Manage': false,
'Workspace.Properties.Create': false, 'Workspace.Properties.Create': false,
'Workspace.Properties.Delete': false, 'Workspace.Properties.Delete': false,
'Workspace.Properties.Read': true, 'Workspace.Properties.Read': true,
'Workspace.Properties.Update': false, 'Workspace.Properties.Update': false,
'Workspace.Read': true,
'Workspace.Settings.Read': true, 'Workspace.Settings.Read': true,
'Workspace.Settings.Update': false, 'Workspace.Settings.Update': false,
'Workspace.Sync': true, 'Workspace.Sync': true,
@@ -127,13 +141,20 @@ Generated by [AVA](https://avajs.dev).
> WorkspaceRole: Admin > WorkspaceRole: Admin
{ {
'Workspace.Adminitrators.Manage': false,
'Workspace.Blobs.List': true,
'Workspace.Blobs.Read': true,
'Workspace.Blobs.Write': true,
'Workspace.Copilot': true,
'Workspace.CreateDoc': true, 'Workspace.CreateDoc': true,
'Workspace.Delete': false, 'Workspace.Delete': false,
'Workspace.Organize.Read': true, 'Workspace.Organize.Read': true,
'Workspace.Payment.Manage': false,
'Workspace.Properties.Create': true, 'Workspace.Properties.Create': true,
'Workspace.Properties.Delete': true, 'Workspace.Properties.Delete': true,
'Workspace.Properties.Read': true, 'Workspace.Properties.Read': true,
'Workspace.Properties.Update': true, 'Workspace.Properties.Update': true,
'Workspace.Read': true,
'Workspace.Settings.Read': true, 'Workspace.Settings.Read': true,
'Workspace.Settings.Update': true, 'Workspace.Settings.Update': true,
'Workspace.Sync': true, 'Workspace.Sync': true,
@@ -145,13 +166,20 @@ Generated by [AVA](https://avajs.dev).
> WorkspaceRole: Owner > WorkspaceRole: Owner
{ {
'Workspace.Adminitrators.Manage': true,
'Workspace.Blobs.List': true,
'Workspace.Blobs.Read': true,
'Workspace.Blobs.Write': true,
'Workspace.Copilot': true,
'Workspace.CreateDoc': true, 'Workspace.CreateDoc': true,
'Workspace.Delete': true, 'Workspace.Delete': true,
'Workspace.Organize.Read': true, 'Workspace.Organize.Read': true,
'Workspace.Payment.Manage': true,
'Workspace.Properties.Create': true, 'Workspace.Properties.Create': true,
'Workspace.Properties.Delete': true, 'Workspace.Properties.Delete': true,
'Workspace.Properties.Read': true, 'Workspace.Properties.Read': true,
'Workspace.Properties.Update': true, 'Workspace.Properties.Update': true,
'Workspace.Read': true,
'Workspace.Settings.Read': true, 'Workspace.Settings.Read': true,
'Workspace.Settings.Update': true, 'Workspace.Settings.Update': true,
'Workspace.Sync': true, 'Workspace.Sync': true,
@@ -257,13 +285,20 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1 > Snapshot 1
{ {
'Workspace.Adminitrators.Manage': 'Owner',
'Workspace.Blobs.List': 'Collaborator',
'Workspace.Blobs.Read': 'External',
'Workspace.Blobs.Write': 'Collaborator',
'Workspace.Copilot': 'Collaborator',
'Workspace.CreateDoc': 'Collaborator', 'Workspace.CreateDoc': 'Collaborator',
'Workspace.Delete': 'Owner', 'Workspace.Delete': 'Owner',
'Workspace.Organize.Read': 'External', 'Workspace.Organize.Read': 'External',
'Workspace.Payment.Manage': 'Owner',
'Workspace.Properties.Create': 'Admin', 'Workspace.Properties.Create': 'Admin',
'Workspace.Properties.Delete': 'Admin', 'Workspace.Properties.Delete': 'Admin',
'Workspace.Properties.Read': 'Collaborator', 'Workspace.Properties.Read': 'External',
'Workspace.Properties.Update': 'Admin', 'Workspace.Properties.Update': 'Admin',
'Workspace.Read': 'External',
'Workspace.Settings.Read': 'Collaborator', 'Workspace.Settings.Read': 'Collaborator',
'Workspace.Settings.Update': 'Admin', 'Workspace.Settings.Update': 'Admin',
'Workspace.Sync': 'Collaborator', 'Workspace.Sync': 'Collaborator',

View File

@@ -37,7 +37,7 @@ test(`should be able to fixup doc role from workspace role and doc role`, t => {
for (const workspaceRole of workspaceRoles) { for (const workspaceRole of workspaceRoles) {
for (const docRole of docRoles) { for (const docRole of docRoles) {
t.snapshot( t.snapshot(
DocRole[fixupDocRole(workspaceRole, docRole)], DocRole[fixupDocRole(workspaceRole, docRole)!],
`WorkspaceRole: ${WorkspaceRole[workspaceRole]}, DocRole: ${DocRole[docRole]}` `WorkspaceRole: ${WorkspaceRole[workspaceRole]}, DocRole: ${DocRole[docRole]}`
); );
} }

View File

@@ -0,0 +1,48 @@
import test from 'ava';
import { DocID } from '../../utils/doc';
import { AccessControllerBuilder } from '../builder';
let builder: AccessControllerBuilder;
test.before(async () => {
builder = new AccessControllerBuilder();
});
test('should build correct workspace resource', t => {
t.deepEqual(builder.user('u1').workspace('ws1').data, {
userId: 'u1',
workspaceId: 'ws1',
});
t.deepEqual(builder.user('u1').workspace('ws1').allowLocal().data, {
allowLocal: true,
userId: 'u1',
workspaceId: 'ws1',
});
});
test('should build correct doc resource', t => {
const resources = [
builder.user('u1').workspace('ws1').doc('doc1').data,
builder.user('u1').doc('ws1', 'doc1').data,
builder.user('u1').doc({ workspaceId: 'ws1', docId: 'doc1' }).data,
builder.user('u1').doc(new DocID('ws1:space:doc1', 'ws1')).data,
];
t.deepEqual(
resources,
Array.from({ length: 4 }, () => ({
userId: 'u1',
workspaceId: 'ws1',
docId: 'doc1',
}))
);
t.deepEqual(builder.user('u1').doc('ws1', 'doc1').allowLocal().data, {
allowLocal: true,
docId: 'doc1',
userId: 'u1',
workspaceId: 'ws1',
});
});

View File

@@ -0,0 +1,160 @@
import test from 'ava';
import { createTestingModule, TestingModule } from '../../../__tests__/utils';
import {
Models,
User,
Workspace,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { PermissionModule } from '..';
import { DocAccessController } from '../doc';
import { DocRole, mapDocRoleToPermissions } from '../types';
let module: TestingModule;
let models: Models;
let ac: DocAccessController;
let user: User;
let ws: Workspace;
test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models);
ac = new DocAccessController(models);
});
test.beforeEach(async () => {
await module.initTestingDB();
user = await models.user.create({ email: 'u1@affine.pro' });
ws = await models.workspace.create(user.id);
});
test.after.always(async () => {
await module.close();
});
test('should get null role', async t => {
const role = await ac.getRole({
workspaceId: 'ws1',
docId: 'doc1',
userId: 'u1',
});
t.is(role, null);
});
test('should return null if workspace role is not accepted', async t => {
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
const role = await ac.getRole({
workspaceId: ws.id,
docId: 'doc1',
userId: u2.id,
});
t.is(role, null);
});
test('should return [Owner] role if workspace is not found but local is allowed', async t => {
const role = await ac.getRole({
workspaceId: 'ws1',
docId: 'doc1',
userId: 'u1',
allowLocal: true,
});
t.is(role, DocRole.Owner);
});
test('should fallback to [External] if workspace is public', async t => {
await models.workspace.update(ws.id, {
public: true,
});
const role = await ac.getRole({
workspaceId: ws.id,
docId: 'doc1',
userId: 'random-user-id',
});
t.is(role, DocRole.External);
});
test('should return null even if workspace has other public doc', async t => {
await models.workspace.publishDoc(ws.id, 'doc1');
const role = await ac.getRole({
workspaceId: ws.id,
docId: 'doc2',
userId: 'random-user-id',
});
t.is(role, null);
});
test('should return [External] if doc is public', async t => {
await models.workspace.publishDoc(ws.id, 'doc1');
const role = await ac.getRole({
workspaceId: ws.id,
docId: 'doc1',
userId: 'random-user-id',
});
t.is(role, DocRole.External);
});
test('should return mapped permissions', async t => {
const { permissions } = await ac.role({
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
});
t.deepEqual(permissions, mapDocRoleToPermissions(DocRole.Owner));
});
test('should assert action', async t => {
await t.notThrowsAsync(
ac.assert(
{
workspaceId: ws.id,
docId: 'doc1',
userId: user.id,
},
'Doc.Update'
)
);
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await t.throwsAsync(
ac.assert(
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
'Doc.Update'
)
);
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
);
await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager);
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, docId: 'doc1', userId: u2.id },
'Doc.Delete'
)
);
});

View File

@@ -0,0 +1,147 @@
import test from 'ava';
import { createTestingModule, TestingModule } from '../../../__tests__/utils';
import {
Models,
User,
Workspace,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { PermissionModule } from '..';
import { mapWorkspaceRoleToPermissions } from '../types';
import { WorkspaceAccessController } from '../workspace';
let module: TestingModule;
let models: Models;
let ac: WorkspaceAccessController;
let user: User;
let ws: Workspace;
test.before(async () => {
module = await createTestingModule({ imports: [PermissionModule] });
models = module.get<Models>(Models);
ac = new WorkspaceAccessController(models);
});
test.beforeEach(async () => {
await module.initTestingDB();
user = await models.user.create({ email: 'u1@affine.pro' });
ws = await models.workspace.create(user.id);
});
test.after.always(async () => {
await module.close();
});
test('should get null role', async t => {
const role = await ac.getRole({
workspaceId: 'ws1',
userId: 'u1',
});
t.is(role, null);
});
test('should return null if role is not accepted', async t => {
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
const role = await ac.getRole({
workspaceId: ws.id,
userId: u2.id,
});
t.is(role, null);
});
test('should return [Owner] role if workspace is not found but local is allowed', async t => {
const role = await ac.getRole({
workspaceId: 'ws1',
userId: 'u1',
allowLocal: true,
});
t.is(role, WorkspaceRole.Owner);
});
test('should fallback to [External] if workspace is public', async t => {
await models.workspace.update(ws.id, {
public: true,
});
const role = await ac.getRole({
workspaceId: ws.id,
userId: 'random-user-id',
});
t.is(role, WorkspaceRole.External);
});
test('should return null even workspace has public doc', async t => {
await models.workspace.publishDoc(ws.id, 'doc1');
const role = await ac.getRole({
workspaceId: ws.id,
userId: 'random-user-id',
});
t.is(role, null);
});
test('should return mapped external permission for workspace has public docs', async t => {
await models.workspace.publishDoc(ws.id, 'doc1');
const { permissions } = await ac.role({
workspaceId: ws.id,
userId: 'random-user-id',
});
t.deepEqual(
permissions,
mapWorkspaceRoleToPermissions(WorkspaceRole.External)
);
});
test('should return mapped permissions', async t => {
const { permissions } = await ac.role({
workspaceId: ws.id,
userId: user.id,
});
t.deepEqual(permissions, mapWorkspaceRoleToPermissions(WorkspaceRole.Owner));
});
test('should assert action', async t => {
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, userId: user.id },
'Workspace.TransferOwner'
)
);
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await t.throwsAsync(
ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync')
);
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
await t.notThrowsAsync(
ac.assert(
{ workspaceId: ws.id, userId: u2.id },
'Workspace.Settings.Update'
)
);
});

View File

@@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { DocID } from '../utils/doc';
import { getAccessController } from './controller';
import { Resource } from './resource';
import { DocAction, WorkspaceAction } from './types';
@Injectable()
export class AccessControllerBuilder {
user(userId: string) {
return new UserAccessControllerBuilder(userId);
}
}
export class UserAccessControllerBuilder {
constructor(private readonly userId: string) {}
workspace(workspaceId: string) {
return new WorkspaceAccessControllerBuilder({
userId: this.userId,
workspaceId,
});
}
doc(
docId: DocID | { workspaceId: string; docId: string }
): DocAccessControllerBuilder;
doc(workspaceId: string, docId: string): DocAccessControllerBuilder;
doc(
docIdOrWorkspaceId: string | DocID | { workspaceId: string; docId: string },
doc?: string
) {
let workspaceId: string;
let docId: string;
if (docIdOrWorkspaceId instanceof DocID) {
workspaceId = docIdOrWorkspaceId.workspace;
docId = docIdOrWorkspaceId.guid;
} else if (typeof docIdOrWorkspaceId === 'string') {
workspaceId = docIdOrWorkspaceId;
docId = doc as string;
} else {
workspaceId = docIdOrWorkspaceId.workspaceId;
docId = docIdOrWorkspaceId.docId;
}
return new DocAccessControllerBuilder({
userId: this.userId,
workspaceId,
docId,
});
}
}
class WorkspaceAccessControllerBuilder {
constructor(public readonly data: Resource<'ws'>) {}
allowLocal() {
this.data.allowLocal = true;
return this;
}
doc(docId: string) {
return new DocAccessControllerBuilder({
...this.data,
docId,
});
}
async assert(action: WorkspaceAction) {
const checker = getAccessController('ws');
await checker.assert(this.data, action);
}
async can(action: WorkspaceAction) {
const checker = getAccessController('ws');
return await checker.can(this.data, action);
}
async permissions() {
const checker = getAccessController('ws');
return await checker.role(this.data);
}
}
class DocAccessControllerBuilder {
constructor(public readonly data: Resource<'doc'>) {}
allowLocal() {
this.data.allowLocal = true;
return this;
}
async assert(action: DocAction) {
const checker = getAccessController('doc');
await checker.assert(this.data, action);
}
async can(action: DocAction) {
const checker = getAccessController('doc');
return await checker.can(this.data, action);
}
async permissions() {
const checker = getAccessController('doc');
return await checker.role(this.data);
}
}

View File

@@ -0,0 +1,53 @@
import { Logger, OnModuleInit } from '@nestjs/common';
import type {
Resource,
ResourceAction,
ResourceRole,
ResourceType,
} from './resource';
const ACTION_CHECKER_PROVIDERS = new Map<ResourceType, AccessController<any>>();
function registerAccessController<Type extends ResourceType>(
type: Type,
provider: AccessController<Type>
) {
ACTION_CHECKER_PROVIDERS.set(type, provider);
}
export function getAccessController<Type extends ResourceType>(
type: Type
): AccessController<Type> {
const provider = ACTION_CHECKER_PROVIDERS.get(type);
if (!provider) {
throw new Error(`No action checker provider for type ${type}`);
}
return provider;
}
export abstract class AccessController<Type extends ResourceType>
implements OnModuleInit
{
protected abstract readonly type: Type;
protected logger = new Logger(AccessController.name);
onModuleInit() {
registerAccessController(this.type, this);
}
abstract assert(
resource: Resource<Type>,
action: ResourceAction<Type>
): Promise<void>;
abstract can(
resource: Resource<Type>,
action: ResourceAction<Type>
): Promise<boolean>;
abstract role(resource: Resource<Type>): Promise<{
role: ResourceRole<Type> | null;
permissions: Record<ResourceAction<Type>, boolean>;
}>;
}

View File

@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { DocActionDenied } from '../../base';
import { Models } from '../../models';
import { AccessController, getAccessController } from './controller';
import type { Resource } from './resource';
import {
DocAction,
docActionRequiredRole,
DocRole,
fixupDocRole,
mapDocRoleToPermissions,
WorkspaceRole,
} from './types';
import { WorkspaceAccessController } from './workspace';
@Injectable()
export class DocAccessController extends AccessController<'doc'> {
protected readonly type = 'doc';
constructor(private readonly models: Models) {
super();
}
async role(resource: Resource<'doc'>) {
const role = await this.getRole(resource);
return {
role,
permissions: mapDocRoleToPermissions(role),
};
}
async can(resource: Resource<'doc'>, action: DocAction) {
const { permissions, role } = await this.role(resource);
const allow = permissions[action] || false;
if (!allow) {
this.logger.log('Doc access check failed', {
action,
resource,
role,
requiredRole: docActionRequiredRole(action),
});
}
return allow;
}
async assert(resource: Resource<'doc'>, action: DocAction) {
const allow = await this.can(resource, action);
if (!allow) {
throw new DocActionDenied({
docId: resource.docId,
spaceId: resource.workspaceId,
action,
});
}
}
async getRole(payload: Resource<'doc'>): Promise<DocRole | null> {
const workspaceController = getAccessController(
'ws'
) as WorkspaceAccessController;
const workspaceRole = await workspaceController.getRole(payload);
const userRole = await this.models.docUser.get(
payload.workspaceId,
payload.docId,
payload.userId
);
let docRole = userRole?.type ?? (null as DocRole | null);
// fallback logic
if (docRole === null) {
const defaultDocRole = await this.defaultDocRole(
payload.workspaceId,
payload.docId
);
// if user is in workspace but doc role is not set, fallback to default doc role
if (workspaceRole && workspaceRole !== WorkspaceRole.External) {
docRole = defaultDocRole.workspace;
} else {
// else fallback to external doc role
docRole = defaultDocRole.external;
}
}
// we need to fixup doc role to make sure it's not miss set
// for example: workspace owner will have doc owner role
// workspace external will not have role higher than editor
return fixupDocRole(workspaceRole, docRole);
}
private async defaultDocRole(workspaceId: string, docId: string) {
const doc = await this.models.workspace.getDoc(workspaceId, docId);
return {
external: doc?.public ? DocRole.External : null,
workspace: doc?.defaultRole ?? DocRole.Manager,
};
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '../../base';
import { Models } from '../../models';
@Injectable()
export class EventsListener {
constructor(private readonly models: Models) {}
@OnEvent('doc.created')
async setDefaultPageOwner(payload: Events['doc.created']) {
const { workspaceId, docId, editor } = payload;
if (!editor) {
return;
}
await this.models.docUser.setOwner(workspaceId, docId, editor);
}
}

View File

@@ -1,22 +1,26 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PermissionService } from './service'; import { AccessControllerBuilder } from './builder';
import { DocAccessController } from './doc';
import { EventsListener } from './event';
import { WorkspaceAccessController } from './workspace';
@Module({ @Module({
providers: [PermissionService], providers: [
exports: [PermissionService], WorkspaceAccessController,
DocAccessController,
AccessControllerBuilder,
EventsListener,
],
exports: [AccessControllerBuilder],
}) })
export class PermissionModule {} export class PermissionModule {}
export { PermissionService } from './service'; export { AccessControllerBuilder as AccessController } from './builder';
export { export {
DOC_ACTIONS, DOC_ACTIONS,
type DocAction, type DocAction,
DocRole, DocRole,
fixupDocRole,
mapDocRoleToPermissions,
mapWorkspaceRoleToPermissions,
PublicDocMode,
WORKSPACE_ACTIONS, WORKSPACE_ACTIONS,
type WorkspaceAction, type WorkspaceAction,
WorkspaceRole, WorkspaceRole,

View File

@@ -0,0 +1,42 @@
import { DocAction, DocRole, WorkspaceAction, WorkspaceRole } from './types';
export type ResourceType = 'ws' | 'doc';
interface WorkspaceResource {
type: 'ws';
payload: {
allowLocal?: boolean;
workspaceId: string;
userId: string;
};
action: WorkspaceAction;
role: WorkspaceRole;
}
interface DocResource {
type: 'doc';
payload: {
allowLocal?: boolean;
workspaceId: string;
docId: string;
userId: string;
};
action: DocAction;
role: DocRole;
}
export type KnownResource = WorkspaceResource | DocResource;
export type Resource<Type extends ResourceType = 'ws'> = Extract<
KnownResource,
{ type: Type }
>['payload'];
export type ResourceRole<Type extends ResourceType> = Extract<
KnownResource,
{ type: Type }
>['role'];
export type ResourceAction<Type extends ResourceType> = Extract<
KnownResource,
{ type: Type }
>['action'];

View File

@@ -1,798 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma, WorkspaceDocUserPermission } from '@prisma/client';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import { groupBy } from 'lodash-es';
import {
CanNotBatchGrantDocOwnerPermissions,
DocActionDenied,
EventBus,
OnEvent,
SpaceAccessDenied,
SpaceOwnerNotFound,
WorkspacePermissionNotFound,
} from '../../base';
import {
DocAction,
docActionRequiredRole,
docActionRequiredWorkspaceRole,
DocRole,
PublicDocMode,
WorkspaceRole,
} from './types';
@Injectable()
export class PermissionService {
private readonly logger = new Logger(PermissionService.name);
constructor(
private readonly prisma: PrismaClient,
private readonly event: EventBus
) {}
@OnEvent('doc.created')
async setDefaultPageOwner(payload: Events['doc.created']) {
const { workspaceId, docId, editor } = payload;
if (!editor) {
return;
}
await this.prisma.workspaceDocUserPermission.createMany({
data: {
workspaceId,
docId,
userId: editor,
type: DocRole.Owner,
createdAt: new Date(),
},
});
}
private get acceptedCondition() {
return [
{
accepted: true,
},
{
status: WorkspaceMemberStatus.Accepted,
},
];
}
/// Start regin: workspace permission
async get(ws: string, user: string): Promise<WorkspaceRole> {
const data = await this.prisma.workspaceUserPermission.findFirst({
where: {
workspaceId: ws,
userId: user,
OR: this.acceptedCondition,
},
});
if (!data) {
throw new WorkspacePermissionNotFound({ spaceId: ws });
}
return data.type;
}
/**
* check whether a workspace exists and has any one can access it
* @param workspaceId workspace id
* @returns
*/
async hasWorkspace(workspaceId: string) {
return await this.prisma.workspaceUserPermission
.count({
where: {
workspaceId,
OR: this.acceptedCondition,
},
})
.then(count => count > 0);
}
async getOwnedWorkspaces(userId: string) {
return this.prisma.workspaceUserPermission
.findMany({
where: {
userId,
type: WorkspaceRole.Owner,
OR: this.acceptedCondition,
},
select: {
workspaceId: true,
},
})
.then(data => data.map(({ workspaceId }) => workspaceId));
}
async getWorkspaceOwner(workspaceId: string) {
const owner = await this.prisma.workspaceUserPermission.findFirst({
where: {
workspaceId,
type: WorkspaceRole.Owner,
},
include: {
user: true,
},
});
if (!owner) {
throw new SpaceOwnerNotFound({ spaceId: workspaceId });
}
return owner.user;
}
async getWorkspaceAdmin(workspaceId: string) {
const admin = await this.prisma.workspaceUserPermission.findMany({
where: {
workspaceId,
type: WorkspaceRole.Admin,
},
include: {
user: true,
},
});
return admin.map(({ user }) => user);
}
async getWorkspaceMemberCount(workspaceId: string) {
return this.prisma.workspaceUserPermission.count({
where: {
workspaceId,
},
});
}
async tryGetWorkspaceOwner(workspaceId: string) {
return this.prisma.workspaceUserPermission.findFirst({
where: {
workspaceId,
type: WorkspaceRole.Owner,
},
include: {
user: true,
},
});
}
/**
* check if a doc binary is accessible by a user
*/
async isPublicAccessible(
ws: string,
id: string,
user?: string
): Promise<boolean> {
if (ws === id) {
// if workspace is public or have any public page, then allow to access
const [isPublicWorkspace, publicPages] = await Promise.all([
this.tryCheckWorkspace(ws, user, WorkspaceRole.Collaborator),
this.prisma.workspaceDoc.count({
where: {
workspaceId: ws,
public: true,
},
}),
]);
return isPublicWorkspace || publicPages > 0;
}
return this.tryCheckPage(ws, id, 'Doc.Read', user);
}
async getWorkspaceMemberStatus(ws: string, user: string) {
return this.prisma.workspaceUserPermission
.findFirst({
where: {
workspaceId: ws,
userId: user,
},
select: { status: true },
})
.then(r => r?.status);
}
/**
* Returns whether a given user is a member of a workspace and has the given or higher permission.
*/
async isWorkspaceMember(
ws: string,
user: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
): Promise<boolean> {
const count = await this.prisma.workspaceUserPermission.count({
where: {
workspaceId: ws,
userId: user,
OR: this.acceptedCondition,
type: {
gte: permission,
},
},
});
return count !== 0;
}
/**
* only check permission if the workspace is a cloud workspace
* @param workspaceId workspace id
* @param userId user id, check if is a public workspace if not provided
* @param permission default is read
*/
async checkCloudWorkspace(
workspaceId: string,
userId?: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
const hasWorkspace = await this.hasWorkspace(workspaceId);
if (hasWorkspace) {
await this.checkWorkspace(workspaceId, userId, permission);
}
}
async checkWorkspace(
ws: string,
user?: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
if (!(await this.tryCheckWorkspace(ws, user, permission))) {
throw new SpaceAccessDenied({ spaceId: ws });
}
}
async tryCheckWorkspace(
ws: string,
user?: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
// If the permission is read, we should check if the workspace is public
if (permission === WorkspaceRole.Collaborator) {
const count = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
// workspace is public
// accessible
if (count > 0) {
return true;
}
}
if (user) {
// normally check if the user has the permission
const count = await this.prisma.workspaceUserPermission.count({
where: {
workspaceId: ws,
userId: user,
OR: this.acceptedCondition,
type: {
gte: permission,
},
},
});
if (count > 0) {
return true;
} else {
const info = {
workspaceId: ws,
userId: user,
requiredRole: WorkspaceRole[permission],
};
this.logger.log(
`User's WorkspaceRole is lower than required (${JSON.stringify(info)})`
);
}
}
// unsigned in, workspace is not public
// unaccessible
return false;
}
async checkWorkspaceIs(
ws: string,
user: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
if (!(await this.tryCheckWorkspaceIs(ws, user, permission))) {
throw new SpaceAccessDenied({ spaceId: ws });
}
}
async tryCheckWorkspaceIs(
ws: string,
user: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
const count = await this.prisma.workspaceUserPermission.count({
where: {
workspaceId: ws,
userId: user,
OR: this.acceptedCondition,
type: permission,
},
});
return count > 0;
}
async allowUrlPreview(ws: string) {
const count = await this.prisma.workspace.count({
where: {
id: ws,
enableUrlPreview: true,
},
});
return count > 0;
}
private getAllowedStatusSource(
to: WorkspaceMemberStatus
): WorkspaceMemberStatus[] {
switch (to) {
case WorkspaceMemberStatus.NeedMoreSeat:
return [WorkspaceMemberStatus.Pending];
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
return [WorkspaceMemberStatus.UnderReview];
case WorkspaceMemberStatus.Pending:
case WorkspaceMemberStatus.UnderReview:
return [WorkspaceMemberStatus.Accepted];
default:
return [];
}
}
async grant(
ws: string,
user: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
): Promise<string> {
const data = await this.prisma.workspaceUserPermission.findFirst({
where: { workspaceId: ws, userId: user },
});
if (data) {
const toBeOwner = permission === WorkspaceRole.Owner;
if (data.accepted && data.status === WorkspaceMemberStatus.Accepted) {
const [p] = await this.prisma.$transaction(
[
this.prisma.workspaceUserPermission.update({
where: {
workspaceId_userId: { workspaceId: ws, userId: user },
},
data: { type: permission },
}),
// If the new permission is owner, we need to revoke old owner
toBeOwner
? this.prisma.workspaceUserPermission.updateMany({
where: {
workspaceId: ws,
type: WorkspaceRole.Owner,
userId: { not: user },
},
data: { type: WorkspaceRole.Admin },
})
: null,
].filter(Boolean) as Prisma.PrismaPromise<any>[]
);
return p.id;
}
const allowedStatus = this.getAllowedStatusSource(data.status);
if (allowedStatus.includes(status)) {
const ret = await this.prisma.workspaceUserPermission.update({
where: { workspaceId_userId: { workspaceId: ws, userId: user } },
data: { status },
});
return ret.id;
}
return data.id;
}
return this.prisma.workspaceUserPermission
.create({
data: {
workspaceId: ws,
userId: user,
type: permission,
status,
},
})
.then(p => p.id);
}
async acceptWorkspaceInvitation(
invitationId: string,
workspaceId: string,
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted
) {
const result = await this.prisma.workspaceUserPermission.updateMany({
where: {
id: invitationId,
workspaceId: workspaceId,
AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }],
},
data: { accepted: true, status },
});
return result.count > 0;
}
async refreshSeatStatus(workspaceId: string, memberLimit: number) {
const usedCount = await this.prisma.workspaceUserPermission.count({
where: { workspaceId, status: WorkspaceMemberStatus.Accepted },
});
const availableCount = memberLimit - usedCount;
if (availableCount <= 0) {
return;
}
await this.prisma.$transaction(async tx => {
const members = await tx.workspaceUserPermission.findMany({
select: { id: true, status: true },
where: {
workspaceId,
status: {
in: [
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
],
},
},
orderBy: { createdAt: 'asc' },
});
const needChange = members.slice(0, availableCount);
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
needChange,
m => m.status
);
const toPendings = NeedMoreSeat ?? [];
if (toPendings.length > 0) {
await tx.workspaceUserPermission.updateMany({
where: { id: { in: toPendings.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.Pending },
});
}
const toUnderReviewUserIds = NeedMoreSeatAndReview ?? [];
if (toUnderReviewUserIds.length > 0) {
await tx.workspaceUserPermission.updateMany({
where: { id: { in: toUnderReviewUserIds.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.UnderReview },
});
}
return [toPendings, toUnderReviewUserIds] as const;
});
}
async revokeWorkspace(workspaceId: string, user: string) {
const permission = await this.prisma.workspaceUserPermission.findUnique({
where: { workspaceId_userId: { workspaceId, userId: user } },
});
// We shouldn't revoke owner permission
// should auto deleted by workspace/user delete cascading
if (!permission || permission.type === WorkspaceRole.Owner) {
return false;
}
await this.prisma.workspaceUserPermission.deleteMany({
where: {
workspaceId,
userId: user,
},
});
const count = await this.prisma.workspaceUserPermission.count({
where: { workspaceId },
});
this.event.emit('workspace.members.updated', {
workspaceId,
count,
});
this.event.emit('workspace.members.removed', {
workspaceId,
userId: user,
});
if (
permission.status === 'UnderReview' ||
permission.status === 'NeedMoreSeatAndReview'
) {
this.event.emit('workspace.members.requestDeclined', {
userId: user,
workspaceId,
});
}
return true;
}
/// End regin: workspace permission
/// Start regin: page permission
/**
* only check permission if the workspace is a cloud workspace
* @param workspaceId workspace id
* @param pageId page id aka doc id
* @param userId user id, check if is a public page if not provided
* @param permission default is read
*/
async checkCloudPagePermission(
workspaceId: string,
pageId: string,
action: DocAction,
userId?: string
) {
const hasWorkspace = await this.hasWorkspace(workspaceId);
if (hasWorkspace) {
await this.checkPagePermission(workspaceId, pageId, action, userId);
}
}
async checkPagePermission(
ws: string,
page: string,
action: DocAction,
user?: string
) {
if (!(await this.tryCheckPage(ws, page, action, user))) {
throw new DocActionDenied({ spaceId: ws, docId: page, action });
}
}
async tryCheckPage(
ws: string,
doc: string,
action: DocAction,
user?: string
) {
const role = docActionRequiredRole(action);
// check whether page is public
if (action === 'Doc.Read') {
const count = await this.prisma.workspaceDoc.count({
where: {
workspaceId: ws,
docId: doc,
public: true,
},
});
// page is public
// accessible
if (count > 0) {
return true;
}
}
if (user) {
const [roleEntity, pageEntity, workspaceRoleEntity] = await Promise.all([
this.prisma.workspaceDocUserPermission.findFirst({
where: {
workspaceId: ws,
docId: doc,
userId: user,
},
select: {
type: true,
},
}),
this.prisma.workspaceDoc.findFirst({
where: {
workspaceId: ws,
docId: doc,
},
select: {
defaultRole: true,
},
}),
this.prisma.workspaceUserPermission.findFirst({
where: {
workspaceId: ws,
userId: user,
OR: this.acceptedCondition,
},
select: {
type: true,
},
}),
]);
const defaultPageRole = pageEntity?.defaultRole ?? DocRole.Manager;
if (
// Page role exists, check it first
(roleEntity && roleEntity.type >= role) ||
// if
// - page has a default role
// - the user is in this workspace
// - the user is not an external user in this workspace
// then use the max of the two
(workspaceRoleEntity &&
workspaceRoleEntity.type !== WorkspaceRole.External &&
Math.max(
roleEntity?.type ?? Number.MIN_SAFE_INTEGER,
defaultPageRole
) >= role)
) {
return true;
}
const info = {
workspaceId: ws,
docId: doc,
userId: user,
workspaceRole: workspaceRoleEntity
? WorkspaceRole[workspaceRoleEntity.type]
: undefined,
pageRole: roleEntity ? DocRole[roleEntity.type] : undefined,
pageDefaultRole: DocRole[defaultPageRole],
requiredRole: DocRole[role],
action,
};
this.logger.log(
`Page role is lower than required, continue to check workspace permission (${JSON.stringify(info)})`
);
}
// check whether user has workspace related permission
return this.tryCheckWorkspace(
ws,
user,
docActionRequiredWorkspaceRole(action)
);
}
async isPublicPage(ws: string, doc: string) {
return this.prisma.workspaceDoc
.count({
where: {
workspaceId: ws,
docId: doc,
public: true,
},
})
.then(count => count > 0);
}
async publishPage(ws: string, doc: string, mode = PublicDocMode.Page) {
return this.prisma.workspaceDoc.upsert({
where: {
workspaceId_docId: {
workspaceId: ws,
docId: doc,
},
},
update: {
public: true,
mode,
},
create: {
workspaceId: ws,
docId: doc,
mode,
public: true,
},
});
}
async revokePublicPage(ws: string, doc: string) {
return this.prisma.workspaceDoc.upsert({
where: {
workspaceId_docId: {
workspaceId: ws,
docId: doc,
},
},
update: {
public: false,
},
create: {
workspaceId: ws,
docId: doc,
public: false,
},
});
}
async grantPage(ws: string, doc: string, user: string, permission: DocRole) {
const [p] = await this.prisma.$transaction(
[
this.prisma.workspaceDocUserPermission.upsert({
where: {
workspaceId_docId_userId: {
workspaceId: ws,
docId: doc,
userId: user,
},
},
update: {
type: permission,
},
create: {
workspaceId: ws,
docId: doc,
userId: user,
type: permission,
},
}),
// If the new permission is owner, we need to revoke old owner
permission === DocRole.Owner
? this.prisma.workspaceDocUserPermission.updateMany({
where: {
workspaceId: ws,
docId: doc,
type: DocRole.Owner,
userId: {
not: user,
},
},
data: {
type: DocRole.Manager,
},
})
: null,
].filter(Boolean) as Prisma.PrismaPromise<any>[]
);
return p as WorkspaceDocUserPermission;
}
async revokePage(ws: string, doc: string, user: string) {
const result = await this.prisma.workspaceDocUserPermission.deleteMany({
where: {
workspaceId: ws,
docId: doc,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: DocRole.Owner,
},
},
});
return result.count > 0;
}
async batchGrantPage(
workspaceId: string,
docId: string,
userIds: string[],
role: DocRole
) {
if (userIds.length === 0) {
return 0;
}
if (role === DocRole.Owner) {
throw new CanNotBatchGrantDocOwnerPermissions();
}
const result = await this.prisma.workspaceDocUserPermission.createMany({
skipDuplicates: true,
data: userIds.map(id => ({
workspaceId,
docId,
userId: id,
type: role,
})),
});
return result.count;
}
}

View File

@@ -1,25 +1,7 @@
import { LeafPaths, LeafVisitor } from '../../base'; import { LeafPaths, LeafVisitor } from '../../base';
import { DocRole, WorkspaceRole } from '../../models';
export enum PublicDocMode { export { DocRole, WorkspaceRole };
Page,
Edgeless,
}
export enum DocRole {
External = 0,
Reader = 10,
Editor = 20,
Manager = 30,
Owner = 99,
}
export enum WorkspaceRole {
External = -99,
Collaborator = 1,
Admin = 10,
Owner = 99,
}
/** /**
* Definitions of all possible actions * Definitions of all possible actions
* *
@@ -28,6 +10,7 @@ export enum WorkspaceRole {
export const Actions = { export const Actions = {
// Workspace Actions // Workspace Actions
Workspace: { Workspace: {
Read: '',
Sync: '', Sync: '',
CreateDoc: '', CreateDoc: '',
Delete: '', Delete: '',
@@ -39,6 +22,9 @@ export const Actions = {
Read: '', Read: '',
Manage: '', Manage: '',
}, },
Adminitrators: {
Manage: '',
},
Properties: { Properties: {
Read: '', Read: '',
Create: '', Create: '',
@@ -49,6 +35,15 @@ export const Actions = {
Read: '', Read: '',
Update: '', Update: '',
}, },
Blobs: {
Read: '',
List: '',
Write: '',
},
Copilot: '',
Payment: {
Manage: '',
},
}, },
// Doc Actions // Doc Actions
@@ -76,7 +71,12 @@ export const Actions = {
export const RoleActionsMap = { export const RoleActionsMap = {
WorkspaceRole: { WorkspaceRole: {
get [WorkspaceRole.External]() { get [WorkspaceRole.External]() {
return [Action.Workspace.Organize.Read]; return [
Action.Workspace.Read,
Action.Workspace.Organize.Read,
Action.Workspace.Properties.Read,
Action.Workspace.Blobs.Read,
];
}, },
get [WorkspaceRole.Collaborator]() { get [WorkspaceRole.Collaborator]() {
return [ return [
@@ -84,8 +84,10 @@ export const RoleActionsMap = {
Action.Workspace.Sync, Action.Workspace.Sync,
Action.Workspace.CreateDoc, Action.Workspace.CreateDoc,
Action.Workspace.Users.Read, Action.Workspace.Users.Read,
Action.Workspace.Properties.Read,
Action.Workspace.Settings.Read, Action.Workspace.Settings.Read,
Action.Workspace.Blobs.Write,
Action.Workspace.Blobs.List,
Action.Workspace.Copilot,
]; ];
}, },
get [WorkspaceRole.Admin]() { get [WorkspaceRole.Admin]() {
@@ -102,7 +104,9 @@ export const RoleActionsMap = {
return [ return [
...this[WorkspaceRole.Admin], ...this[WorkspaceRole.Admin],
Action.Workspace.Delete, Action.Workspace.Delete,
Action.Workspace.Adminitrators.Manage,
Action.Workspace.TransferOwner, Action.Workspace.TransferOwner,
Action.Workspace.Payment.Manage,
]; ];
}, },
}, },
@@ -187,7 +191,9 @@ export const WORKSPACE_ACTIONS =
RoleActionsMap.WorkspaceRole[WorkspaceRole.Owner]; RoleActionsMap.WorkspaceRole[WorkspaceRole.Owner];
export const DOC_ACTIONS = RoleActionsMap.DocRole[DocRole.Owner]; export const DOC_ACTIONS = RoleActionsMap.DocRole[DocRole.Owner];
export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) { export function mapWorkspaceRoleToPermissions(
workspaceRole: WorkspaceRole | null
) {
const permissions = WORKSPACE_ACTIONS.reduce( const permissions = WORKSPACE_ACTIONS.reduce(
(map, action) => { (map, action) => {
map[action] = false; map[action] = false;
@@ -196,6 +202,10 @@ export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) {
{} as Record<WorkspaceAction, boolean> {} as Record<WorkspaceAction, boolean>
); );
if (workspaceRole === null) {
return permissions;
}
RoleActionsMap.WorkspaceRole[workspaceRole].forEach(action => { RoleActionsMap.WorkspaceRole[workspaceRole].forEach(action => {
permissions[action] = true; permissions[action] = true;
}); });
@@ -203,7 +213,7 @@ export function mapWorkspaceRoleToPermissions(workspaceRole: WorkspaceRole) {
return permissions; return permissions;
} }
export function mapDocRoleToPermissions(docRole: DocRole) { export function mapDocRoleToPermissions(docRole: DocRole | null) {
const permissions = DOC_ACTIONS.reduce( const permissions = DOC_ACTIONS.reduce(
(map, action) => { (map, action) => {
map[action] = false; map[action] = false;
@@ -212,6 +222,10 @@ export function mapDocRoleToPermissions(docRole: DocRole) {
{} as Record<DocAction, boolean> {} as Record<DocAction, boolean>
); );
if (docRole === null) {
return permissions;
}
RoleActionsMap.DocRole[docRole].forEach(action => { RoleActionsMap.DocRole[docRole].forEach(action => {
permissions[action] = true; permissions[action] = true;
}); });
@@ -232,9 +246,16 @@ export function mapDocRoleToPermissions(docRole: DocRole) {
* fixupDocRole(WorkspaceRole.Owner, DocRole.External) // returns DocRole.Manager * fixupDocRole(WorkspaceRole.Owner, DocRole.External) // returns DocRole.Manager
*/ */
export function fixupDocRole( export function fixupDocRole(
workspaceRole: WorkspaceRole = WorkspaceRole.External, workspaceRole: WorkspaceRole | null,
docRole: DocRole = DocRole.External docRole: DocRole | null
): DocRole { ): DocRole | null {
if (workspaceRole === null && docRole === null) {
return null;
}
workspaceRole = workspaceRole ?? WorkspaceRole.External;
docRole = docRole ?? DocRole.External;
switch (workspaceRole) { switch (workspaceRole) {
case WorkspaceRole.External: case WorkspaceRole.External:
// Workspace External user won't be able to have any high permission doc role // Workspace External user won't be able to have any high permission doc role

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { SpaceAccessDenied } from '../../base';
import { Models } from '../../models';
import { AccessController } from './controller';
import type { Resource } from './resource';
import {
mapWorkspaceRoleToPermissions,
WorkspaceAction,
workspaceActionRequiredRole,
WorkspaceRole,
} from './types';
@Injectable()
export class WorkspaceAccessController extends AccessController<'ws'> {
protected readonly type = 'ws';
constructor(private readonly models: Models) {
super();
}
async role(resource: Resource<'ws'>) {
let role = await this.getRole(resource);
// NOTE(@forehalo): special case for public page
// Currently, we can not only load binary of a public Doc to render in a shared page,
// so we need to ensure anyone has basic 'read' permission to a workspace that has public pages.
if (
!role &&
(await this.models.workspace.hasPublicDoc(resource.workspaceId))
) {
role = WorkspaceRole.External;
}
return {
role,
permissions: mapWorkspaceRoleToPermissions(role),
};
}
async can(resource: Resource<'ws'>, action: WorkspaceAction) {
const { permissions, role } = await this.role(resource);
const allow = permissions[action] || false;
if (!allow) {
this.logger.log('Workspace access check failed', {
action,
resource,
role,
requiredRole: workspaceActionRequiredRole(action),
});
}
return allow;
}
async assert(resource: Resource<'ws'>, action: WorkspaceAction) {
const allow = await this.can(resource, action);
if (!allow) {
throw new SpaceAccessDenied({ spaceId: resource.workspaceId });
}
}
async getRole(payload: Resource<'ws'>) {
const userRole = await this.models.workspaceUser.getActive(
payload.workspaceId,
payload.userId
);
let role = userRole?.type as WorkspaceRole | null;
if (!role) {
role = await this.defaultWorkspaceRole(payload);
}
return role;
}
private async defaultWorkspaceRole(payload: Resource<'ws'>) {
const ws = await this.models.workspace.get(payload.workspaceId);
// NOTE(@forehalo):
// we allow user to use online service with local workspace
// so we always return owner role for local workspace
// copilot session for local workspace is an example
if (!ws) {
if (payload.allowLocal) {
return WorkspaceRole.Owner;
}
return null;
}
if (ws.public) {
return WorkspaceRole.External;
}
return null;
}
}

View File

@@ -5,8 +5,8 @@ import {
Models, Models,
type UserQuota, type UserQuota,
WorkspaceQuota as BaseWorkspaceQuota, WorkspaceQuota as BaseWorkspaceQuota,
WorkspaceRole,
} from '../../models'; } from '../../models';
import { PermissionService } from '../permission';
import { WorkspaceBlobStorage } from '../storage'; import { WorkspaceBlobStorage } from '../storage';
import { import {
UserQuotaHumanReadableType, UserQuotaHumanReadableType,
@@ -28,7 +28,6 @@ export class QuotaService {
constructor( constructor(
private readonly models: Models, private readonly models: Models,
private readonly permissions: PermissionService,
private readonly storage: WorkspaceBlobStorage private readonly storage: WorkspaceBlobStorage
) {} ) {}
@@ -73,12 +72,20 @@ export class QuotaService {
} }
async getUserStorageUsage(userId: string) { async getUserStorageUsage(userId: string) {
const workspaces = await this.permissions.getOwnedWorkspaces(userId); const workspaces = await this.models.workspaceUser.getUserActiveRoles(
userId,
{
role: WorkspaceRole.Owner,
}
);
const ids = workspaces.map(w => w.workspaceId);
const workspacesWithQuota = const workspacesWithQuota =
await this.models.workspaceFeature.batchHasQuota(workspaces); await this.models.workspaceFeature.batchHasQuota(ids);
const sizes = await Promise.allSettled( const sizes = await Promise.allSettled(
workspaces ids
.filter(w => !workspacesWithQuota.includes(w)) .filter(w => !workspacesWithQuota.includes(w))
.map(workspace => this.storage.totalSize(workspace)) .map(workspace => this.storage.totalSize(workspace))
); );
@@ -116,8 +123,7 @@ export class QuotaService {
if (!quota) { if (!quota) {
// get and convert to workspace quota from owner's quota // get and convert to workspace quota from owner's quota
// TODO(@forehalo): replace it with `WorkspaceRoleModel` when it's ready const owner = await this.models.workspaceUser.getOwner(workspaceId);
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const ownerQuota = await this.getUserQuota(owner.id); const ownerQuota = await this.getUserQuota(owner.id);
return { return {
@@ -136,8 +142,7 @@ export class QuotaService {
const usedStorageQuota = quota.ownerQuota const usedStorageQuota = quota.ownerQuota
? await this.getUserStorageUsage(quota.ownerQuota) ? await this.getUserStorageUsage(quota.ownerQuota)
: await this.getWorkspaceStorageUsage(workspaceId); : await this.getWorkspaceStorageUsage(workspaceId);
const memberCount = const memberCount = await this.models.workspaceUser.count(workspaceId);
await this.permissions.getWorkspaceMemberCount(workspaceId);
return { return {
...quota, ...quota,
@@ -165,8 +170,7 @@ export class QuotaService {
async getWorkspaceSeatQuota(workspaceId: string) { async getWorkspaceSeatQuota(workspaceId: string) {
const quota = await this.getWorkspaceQuota(workspaceId); const quota = await this.getWorkspaceQuota(workspaceId);
const memberCount = const memberCount = await this.models.workspaceUser.count(workspaceId);
await this.permissions.getWorkspaceMemberCount(workspaceId);
return { return {
memberCount, memberCount,

View File

@@ -13,6 +13,19 @@ import {
StorageProviderFactory, StorageProviderFactory,
} from '../../../base'; } from '../../../base';
declare global {
interface Events {
'workspace.blob.sync': {
workspaceId: string;
key: string;
};
'workspace.blob.delete': {
workspaceId: string;
key: string;
};
}
}
@Injectable() @Injectable()
export class WorkspaceBlobStorage { export class WorkspaceBlobStorage {
private readonly logger = new Logger(WorkspaceBlobStorage.name); private readonly logger = new Logger(WorkspaceBlobStorage.name);

View File

@@ -27,7 +27,7 @@ import {
PgUserspaceDocStorageAdapter, PgUserspaceDocStorageAdapter,
PgWorkspaceDocStorageAdapter, PgWorkspaceDocStorageAdapter,
} from '../doc'; } from '../doc';
import { PermissionService, WorkspaceRole } from '../permission'; import { AccessController, WorkspaceAction } from '../permission';
import { DocID } from '../utils/doc'; import { DocID } from '../utils/doc';
const SubscribeMessage = (event: string) => const SubscribeMessage = (event: string) =>
@@ -144,7 +144,7 @@ export class SpaceSyncGateway
constructor( constructor(
private readonly runtime: Runtime, private readonly runtime: Runtime,
private readonly permissions: PermissionService, private readonly ac: AccessController,
private readonly workspace: PgWorkspaceDocStorageAdapter, private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly userspace: PgUserspaceDocStorageAdapter, private readonly userspace: PgUserspaceDocStorageAdapter,
private readonly docReader: DocReader private readonly docReader: DocReader
@@ -170,7 +170,7 @@ export class SpaceSyncGateway
const workspace = new WorkspaceSyncAdapter( const workspace = new WorkspaceSyncAdapter(
client, client,
this.workspace, this.workspace,
this.permissions, this.ac,
this.docReader this.docReader
); );
const userspace = new UserspaceSyncAdapter(client, this.userspace); const userspace = new UserspaceSyncAdapter(client, this.userspace);
@@ -248,12 +248,13 @@ export class SpaceSyncGateway
): Promise< ): Promise<
EventResponse<{ missing: string; state: string; timestamp: number }> EventResponse<{ missing: string; state: string; timestamp: number }>
> { > {
const id = new DocID(docId, spaceId);
const adapter = this.selectAdapter(client, spaceType); const adapter = this.selectAdapter(client, spaceType);
adapter.assertIn(spaceId); adapter.assertIn(spaceId);
const doc = await adapter.diff( const doc = await adapter.diff(
spaceId, spaceId,
docId, id.guid,
stateVector ? Buffer.from(stateVector, 'base64') : undefined stateVector ? Buffer.from(stateVector, 'base64') : undefined
); );
@@ -293,11 +294,12 @@ export class SpaceSyncGateway
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> { ): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
const { spaceType, spaceId, docId, updates } = message; const { spaceType, spaceId, docId, updates } = message;
const adapter = this.selectAdapter(client, spaceType); const adapter = this.selectAdapter(client, spaceType);
const id = new DocID(docId, spaceId);
// TODO(@forehalo): we might need to check write permission before push updates await this.ac.user(user.id).doc(spaceId, id.guid).assert('Doc.Update');
const timestamp = await adapter.push( const timestamp = await adapter.push(
spaceId, spaceId,
docId, id.guid,
updates.map(update => Buffer.from(update, 'base64')), updates.map(update => Buffer.from(update, 'base64')),
user.id user.id
); );
@@ -334,7 +336,7 @@ export class SpaceSyncGateway
const { spaceType, spaceId, docId, update } = message; const { spaceType, spaceId, docId, update } = message;
const adapter = this.selectAdapter(client, spaceType); const adapter = this.selectAdapter(client, spaceType);
// TODO(@forehalo): we might need to check write permission before push updates await this.ac.user(user.id).doc(spaceId, docId).assert('Doc.Update');
const timestamp = await adapter.push( const timestamp = await adapter.push(
spaceId, spaceId,
docId, docId,
@@ -472,7 +474,7 @@ abstract class SyncSocketAdapter {
if (this.in(spaceId, roomType)) { if (this.in(spaceId, roomType)) {
return; return;
} }
await this.assertAccessible(spaceId, userId, WorkspaceRole.Collaborator); await this.assertAccessible(spaceId, userId, 'Workspace.Sync');
return this.client.join(this.room(spaceId, roomType)); return this.client.join(this.room(spaceId, roomType));
} }
@@ -496,7 +498,7 @@ abstract class SyncSocketAdapter {
abstract assertAccessible( abstract assertAccessible(
spaceId: string, spaceId: string,
userId: string, userId: string,
permission?: WorkspaceRole action: WorkspaceAction
): Promise<void>; ): Promise<void>;
push(spaceId: string, docId: string, updates: Buffer[], editorId: string) { push(spaceId: string, docId: string, updates: Buffer[], editorId: string) {
@@ -525,7 +527,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
constructor( constructor(
client: Socket, client: Socket,
storage: DocStorageAdapter, storage: DocStorageAdapter,
private readonly permission: PermissionService, private readonly ac: AccessController,
private readonly docReader: DocReader private readonly docReader: DocReader
) { ) {
super(SpaceType.Workspace, client, storage); super(SpaceType.Workspace, client, storage);
@@ -537,8 +539,7 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
updates: Buffer[], updates: Buffer[],
editorId: string editorId: string
) { ) {
const id = new DocID(docId, spaceId); return super.push(spaceId, docId, updates, editorId);
return super.push(spaceId, id.guid, updates, editorId);
} }
override async diff( override async diff(
@@ -546,20 +547,15 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
docId: string, docId: string,
stateVector?: Uint8Array stateVector?: Uint8Array
) { ) {
const id = new DocID(docId, spaceId); return await this.docReader.getDocDiff(spaceId, docId, stateVector);
return await this.docReader.getDocDiff(spaceId, id.guid, stateVector);
} }
async assertAccessible( async assertAccessible(
spaceId: string, spaceId: string,
userId: string, userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator action: WorkspaceAction
) { ) {
if ( await this.ac.user(userId).workspace(spaceId).assert(action);
!(await this.permission.isWorkspaceMember(spaceId, userId, permission))
) {
throw new SpaceAccessDenied({ spaceId });
}
} }
} }
@@ -571,7 +567,7 @@ class UserspaceSyncAdapter extends SyncSocketAdapter {
async assertAccessible( async assertAccessible(
spaceId: string, spaceId: string,
userId: string, userId: string,
_permission: WorkspaceRole = WorkspaceRole.Collaborator _action: WorkspaceAction
) { ) {
if (spaceId !== userId) { if (spaceId !== userId) {
throw new SpaceAccessDenied({ spaceId }); throw new SpaceAccessDenied({ spaceId });

View File

@@ -1,20 +1,18 @@
import { Controller, Get, Logger, Param, Res } from '@nestjs/common'; import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { Response } from 'express'; import type { Response } from 'express';
import { import {
AccessDenied,
ActionForbidden,
BlobNotFound, BlobNotFound,
CallMetric, CallMetric,
DocHistoryNotFound, DocHistoryNotFound,
DocNotFound, DocNotFound,
InvalidHistoryTimestamp, InvalidHistoryTimestamp,
} from '../../base'; } from '../../base';
import { Models, PublicDocMode } from '../../models';
import { CurrentUser, Public } from '../auth'; import { CurrentUser, Public } from '../auth';
import { PgWorkspaceDocStorageAdapter } from '../doc'; import { PgWorkspaceDocStorageAdapter } from '../doc';
import { DocReader } from '../doc/reader'; import { DocReader } from '../doc/reader';
import { PermissionService, PublicDocMode } from '../permission'; import { AccessController } from '../permission';
import { WorkspaceBlobStorage } from '../storage'; import { WorkspaceBlobStorage } from '../storage';
import { DocID } from '../utils/doc'; import { DocID } from '../utils/doc';
@@ -23,10 +21,10 @@ export class WorkspacesController {
logger = new Logger(WorkspacesController.name); logger = new Logger(WorkspacesController.name);
constructor( constructor(
private readonly storage: WorkspaceBlobStorage, private readonly storage: WorkspaceBlobStorage,
private readonly permission: PermissionService, private readonly ac: AccessController,
private readonly workspace: PgWorkspaceDocStorageAdapter, private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly docReader: DocReader, private readonly docReader: DocReader,
private readonly prisma: PrismaClient private readonly models: Models
) {} ) {}
// get workspace blob // get workspace blob
@@ -41,18 +39,10 @@ export class WorkspacesController {
@Param('name') name: string, @Param('name') name: string,
@Res() res: Response @Res() res: Response
) { ) {
// if workspace is public or have any public page, then allow to access await this.ac
// otherwise, check permission .user(user?.id ?? 'anonymous')
if ( .workspace(workspaceId)
!(await this.permission.isPublicAccessible( .assert('Workspace.Read');
workspaceId,
workspaceId,
user?.id
))
) {
throw new ActionForbidden();
}
const { body, metadata } = await this.storage.get(workspaceId, name); const { body, metadata } = await this.storage.get(workspaceId, name);
if (!body) { if (!body) {
@@ -86,17 +76,17 @@ export class WorkspacesController {
@Res() res: Response @Res() res: Response
) { ) {
const docId = new DocID(guid, ws); const docId = new DocID(guid, ws);
if ( if (docId.isWorkspace) {
// if a user has the permission await this.ac
!(await this.permission.isPublicAccessible( .user(user?.id ?? 'anonymous')
docId.workspace, .workspace(ws)
docId.guid, .assert('Workspace.Read');
user?.id } else {
)) await this.ac
) { .user(user?.id ?? 'anonymous')
throw new AccessDenied(); .doc(ws, guid)
.assert('Doc.Read');
} }
const binResponse = await this.docReader.getDoc( const binResponse = await this.docReader.getDoc(
docId.workspace, docId.workspace,
docId.guid docId.guid
@@ -111,16 +101,12 @@ export class WorkspacesController {
if (!docId.isWorkspace) { if (!docId.isWorkspace) {
// fetch the publish page mode for publish page // fetch the publish page mode for publish page
const publishPage = await this.prisma.workspaceDoc.findUnique({ const doc = await this.models.workspace.getDoc(
where: { docId.workspace,
workspaceId_docId: { docId.guid
workspaceId: docId.workspace, );
docId: docId.guid,
},
},
});
const publishPageMode = const publishPageMode =
publishPage?.mode === PublicDocMode.Edgeless ? 'edgeless' : 'page'; doc?.mode === PublicDocMode.Edgeless ? 'edgeless' : 'page';
res.setHeader('publish-mode', publishPageMode); res.setHeader('publish-mode', publishPageMode);
} }
@@ -146,12 +132,7 @@ export class WorkspacesController {
throw new InvalidHistoryTimestamp({ timestamp }); throw new InvalidHistoryTimestamp({ timestamp });
} }
await this.permission.checkPagePermission( await this.ac.user(user.id).doc(ws, guid).assert('Doc.Read');
docId.workspace,
docId.guid,
'Doc.Read',
user.id
);
const history = await this.workspace.getDocHistory( const history = await this.workspace.getDocHistory(
docId.workspace, docId.workspace,

View File

@@ -44,21 +44,21 @@ export class WorkspaceEvents {
async onRoleChanged({ async onRoleChanged({
userId, userId,
workspaceId, workspaceId,
permission, role,
}: Events['workspace.members.roleChanged']) { }: Events['workspace.members.roleChanged']) {
// send role changed mail // send role changed mail
await this.workspaceService.sendRoleChangedEmail(userId, { await this.workspaceService.sendRoleChangedEmail(userId, {
id: workspaceId, id: workspaceId,
role: permission, role,
}); });
} }
@OnEvent('workspace.members.ownershipTransferred') @OnEvent('workspace.owner.changed')
async onOwnerTransferred({ async onOwnerTransferred({
workspaceId, workspaceId,
from, from,
to, to,
}: Events['workspace.members.ownershipTransferred']) { }: Events['workspace.owner.changed']) {
// send ownership transferred mail // send ownership transferred mail
const fromUser = await this.models.user.getPublicUser(from); const fromUser = await this.models.user.getPublicUser(from);
const toUser = await this.models.user.getPublicUser(to); const toUser = await this.models.user.getPublicUser(to);

View File

@@ -15,7 +15,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../base'; import type { FileUpload } from '../../../base';
import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base'; import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base';
import { CurrentUser } from '../../auth'; import { CurrentUser } from '../../auth';
import { PermissionService, WorkspaceRole } from '../../permission'; import { AccessController } from '../../permission';
import { QuotaService } from '../../quota'; import { QuotaService } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobStorage } from '../../storage';
import { WorkspaceBlobSizes, WorkspaceType } from '../types'; import { WorkspaceBlobSizes, WorkspaceType } from '../types';
@@ -40,7 +40,7 @@ class ListedBlob {
export class WorkspaceBlobResolver { export class WorkspaceBlobResolver {
logger = new Logger(WorkspaceBlobResolver.name); logger = new Logger(WorkspaceBlobResolver.name);
constructor( constructor(
private readonly permissions: PermissionService, private readonly ac: AccessController,
private readonly quota: QuotaService, private readonly quota: QuotaService,
private readonly storage: WorkspaceBlobStorage private readonly storage: WorkspaceBlobStorage
) {} ) {}
@@ -53,7 +53,10 @@ export class WorkspaceBlobResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType @Parent() workspace: WorkspaceType
) { ) {
await this.permissions.checkWorkspace(workspace.id, user.id); await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Blobs.List');
return this.storage.list(workspace.id); return this.storage.list(workspace.id);
} }
@@ -66,24 +69,6 @@ export class WorkspaceBlobResolver {
return this.storage.totalSize(workspace.id); return this.storage.totalSize(workspace.id);
} }
/**
* @deprecated use `workspace.blobs` instead
*/
@Query(() => [String], {
description: 'List blobs of workspace',
deprecationReason: 'use `workspace.blobs` instead',
})
async listBlobs(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.permissions.checkWorkspace(workspaceId, user.id);
return this.storage
.list(workspaceId)
.then(list => list.map(item => item.key));
}
@Query(() => WorkspaceBlobSizes, { @Query(() => WorkspaceBlobSizes, {
deprecationReason: 'use `user.quotaUsage` instead', deprecationReason: 'use `user.quotaUsage` instead',
}) })
@@ -99,11 +84,10 @@ export class WorkspaceBlobResolver {
@Args({ name: 'blob', type: () => GraphQLUpload }) @Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload blob: FileUpload
) { ) {
await this.permissions.checkWorkspace( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Collaborator .assert('Workspace.Blobs.Write');
);
const checkExceeded = const checkExceeded =
await this.quota.getWorkspaceQuotaCalculator(workspaceId); await this.quota.getWorkspaceQuotaCalculator(workspaceId);
@@ -159,7 +143,10 @@ export class WorkspaceBlobResolver {
return false; return false;
} }
await this.permissions.checkWorkspace(workspaceId, user.id); await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Blobs.Write');
await this.storage.delete(workspaceId, key, permanently); await this.storage.delete(workspaceId, key, permanently);
@@ -171,11 +158,10 @@ export class WorkspaceBlobResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string @Args('workspaceId') workspaceId: string
) { ) {
await this.permissions.checkWorkspace( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Collaborator .assert('Workspace.Blobs.Write');
);
await this.storage.release(workspaceId); await this.storage.release(workspaceId);

View File

@@ -17,7 +17,6 @@ import {
Cache, Cache,
DocActionDenied, DocActionDenied,
DocDefaultRoleCanNotBeOwner, DocDefaultRoleCanNotBeOwner,
DocIsNotPublic,
ExpectToGrantDocUserRoles, ExpectToGrantDocUserRoles,
ExpectToPublishDoc, ExpectToPublishDoc,
ExpectToRevokeDocUserRoles, ExpectToRevokeDocUserRoles,
@@ -28,21 +27,20 @@ import {
PaginationInput, PaginationInput,
registerObjectType, registerObjectType,
} from '../../../base'; } from '../../../base';
import { Models } from '../../../models'; import { Models, PublicDocMode } from '../../../models';
import { CurrentUser } from '../../auth'; import { CurrentUser } from '../../auth';
import { import {
AccessController,
DOC_ACTIONS, DOC_ACTIONS,
DocAction, DocAction,
DocRole, DocRole,
fixupDocRole,
mapDocRoleToPermissions,
PermissionService,
PublicDocMode,
} from '../../permission'; } from '../../permission';
import { PublicUserType } from '../../user'; import { PublicUserType } from '../../user';
import { DocID } from '../../utils/doc';
import { WorkspaceType } from '../types'; import { WorkspaceType } from '../types';
import { DotToUnderline, mapPermissionToGraphqlPermissions } from './workspace'; import {
DotToUnderline,
mapPermissionsToGraphqlPermissions,
} from './workspace';
registerEnumType(PublicDocMode, { registerEnumType(PublicDocMode, {
name: 'PublicDocMode', name: 'PublicDocMode',
@@ -155,8 +153,11 @@ export class WorkspaceDocResolver {
private readonly logger = new Logger(WorkspaceDocResolver.name); private readonly logger = new Logger(WorkspaceDocResolver.name);
constructor( constructor(
/**
* @deprecated migrate to models
*/
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly permission: PermissionService, private readonly ac: AccessController,
private readonly models: Models, private readonly models: Models,
private readonly cache: Cache private readonly cache: Cache
) {} ) {}
@@ -174,12 +175,7 @@ export class WorkspaceDocResolver {
complexity: 2, complexity: 2,
}) })
async publicDocs(@Parent() workspace: WorkspaceType) { async publicDocs(@Parent() workspace: WorkspaceType) {
return this.prisma.workspaceDoc.findMany({ return this.models.workspace.getPublicDocs(workspace.id);
where: {
workspaceId: workspace.id,
public: true,
},
});
} }
@ResolveField(() => DocType, { @ResolveField(() => DocType, {
@@ -203,14 +199,7 @@ export class WorkspaceDocResolver {
@Parent() workspace: WorkspaceType, @Parent() workspace: WorkspaceType,
@Args('docId') docId: string @Args('docId') docId: string
): Promise<DocType> { ): Promise<DocType> {
const doc = await this.prisma.workspaceDoc.findUnique({ const doc = await this.models.workspace.getDoc(workspace.id, docId);
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId,
},
},
});
if (doc) { if (doc) {
return doc; return doc;
@@ -249,7 +238,7 @@ export class WorkspaceDocResolver {
async publishDoc( async publishDoc(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('docId') rawDocId: string, @Args('docId') docId: string,
@Args({ @Args({
name: 'mode', name: 'mode',
type: () => PublicDocMode, type: () => PublicDocMode,
@@ -258,28 +247,27 @@ export class WorkspaceDocResolver {
}) })
mode: PublicDocMode mode: PublicDocMode
) { ) {
const docId = new DocID(rawDocId, workspaceId); if (workspaceId === docId) {
if (docId.isWorkspace) {
this.logger.error('Expect to publish doc, but it is a workspace', { this.logger.error('Expect to publish doc, but it is a workspace', {
workspaceId, workspaceId,
docId: rawDocId, docId,
}); });
throw new ExpectToPublishDoc(); throw new ExpectToPublishDoc();
} }
await this.permission.checkPagePermission( await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
docId.workspace,
docId.guid, const doc = await this.models.workspace.publishDoc(
'Doc.Publish', workspaceId,
user.id docId,
mode
); );
this.logger.log( this.logger.log(
`Publish page ${rawDocId} with mode ${mode} in workspace ${workspaceId}` `Publish page ${docId} with mode ${mode} in workspace ${workspaceId}`
); );
return this.permission.publishPage(docId.workspace, docId.guid, mode); return doc;
} }
@Mutation(() => DocType, { @Mutation(() => DocType, {
@@ -297,44 +285,23 @@ export class WorkspaceDocResolver {
async revokePublicDoc( async revokePublicDoc(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('docId') rawDocId: string @Args('docId') docId: string
) { ) {
const docId = new DocID(rawDocId, workspaceId); if (workspaceId === docId) {
if (docId.isWorkspace) {
this.logger.error('Expect to revoke public doc, but it is a workspace', { this.logger.error('Expect to revoke public doc, but it is a workspace', {
workspaceId, workspaceId,
docId: rawDocId, docId,
}); });
throw new ExpectToRevokePublicDoc('Expect doc not to be workspace'); throw new ExpectToRevokePublicDoc('Expect doc not to be workspace');
} }
await this.permission.checkPagePermission( await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
docId.workspace,
docId.guid,
'Doc.Publish',
user.id
);
const isPublic = await this.permission.isPublicPage( const doc = await this.models.workspace.revokePublicDoc(workspaceId, docId);
docId.workspace,
docId.guid
);
const info = { this.logger.log(`Revoke public doc ${docId} in workspace ${workspaceId}`);
workspaceId,
docId: rawDocId,
};
if (!isPublic) {
this.logger.log(
`Expect to revoke public doc, but it is not public (${JSON.stringify(info)})`
);
throw new DocIsNotPublic('Doc is not public');
}
this.logger.log(`Revoke public doc (${JSON.stringify(info)})`); return doc;
return this.permission.revokePublicPage(docId.workspace, docId.guid);
} }
private async tryFixDocOwner(workspaceId: string, docId: string) { private async tryFixDocOwner(workspaceId: string, docId: string) {
@@ -357,13 +324,7 @@ export class WorkspaceDocResolver {
return; return;
} }
const owner = await this.prisma.workspaceDocUserPermission.findFirst({ const owner = await this.models.docUser.getOwner(workspaceId, docId);
where: {
workspaceId,
docId,
type: DocRole.Owner,
},
});
// skip if owner already exists // skip if owner already exists
if (owner) { if (owner) {
@@ -387,18 +348,11 @@ export class WorkspaceDocResolver {
// try workspace.owner // try workspace.owner
if (!fixedOwner) { if (!fixedOwner) {
const owner = await this.permission.getWorkspaceOwner(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId);
fixedOwner = owner.id; fixedOwner = owner.id;
} }
await this.prisma.workspaceDocUserPermission.createMany({ await this.models.docUser.setOwner(workspaceId, docId, fixedOwner);
data: {
workspaceId,
docId,
userId: fixedOwner,
type: DocRole.Owner,
},
});
this.logger.debug( this.logger.debug(
`Fixed doc owner for ${docId} in workspace ${workspaceId}, new owner: ${fixedOwner}` `Fixed doc owner for ${docId} in workspace ${workspaceId}, new owner: ${fixedOwner}`
@@ -411,8 +365,7 @@ export class DocResolver {
private readonly logger = new Logger(DocResolver.name); private readonly logger = new Logger(DocResolver.name);
constructor( constructor(
private readonly prisma: PrismaClient, private readonly ac: AccessController,
private readonly permission: PermissionService,
private readonly models: Models private readonly models: Models
) {} ) {}
@@ -421,33 +374,9 @@ export class DocResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Parent() doc: DocType @Parent() doc: DocType
): Promise<InstanceType<typeof DocPermissions>> { ): Promise<InstanceType<typeof DocPermissions>> {
const [permission, workspacePermission] = await this.prisma.$transaction( const { permissions } = await this.ac.user(user.id).doc(doc).permissions();
tx =>
Promise.all([
tx.workspaceDocUserPermission.findFirst({
where: {
workspaceId: doc.workspaceId,
docId: doc.docId,
userId: user.id,
},
}),
tx.workspaceUserPermission.findFirst({
where: {
workspaceId: doc.workspaceId,
userId: user.id,
},
}),
])
);
return mapPermissionToGraphqlPermissions( return mapPermissionsToGraphqlPermissions(permissions);
mapDocRoleToPermissions(
fixupDocRole(
workspacePermission?.type,
permission?.type ?? doc.defaultRole
)
)
);
} }
@ResolveField(() => PaginatedGrantedDocUserType, { @ResolveField(() => PaginatedGrantedDocUserType, {
@@ -459,49 +388,20 @@ export class DocResolver {
@Parent() doc: DocType, @Parent() doc: DocType,
@Args('pagination', PaginationInput.decode) pagination: PaginationInput @Args('pagination', PaginationInput.decode) pagination: PaginationInput
): Promise<PaginatedGrantedDocUserType> { ): Promise<PaginatedGrantedDocUserType> {
await this.permission.checkPagePermission( await this.ac.user(user.id).doc(doc).assert('Doc.Users.Read');
const [permissions, totalCount] = await this.models.docUser.paginate(
doc.workspaceId, doc.workspaceId,
doc.docId, doc.docId,
'Doc.Users.Read', pagination
user.id
); );
const [permissions, totalCount] = await this.prisma.$transaction(tx => {
return Promise.all([
tx.workspaceDocUserPermission.findMany({
where: {
workspaceId: doc.workspaceId,
docId: doc.docId,
createdAt: pagination.after
? {
gt: pagination.after,
}
: undefined,
},
orderBy: [
{
type: 'desc',
},
{
createdAt: 'desc',
},
],
take: pagination.first,
skip: pagination.offset,
}),
tx.workspaceDocUserPermission.count({
where: {
workspaceId: doc.workspaceId,
docId: doc.docId,
},
}),
]);
});
const publicUsers = await this.models.user.getPublicUsers( const publicUsers = await this.models.user.getPublicUsers(
permissions.map(p => p.userId) permissions.map(p => p.userId)
); );
const publicUsersMap = new Map(publicUsers.map(pu => [pu.id, pu])); const publicUsersMap = new Map(publicUsers.map(pu => [pu.id, pu]));
return paginate( return paginate(
permissions.map(p => ({ permissions.map(p => ({
...p, ...p,
@@ -518,12 +418,12 @@ export class DocResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('input') input: GrantDocUserRolesInput @Args('input') input: GrantDocUserRolesInput
): Promise<boolean> { ): Promise<boolean> {
const doc = new DocID(input.docId, input.workspaceId);
const pairs = { const pairs = {
spaceId: input.workspaceId, spaceId: input.workspaceId,
docId: input.docId, docId: input.docId,
}; };
if (doc.isWorkspace) {
if (input.workspaceId === input.docId) {
this.logger.error( this.logger.error(
'Expect to grant doc user roles, but it is a workspace', 'Expect to grant doc user roles, but it is a workspace',
pairs pairs
@@ -533,18 +433,16 @@ export class DocResolver {
'Expect doc not to be workspace' 'Expect doc not to be workspace'
); );
} }
await this.permission.checkPagePermission(
doc.workspace, await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
doc.guid,
'Doc.Users.Manage', await this.models.docUser.batchSetUserRoles(
user.id input.workspaceId,
); input.docId,
await this.permission.batchGrantPage(
doc.workspace,
doc.guid,
input.userIds, input.userIds,
input.role input.role
); );
const info = { const info = {
...pairs, ...pairs,
userIds: input.userIds, userIds: input.userIds,
@@ -559,12 +457,11 @@ export class DocResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('input') input: RevokeDocUserRoleInput @Args('input') input: RevokeDocUserRoleInput
): Promise<boolean> { ): Promise<boolean> {
const doc = new DocID(input.docId, input.workspaceId);
const pairs = { const pairs = {
spaceId: input.workspaceId, spaceId: input.workspaceId,
docId: doc.guid, docId: input.docId,
}; };
if (doc.isWorkspace) { if (input.workspaceId === input.docId) {
this.logger.error( this.logger.error(
'Expect to revoke doc user roles, but it is a workspace', 'Expect to revoke doc user roles, but it is a workspace',
pairs pairs
@@ -574,13 +471,14 @@ export class DocResolver {
'Expect doc not to be workspace' 'Expect doc not to be workspace'
); );
} }
await this.permission.checkPagePermission( await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
doc.workspace,
doc.guid, await this.models.docUser.delete(
'Doc.Users.Manage', input.workspaceId,
user.id input.docId,
input.userId
); );
await this.permission.revokePage(doc.workspace, doc.guid, input.userId);
const info = { const info = {
...pairs, ...pairs,
userId: input.userId, userId: input.userId,
@@ -594,12 +492,11 @@ export class DocResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('input') input: UpdateDocUserRoleInput @Args('input') input: UpdateDocUserRoleInput
): Promise<boolean> { ): Promise<boolean> {
const doc = new DocID(input.docId, input.workspaceId);
const pairs = { const pairs = {
spaceId: doc.workspace, spaceId: input.workspaceId,
docId: doc.guid, docId: input.docId,
}; };
if (doc.isWorkspace) { if (input.workspaceId === input.docId) {
this.logger.error( this.logger.error(
'Expect to update doc user role, but it is a workspace', 'Expect to update doc user role, but it is a workspace',
pairs pairs
@@ -610,28 +507,28 @@ export class DocResolver {
); );
} }
await this.permission.checkPagePermission(
doc.workspace,
doc.guid,
input.role === DocRole.Owner ? 'Doc.TransferOwner' : 'Doc.Users.Manage',
user.id
);
await this.permission.grantPage(
doc.workspace,
doc.guid,
input.userId,
input.role
);
const info = { const info = {
...pairs, ...pairs,
userId: input.userId, userId: input.userId,
role: input.role, role: input.role,
}; };
if (input.role === DocRole.Owner) { if (input.role === DocRole.Owner) {
await this.ac.user(user.id).doc(input).assert('Doc.TransferOwner');
await this.models.docUser.setOwner(
input.workspaceId,
input.docId,
input.userId
);
this.logger.log(`Transfer doc owner (${JSON.stringify(info)})`); this.logger.log(`Transfer doc owner (${JSON.stringify(info)})`);
} else { } else {
await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
await this.models.docUser.set(
input.workspaceId,
input.docId,
input.userId,
input.role
);
this.logger.log(`Update doc user role (${JSON.stringify(info)})`); this.logger.log(`Update doc user role (${JSON.stringify(info)})`);
} }
@@ -649,12 +546,11 @@ export class DocResolver {
); );
throw new DocDefaultRoleCanNotBeOwner(); throw new DocDefaultRoleCanNotBeOwner();
} }
const doc = new DocID(input.docId, input.workspaceId);
const pairs = { const pairs = {
spaceId: doc.workspace, spaceId: input.workspaceId,
docId: doc.guid, docId: input.docId,
}; };
if (doc.isWorkspace) { if (input.workspaceId === input.docId) {
this.logger.error( this.logger.error(
'Expect to update page default role, but it is a workspace', 'Expect to update page default role, but it is a workspace',
pairs pairs
@@ -665,12 +561,7 @@ export class DocResolver {
); );
} }
try { try {
await this.permission.checkPagePermission( await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
doc.workspace,
doc.guid,
'Doc.Users.Manage',
user.id
);
} catch (error) { } catch (error) {
if (error instanceof DocActionDenied) { if (error instanceof DocActionDenied) {
this.logger.log( this.logger.log(
@@ -684,22 +575,11 @@ export class DocResolver {
} }
throw error; throw error;
} }
await this.prisma.workspaceDoc.upsert({ await this.models.workspace.setDocDefaultRole(
where: { input.workspaceId,
workspaceId_docId: { input.docId,
workspaceId: doc.workspace, input.role
docId: doc.guid, );
},
},
update: {
defaultRole: input.role,
},
create: {
workspaceId: doc.workspace,
docId: doc.guid,
defaultRole: input.role,
},
});
return true; return true;
} }
} }

View File

@@ -13,7 +13,7 @@ import type { SnapshotHistory } from '@prisma/client';
import { CurrentUser } from '../../auth'; import { CurrentUser } from '../../auth';
import { PgWorkspaceDocStorageAdapter } from '../../doc'; import { PgWorkspaceDocStorageAdapter } from '../../doc';
import { PermissionService } from '../../permission'; import { AccessController } from '../../permission';
import { DocID } from '../../utils/doc'; import { DocID } from '../../utils/doc';
import { WorkspaceType } from '../types'; import { WorkspaceType } from '../types';
import { EditorType } from './workspace'; import { EditorType } from './workspace';
@@ -37,7 +37,7 @@ class DocHistoryType implements Partial<SnapshotHistory> {
export class DocHistoryResolver { export class DocHistoryResolver {
constructor( constructor(
private readonly workspace: PgWorkspaceDocStorageAdapter, private readonly workspace: PgWorkspaceDocStorageAdapter,
private readonly permission: PermissionService private readonly ac: AccessController
) {} ) {}
@ResolveField(() => [DocHistoryType]) @ResolveField(() => [DocHistoryType])
@@ -76,12 +76,7 @@ export class DocHistoryResolver {
): Promise<Date> { ): Promise<Date> {
const docId = new DocID(guid, workspaceId); const docId = new DocID(guid, workspaceId);
await this.permission.checkPagePermission( await this.ac.user(user.id).doc(docId).assert('Doc.Update');
docId.workspace,
docId.guid,
'Doc.Update',
user.id
);
await this.workspace.rollbackDoc( await this.workspace.rollbackDoc(
docId.workspace, docId.workspace,

View File

@@ -1,17 +1,17 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream'; import { getStreamAsBuffer } from 'get-stream';
import { import {
Cache, Cache,
MailService, MailService,
NotFound,
OnEvent, OnEvent,
URLHelper, URLHelper,
UserNotFound, UserNotFound,
} from '../../../base'; } from '../../../base';
import { Models } from '../../../models'; import { Models } from '../../../models';
import { DocReader } from '../../doc'; import { DocReader } from '../../doc';
import { PermissionService, WorkspaceRole } from '../../permission'; import { WorkspaceRole } from '../../permission';
import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobStorage } from '../../storage';
export const defaultWorkspaceAvatar = export const defaultWorkspaceAvatar =
@@ -32,8 +32,6 @@ export class WorkspaceService {
private readonly cache: Cache, private readonly cache: Cache,
private readonly doc: DocReader, private readonly doc: DocReader,
private readonly mailer: MailService, private readonly mailer: MailService,
private readonly permission: PermissionService,
private readonly prisma: PrismaClient,
private readonly models: Models, private readonly models: Models,
private readonly url: URLHelper private readonly url: URLHelper
) {} ) {}
@@ -47,20 +45,16 @@ export class WorkspaceService {
return invite; return invite;
} }
return await this.prisma.workspaceUserPermission const workspaceUser = await this.models.workspaceUser.getById(inviteId);
.findUniqueOrThrow({
where: { if (!workspaceUser) {
id: inviteId, throw new NotFound('Invitation not found');
}, }
select: {
workspaceId: true, return {
userId: true, workspaceId: workspaceUser.workspaceId,
}, inviteeUserId: workspaceUser.userId,
}) };
.then(r => ({
workspaceId: r.workspaceId,
inviteeUserId: r.userId,
}));
} }
async getWorkspaceInfo(workspaceId: string) { async getWorkspaceInfo(workspaceId: string) {
@@ -115,7 +109,7 @@ export class WorkspaceService {
: null; : null;
const inviter = inviterUserId const inviter = inviterUserId
? await this.models.user.getPublicUser(inviterUserId) ? await this.models.user.getPublicUser(inviterUserId)
: await this.permission.getWorkspaceOwner(workspaceId); : await this.models.workspaceUser.getOwner(workspaceId);
if (!inviter || !invitee) { if (!inviter || !invitee) {
this.logger.error( this.logger.error(
@@ -138,7 +132,7 @@ export class WorkspaceService {
return; return;
} }
const owner = await this.permission.getWorkspaceOwner(target.workspace.id); const owner = await this.models.workspaceUser.getOwner(target.workspace.id);
await this.mailer.sendMemberInviteMail(target.email, { await this.mailer.sendMemberInviteMail(target.email, {
workspace: target.workspace, workspace: target.workspace,
@@ -154,8 +148,8 @@ export class WorkspaceService {
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) { async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId);
const admins = await this.permission.getWorkspaceAdmin(workspaceId); const admins = await this.models.workspaceUser.getAdmins(workspaceId);
await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, { await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, {
workspace, workspace,
@@ -188,8 +182,8 @@ export class WorkspaceService {
} }
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId);
const admin = await this.permission.getWorkspaceAdmin(workspaceId); const admin = await this.models.workspaceUser.getAdmins(workspaceId);
for (const user of [owner, ...admin]) { for (const user of [owner, ...admin]) {
await this.mailer.sendLinkInvitationReviewRequestMail(user.email, { await this.mailer.sendLinkInvitationReviewRequestMail(user.email, {
@@ -260,7 +254,7 @@ export class WorkspaceService {
workspaceId, workspaceId,
}: Events['workspace.members.leave']) { }: Events['workspace.members.leave']) {
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId);
await this.mailer.sendMemberLeaveEmail(owner.email, { await this.mailer.sendMemberLeaveEmail(owner.email, {
workspace, workspace,
user, user,
@@ -271,7 +265,7 @@ export class WorkspaceService {
async onMemberRemoved({ async onMemberRemoved({
userId, userId,
workspaceId, workspaceId,
}: Events['workspace.members.requestDeclined']) { }: Events['workspace.members.removed']) {
const user = await this.models.user.get(userId); const user = await this.models.user.get(userId);
if (!user) return; if (!user) return;

View File

@@ -6,7 +6,7 @@ import {
ResolveField, ResolveField,
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import { WorkspaceMemberStatus } from '@prisma/client';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { import {
@@ -17,11 +17,10 @@ import {
RequestMutex, RequestMutex,
TooManyRequest, TooManyRequest,
URLHelper, URLHelper,
UserFriendlyError,
} from '../../../base'; } from '../../../base';
import { Models } from '../../../models'; import { Models } from '../../../models';
import { CurrentUser } from '../../auth'; import { CurrentUser } from '../../auth';
import { PermissionService, WorkspaceRole } from '../../permission'; import { AccessController, WorkspaceRole } from '../../permission';
import { QuotaService } from '../../quota'; import { QuotaService } from '../../quota';
import { import {
InviteLink, InviteLink,
@@ -44,8 +43,7 @@ export class TeamWorkspaceResolver {
private readonly cache: Cache, private readonly cache: Cache,
private readonly event: EventBus, private readonly event: EventBus,
private readonly url: URLHelper, private readonly url: URLHelper,
private readonly prisma: PrismaClient, private readonly ac: AccessController,
private readonly permissions: PermissionService,
private readonly models: Models, private readonly models: Models,
private readonly quota: QuotaService, private readonly quota: QuotaService,
private readonly mutex: RequestMutex, private readonly mutex: RequestMutex,
@@ -68,21 +66,20 @@ export class TeamWorkspaceResolver {
@Args({ name: 'emails', type: () => [String] }) emails: string[], @Args({ name: 'emails', type: () => [String] }) emails: string[],
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean @Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
) { ) {
await this.permissions.checkWorkspace( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Admin .assert('Workspace.Users.Manage');
);
if (emails.length > 512) { if (emails.length > 512) {
return new TooManyRequest(); throw new TooManyRequest();
} }
// lock to prevent concurrent invite // lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`; const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag); await using lock = await this.mutex.acquire(lockFlag);
if (!lock) { if (!lock) {
return new TooManyRequest(); throw new TooManyRequest();
} }
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId); const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
@@ -93,13 +90,10 @@ export class TeamWorkspaceResolver {
try { try {
let target = await this.models.user.getUserByEmail(email); let target = await this.models.user.getUserByEmail(email);
if (target) { if (target) {
const originRecord = const originRecord = await this.models.workspaceUser.get(
await this.prisma.workspaceUserPermission.findFirst({ workspaceId,
where: { target.id
workspaceId, );
userId: target.id,
},
});
// only invite if the user is not already in the workspace // only invite if the user is not already in the workspace
if (originRecord) continue; if (originRecord) continue;
} else { } else {
@@ -110,7 +104,7 @@ export class TeamWorkspaceResolver {
} }
const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit; const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit;
ret.inviteId = await this.permissions.grant( const role = await this.models.workspaceUser.set(
workspaceId, workspaceId,
target.id, target.id,
WorkspaceRole.Collaborator, WorkspaceRole.Collaborator,
@@ -118,6 +112,7 @@ export class TeamWorkspaceResolver {
? WorkspaceMemberStatus.NeedMoreSeat ? WorkspaceMemberStatus.NeedMoreSeat
: WorkspaceMemberStatus.Pending : WorkspaceMemberStatus.Pending
); );
ret.inviteId = role.id;
// NOTE: we always send email even seat not enough // NOTE: we always send email even seat not enough
// because at this moment we cannot know whether the seat increase charge was successful // because at this moment we cannot know whether the seat increase charge was successful
// after user click the invite link, we can check again and reject if charge failed // after user click the invite link, we can check again and reject if charge failed
@@ -156,11 +151,10 @@ export class TeamWorkspaceResolver {
@Parent() workspace: WorkspaceType, @Parent() workspace: WorkspaceType,
@CurrentUser() user: CurrentUser @CurrentUser() user: CurrentUser
) { ) {
await this.permissions.checkWorkspace( await this.ac
workspace.id, .user(user.id)
user.id, .workspace(workspace.id)
WorkspaceRole.Admin .assert('Workspace.Users.Manage');
);
const cacheId = `workspace:inviteLink:${workspace.id}`; const cacheId = `workspace:inviteLink:${workspace.id}`;
const id = await this.cache.get<{ inviteId: string }>(cacheId); const id = await this.cache.get<{ inviteId: string }>(cacheId);
@@ -183,11 +177,11 @@ export class TeamWorkspaceResolver {
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime }) @Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
expireTime: WorkspaceInviteLinkExpireTime expireTime: WorkspaceInviteLinkExpireTime
): Promise<InviteLink> { ): Promise<InviteLink> {
await this.permissions.checkWorkspace( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Admin .assert('Workspace.Users.Manage');
);
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`; const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId); const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
if (typeof invite?.inviteId === 'string') { if (typeof invite?.inviteId === 'string') {
@@ -219,134 +213,80 @@ export class TeamWorkspaceResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string @Args('workspaceId') workspaceId: string
) { ) {
await this.permissions.checkWorkspace( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Admin .assert('Workspace.Users.Manage');
);
const cacheId = `workspace:inviteLink:${workspaceId}`; const cacheId = `workspace:inviteLink:${workspaceId}`;
return await this.cache.delete(cacheId); return await this.cache.delete(cacheId);
} }
@Mutation(() => String) @Mutation(() => Boolean)
async approveMember( async approveMember(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('userId') userId: string @Args('userId') userId: string
) { ) {
await this.permissions.checkWorkspace( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Admin .assert('Workspace.Users.Manage');
);
try { const role = await this.models.workspaceUser.get(workspaceId, userId);
// lock to prevent concurrent invite and grant
const lockFlag = `invite:${workspaceId}`; if (role) {
await using lock = await this.mutex.acquire(lockFlag); if (role.status === WorkspaceMemberStatus.UnderReview) {
if (!lock) { const result = await this.models.workspaceUser.setStatus(
return new TooManyRequest(); workspaceId,
userId,
WorkspaceMemberStatus.Accepted
);
this.event.emit('workspace.members.requestApproved', {
inviteId: result.id,
});
} }
return true;
const status = await this.permissions.getWorkspaceMemberStatus( } else {
workspaceId, throw new MemberNotFoundInSpace({ spaceId: workspaceId });
userId
);
if (status) {
if (status === WorkspaceMemberStatus.UnderReview) {
const result = await this.permissions.grant(
workspaceId,
userId,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
);
if (result) {
this.event.emit('workspace.members.requestApproved', {
inviteId: result,
});
}
return result;
}
return new TooManyRequest();
} else {
return new MemberNotFoundInSpace({ spaceId: workspaceId });
}
} catch (e) {
this.logger.error('failed to invite user', e);
return new TooManyRequest();
} }
} }
@Mutation(() => String) @Mutation(() => Boolean)
async grantMember( async grantMember(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('userId') userId: string, @Args('userId') userId: string,
@Args('permission', { type: () => WorkspaceRole }) permission: WorkspaceRole @Args('permission', { type: () => WorkspaceRole }) newRole: WorkspaceRole
) { ) {
// non-team workspace can only transfer ownership, but no detailed permission control await this.ac
if (permission !== WorkspaceRole.Owner) { .user(user.id)
.workspace(workspaceId)
.assert(
newRole === WorkspaceRole.Owner
? 'Workspace.TransferOwner'
: 'Workspace.Users.Manage'
);
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
if (newRole === WorkspaceRole.Owner) {
await this.models.workspaceUser.setOwner(workspaceId, userId);
} else {
// non-team workspace can only transfer ownership, but no detailed permission control
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId); const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
if (!isTeam) { if (!isTeam) {
throw new ActionForbiddenOnNonTeamWorkspace(); throw new ActionForbiddenOnNonTeamWorkspace();
} }
await this.models.workspaceUser.set(workspaceId, userId, newRole);
} }
await this.permissions.checkWorkspace( return true;
workspaceId,
user.id,
permission >= WorkspaceRole.Admin
? WorkspaceRole.Owner
: WorkspaceRole.Admin
);
try {
// lock to prevent concurrent invite and grant
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
return new TooManyRequest();
}
const isMember = await this.permissions.isWorkspaceMember(
workspaceId,
userId
);
if (isMember) {
const result = await this.permissions.grant(
workspaceId,
userId,
permission
);
if (result) {
if (permission === WorkspaceRole.Owner) {
this.event.emit('workspace.members.ownershipTransferred', {
workspaceId,
from: user.id,
to: userId,
});
} else {
this.event.emit('workspace.members.roleChanged', {
userId,
workspaceId,
permission,
});
}
}
return result;
} else {
return new MemberNotFoundInSpace({ spaceId: workspaceId });
}
} catch (e) {
this.logger.error('failed to invite user', e);
// pass through user friendly error
if (e instanceof UserFriendlyError) {
return e;
}
return new TooManyRequest();
}
} }
} }

View File

@@ -9,7 +9,7 @@ import {
ResolveField, ResolveField,
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import { Prisma, PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import { WorkspaceMemberStatus } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../base'; import type { FileUpload } from '../../../base';
@@ -17,10 +17,13 @@ import {
AFFiNELogger, AFFiNELogger,
AlreadyInSpace, AlreadyInSpace,
Cache, Cache,
CanNotRevokeYourself,
DocNotFound, DocNotFound,
EventBus, EventBus,
InternalServerError, InternalServerError,
MemberNotFoundInSpace,
MemberQuotaExceeded, MemberQuotaExceeded,
OwnerCanNotLeaveWorkspace,
QueryTooLong, QueryTooLong,
registerObjectType, registerObjectType,
RequestMutex, RequestMutex,
@@ -33,10 +36,9 @@ import {
} from '../../../base'; } from '../../../base';
import { Models } from '../../../models'; import { Models } from '../../../models';
import { CurrentUser, Public } from '../../auth'; import { CurrentUser, Public } from '../../auth';
import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc'; import { type Editor } from '../../doc';
import { import {
mapWorkspaceRoleToPermissions, AccessController,
PermissionService,
WORKSPACE_ACTIONS, WORKSPACE_ACTIONS,
WorkspaceAction, WorkspaceAction,
WorkspaceRole, WorkspaceRole,
@@ -56,7 +58,7 @@ export type DotToUnderline<T extends string> =
? `${Prefix}_${DotToUnderline<Suffix>}` ? `${Prefix}_${DotToUnderline<Suffix>}`
: T; : T;
export function mapPermissionToGraphqlPermissions<A extends string>( export function mapPermissionsToGraphqlPermissions<A extends string>(
permission: Record<A, boolean> permission: Record<A, boolean>
): Record<DotToUnderline<A>, boolean> { ): Record<DotToUnderline<A>, boolean> {
return Object.fromEntries( return Object.fromEntries(
@@ -126,14 +128,12 @@ export class WorkspaceRolePermissions {
export class WorkspaceResolver { export class WorkspaceResolver {
constructor( constructor(
private readonly cache: Cache, private readonly cache: Cache,
private readonly prisma: PrismaClient, private readonly ac: AccessController,
private readonly permissions: PermissionService,
private readonly quota: QuotaService, private readonly quota: QuotaService,
private readonly models: Models, private readonly models: Models,
private readonly event: EventBus, private readonly event: EventBus,
private readonly mutex: RequestMutex, private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly workspaceStorage: PgWorkspaceDocStorageAdapter,
private readonly logger: AFFiNELogger private readonly logger: AFFiNELogger
) { ) {
logger.setContext(WorkspaceResolver.name); logger.setContext(WorkspaceResolver.name);
@@ -152,13 +152,27 @@ export class WorkspaceResolver {
return workspace.role; return workspace.role;
} }
const role = await this.permissions.get(workspace.id, user.id); const { role } = await this.ac
.user(user.id)
.workspace(workspace.id)
.permissions();
if (!role) { return role ?? WorkspaceRole.External;
throw new SpaceAccessDenied({ spaceId: workspace.id }); }
}
return role; @ResolveField(() => WorkspacePermissions, {
description: 'map of action permissions',
})
async permissions(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType
) {
const { permissions } = await this.ac
.user(user.id)
.workspace(workspace.id)
.permissions();
return mapPermissionsToGraphqlPermissions(permissions);
} }
@ResolveField(() => Int, { @ResolveField(() => Int, {
@@ -166,7 +180,7 @@ export class WorkspaceResolver {
complexity: 2, complexity: 2,
}) })
memberCount(@Parent() workspace: WorkspaceType) { memberCount(@Parent() workspace: WorkspaceType) {
return this.permissions.getWorkspaceMemberCount(workspace.id); return this.models.workspaceUser.count(workspace.id);
} }
@ResolveField(() => Boolean, { @ResolveField(() => Boolean, {
@@ -174,14 +188,7 @@ export class WorkspaceResolver {
complexity: 2, complexity: 2,
}) })
async initialized(@Parent() workspace: WorkspaceType) { async initialized(@Parent() workspace: WorkspaceType) {
return this.prisma.snapshot return this.models.doc.exists(workspace.id, workspace.id);
.count({
where: {
id: workspace.id,
workspaceId: workspace.id,
},
})
.then(count => count > 0);
} }
@ResolveField(() => UserType, { @ResolveField(() => UserType, {
@@ -189,7 +196,7 @@ export class WorkspaceResolver {
complexity: 2, complexity: 2,
}) })
async owner(@Parent() workspace: WorkspaceType) { async owner(@Parent() workspace: WorkspaceType) {
return this.permissions.getWorkspaceOwner(workspace.id); return this.models.workspaceUser.getOwner(workspace.id);
} }
@ResolveField(() => [InviteUserType], { @ResolveField(() => [InviteUserType], {
@@ -197,44 +204,48 @@ export class WorkspaceResolver {
complexity: 2, complexity: 2,
}) })
async members( async members(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType, @Parent() workspace: WorkspaceType,
@Args('skip', { type: () => Int, nullable: true }) skip?: number, @Args('skip', { type: () => Int, nullable: true }) skip?: number,
@Args('take', { type: () => Int, nullable: true }) take?: number, @Args('take', { type: () => Int, nullable: true }) take?: number,
@Args('query', { type: () => String, nullable: true }) query?: string @Args('query', { type: () => String, nullable: true }) query?: string
) { ) {
const args: Prisma.WorkspaceUserPermissionFindManyArgs = { await this.ac
where: { workspaceId: workspace.id }, .user(user.id)
skip, .workspace(workspace.id)
take: take || 8, .assert('Workspace.Users.Read');
orderBy: [{ createdAt: 'asc' }, { type: 'desc' }],
};
if (query) { if (query) {
if (query.length > 255) { if (query.length > 255) {
throw new QueryTooLong({ max: 255 }); throw new QueryTooLong({ max: 255 });
} }
// @ts-expect-error not null const list = await this.models.workspaceUser.search(workspace.id, query, {
args.where.user = { offset: skip ?? 0,
// TODO(@forehalo): case-insensitive search later first: take ?? 8,
OR: [{ name: { contains: query } }, { email: { contains: query } }], });
};
return list.map(({ id, accepted, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
status,
}));
} else {
const [list] = await this.models.workspaceUser.paginate(workspace.id, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, accepted, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
status,
}));
} }
const data = await this.prisma.workspaceUserPermission.findMany({
...args,
include: {
user: true,
},
});
return data.map(({ id, accepted, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
status,
}));
} }
@ResolveField(() => WorkspacePageMeta, { @ResolveField(() => WorkspacePageMeta, {
@@ -245,15 +256,7 @@ export class WorkspaceResolver {
@Parent() workspace: WorkspaceType, @Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string @Args('pageId') pageId: string
) { ) {
const metadata = await this.prisma.snapshot.findFirst({ const metadata = await this.models.doc.getMeta(workspace.id, pageId);
where: { workspaceId: workspace.id, id: pageId },
select: {
createdAt: true,
updatedAt: true,
createdByUser: { select: { name: true, avatarUrl: true } },
updatedByUser: { select: { name: true, avatarUrl: true } },
},
});
if (!metadata) { if (!metadata) {
throw new DocNotFound({ spaceId: workspace.id, docId: pageId }); throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
} }
@@ -284,29 +287,35 @@ export class WorkspaceResolver {
@Query(() => Boolean, { @Query(() => Boolean, {
description: 'Get is owner of workspace', description: 'Get is owner of workspace',
complexity: 2, complexity: 2,
deprecationReason: 'use WorkspaceType[role] instead',
}) })
async isOwner( async isOwner(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string @Args('workspaceId') workspaceId: string
) { ) {
const data = await this.permissions.tryGetWorkspaceOwner(workspaceId); const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
return data?.user?.id === user.id; return role?.type === WorkspaceRole.Owner;
} }
@Query(() => Boolean, { @Query(() => Boolean, {
description: 'Get is admin of workspace', description: 'Get is admin of workspace',
complexity: 2, complexity: 2,
deprecationReason: 'use WorkspaceType[role] instead',
}) })
async isAdmin( async isAdmin(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string @Args('workspaceId') workspaceId: string
) { ) {
return this.permissions.tryCheckWorkspaceIs( const role = await this.models.workspaceUser.getActive(
workspaceId, workspaceId,
user.id, user.id
WorkspaceRole.Admin
); );
return role?.type === WorkspaceRole.Admin;
} }
@Query(() => [WorkspaceType], { @Query(() => [WorkspaceType], {
@@ -314,38 +323,30 @@ export class WorkspaceResolver {
complexity: 2, complexity: 2,
}) })
async workspaces(@CurrentUser() user: CurrentUser) { async workspaces(@CurrentUser() user: CurrentUser) {
const data = await this.prisma.workspaceUserPermission.findMany({ const roles = await this.models.workspaceUser.getUserActiveRoles(user.id);
where: {
userId: user.id,
OR: [
{
accepted: true,
},
{
status: WorkspaceMemberStatus.Accepted,
},
],
},
include: {
workspace: true,
},
});
return data.map(({ workspace, type }) => { const map = new Map(
return { roles.map(({ workspaceId, type }) => [workspaceId, type])
...workspace, );
permission: type,
role: type, const workspaces = await this.models.workspace.findMany(
}; roles.map(({ workspaceId }) => workspaceId)
}); );
return workspaces.map(workspace => ({
...workspace,
permission: map.get(workspace.id),
role: map.get(workspace.id),
}));
} }
@Query(() => WorkspaceType, { @Query(() => WorkspaceType, {
description: 'Get workspace by id', description: 'Get workspace by id',
}) })
async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) { async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) {
await this.permissions.checkWorkspace(id, user.id); await this.ac.user(user.id).workspace(id).assert('Workspace.Read');
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
const workspace = await this.models.workspace.get(id);
if (!workspace) { if (!workspace) {
throw new SpaceNotFound({ spaceId: id }); throw new SpaceNotFound({ spaceId: id });
@@ -356,22 +357,24 @@ export class WorkspaceResolver {
@Query(() => WorkspaceRolePermissions, { @Query(() => WorkspaceRolePermissions, {
description: 'Get workspace role permissions', description: 'Get workspace role permissions',
deprecationReason: 'use WorkspaceType[permissions] instead',
}) })
async workspaceRolePermissions( async workspaceRolePermissions(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('id') id: string @Args('id') id: string
): Promise<WorkspaceRolePermissions> { ): Promise<WorkspaceRolePermissions> {
const workspace = await this.prisma.workspaceUserPermission.findFirst({ const { role, permissions } = await this.ac
where: { workspaceId: id, userId: user.id }, .user(user.id)
}); .workspace(id)
if (!workspace) { .permissions();
if (!role) {
throw new SpaceAccessDenied({ spaceId: id }); throw new SpaceAccessDenied({ spaceId: id });
} }
return { return {
role: workspace.type, role,
permissions: mapPermissionToGraphqlPermissions( permissions: mapPermissionsToGraphqlPermissions(permissions),
mapWorkspaceRoleToPermissions(workspace.type)
),
}; };
} }
@@ -385,19 +388,7 @@ export class WorkspaceResolver {
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true }) @Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
init: FileUpload | null init: FileUpload | null
) { ) {
const workspace = await this.prisma.workspace.create({ const workspace = await this.models.workspace.create(user.id);
data: {
public: false,
permissions: {
create: {
type: WorkspaceRole.Owner,
userId: user.id,
accepted: true,
status: WorkspaceMemberStatus.Accepted,
},
},
},
});
if (init) { if (init) {
// convert stream to buffer // convert stream to buffer
@@ -413,13 +404,12 @@ export class WorkspaceResolver {
const buffer = chunks.length ? Buffer.concat(chunks) : null; const buffer = chunks.length ? Buffer.concat(chunks) : null;
if (buffer) { if (buffer) {
await this.prisma.snapshot.create({ await this.models.doc.upsert({
data: { spaceId: workspace.id,
id: workspace.id, docId: workspace.id,
workspaceId: workspace.id, blob: buffer,
blob: buffer, timestamp: Date.now(),
updatedAt: new Date(), editorId: user.id,
},
}); });
} }
} }
@@ -435,14 +425,11 @@ export class WorkspaceResolver {
@Args({ name: 'input', type: () => UpdateWorkspaceInput }) @Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput { id, ...updates }: UpdateWorkspaceInput
) { ) {
await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Admin); await this.ac
.user(user.id)
return this.prisma.workspace.update({ .workspace(id)
where: { .assert('Workspace.Settings.Update');
id, return this.models.workspace.update(id, updates);
},
data: updates,
});
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
@@ -450,16 +437,9 @@ export class WorkspaceResolver {
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('id') id: string @Args('id') id: string
) { ) {
await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Owner); await this.ac.user(user.id).workspace(id).assert('Workspace.Delete');
await this.prisma.workspace.delete({ await this.models.workspace.delete(id);
where: {
id,
},
});
await this.workspaceStorage.deleteSpace(id);
this.event.emit('workspace.deleted', { id });
return true; return true;
} }
@@ -477,11 +457,10 @@ export class WorkspaceResolver {
}) })
_permission?: WorkspaceRole _permission?: WorkspaceRole
) { ) {
await this.permissions.checkWorkspace( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Admin .assert('Workspace.Users.Manage');
);
try { try {
// lock to prevent concurrent invite and grant // lock to prevent concurrent invite and grant
@@ -494,53 +473,40 @@ export class WorkspaceResolver {
// member limit check // member limit check
await this.quota.checkSeat(workspaceId); await this.quota.checkSeat(workspaceId);
let target = await this.models.user.getUserByEmail(email); let user = await this.models.user.getUserByEmail(email);
if (target) { if (user) {
const originRecord = const role = await this.models.workspaceUser.get(workspaceId, user.id);
await this.prisma.workspaceUserPermission.findFirst({
where: {
workspaceId,
userId: target.id,
},
});
// only invite if the user is not already in the workspace // only invite if the user is not already in the workspace
if (originRecord) return originRecord.id; if (role) return role.id;
} else { } else {
target = await this.models.user.create({ user = await this.models.user.create({
email, email,
registered: false, registered: false,
}); });
} }
const inviteId = await this.permissions.grant( const role = await this.models.workspaceUser.set(
workspaceId, workspaceId,
target.id, user.id,
WorkspaceRole.Collaborator WorkspaceRole.Collaborator
); );
if (sendInviteMail) { if (sendInviteMail) {
try { try {
await this.workspaceService.sendInviteEmail(inviteId); await this.workspaceService.sendInviteEmail(role.id);
} catch (e) { } catch (e) {
const ret = await this.permissions.revokeWorkspace( await this.models.workspaceUser.delete(workspaceId, user.id);
workspaceId,
target.id this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
); );
if (!ret) {
this.logger.fatal(
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
);
} else {
this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
throw new InternalServerError( throw new InternalServerError(
'Failed to send invite email. Please try again.' 'Failed to send invite email. Please try again.'
); );
} }
} }
return inviteId; return role.id;
} catch (e) { } catch (e) {
// pass through user friendly error // pass through user friendly error
if (e instanceof UserFriendlyError) { if (e instanceof UserFriendlyError) {
@@ -563,7 +529,7 @@ export class WorkspaceResolver {
const { workspaceId, inviteeUserId } = const { workspaceId, inviteeUserId } =
await this.workspaceService.getInviteInfo(inviteId); await this.workspaceService.getInviteInfo(inviteId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId); const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.permissions.getWorkspaceOwner(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId);
const inviteeId = inviteeUserId || user?.id; const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound(); if (!inviteeId) throw new UserNotFound();
@@ -578,28 +544,47 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('userId') userId: string @Args('userId') userId: string
) { ) {
const isAdmin = await this.permissions.tryCheckWorkspaceIs( if (userId === user.id) {
workspaceId, throw new CanNotRevokeYourself();
userId,
WorkspaceRole.Admin
);
if (isAdmin) {
// only owner can revoke workspace admin
await this.permissions.checkWorkspaceIs(
workspaceId,
user.id,
WorkspaceRole.Owner
);
} else {
await this.permissions.checkWorkspace(
workspaceId,
user.id,
WorkspaceRole.Admin
);
} }
return await this.permissions.revokeWorkspace(workspaceId, userId); const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert(
role.type === WorkspaceRole.Admin
? 'Workspace.Adminitrators.Manage'
: 'Workspace.Users.Manage'
);
await this.models.workspaceUser.delete(workspaceId, userId);
const count = await this.models.workspaceUser.count(workspaceId);
this.event.emit('workspace.members.updated', {
workspaceId,
count,
});
if (role.status === WorkspaceMemberStatus.UnderReview) {
this.event.emit('workspace.members.requestDeclined', {
userId,
workspaceId,
});
} else {
this.event.emit('workspace.members.removed', {
userId,
workspaceId,
});
}
return true;
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
@@ -617,11 +602,12 @@ export class WorkspaceResolver {
} }
if (user) { if (user) {
const status = await this.permissions.getWorkspaceMemberStatus( const role = await this.models.workspaceUser.getActive(
workspaceId, workspaceId,
user.id user.id
); );
if (status === WorkspaceMemberStatus.Accepted) {
if (role) {
throw new AlreadyInSpace({ spaceId: workspaceId }); throw new AlreadyInSpace({ spaceId: workspaceId });
} }
@@ -630,42 +616,39 @@ export class WorkspaceResolver {
`workspace:inviteLink:${workspaceId}` `workspace:inviteLink:${workspaceId}`
); );
if (invite?.inviteId === inviteId) { if (invite?.inviteId === inviteId) {
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
const seatAvailable = await this.quota.tryCheckSeat(workspaceId); const seatAvailable = await this.quota.tryCheckSeat(workspaceId);
if (!seatAvailable) { if (seatAvailable) {
const invite = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
this.event.emit('workspace.members.reviewRequested', {
inviteId: invite.id,
});
return true;
} else {
const isTeam =
await this.workspaceService.isTeamWorkspace(workspaceId);
// only team workspace allow over limit // only team workspace allow over limit
if (isTeam) { if (isTeam) {
await this.permissions.grant( await this.models.workspaceUser.set(
workspaceId, workspaceId,
user.id, user.id,
WorkspaceRole.Collaborator, WorkspaceRole.Collaborator,
WorkspaceMemberStatus.NeedMoreSeatAndReview WorkspaceMemberStatus.NeedMoreSeatAndReview
); );
const memberCount = const memberCount =
await this.permissions.getWorkspaceMemberCount(workspaceId); await this.models.workspaceUser.count(workspaceId);
this.event.emit('workspace.members.updated', { this.event.emit('workspace.members.updated', {
workspaceId, workspaceId,
count: memberCount, count: memberCount,
}); });
return true; return true;
} else if (!status) { } else {
throw new MemberQuotaExceeded(); throw new MemberQuotaExceeded();
} }
} else {
const inviteId = await this.permissions.grant(workspaceId, user.id);
if (isTeam) {
this.event.emit('workspace.members.reviewRequested', {
inviteId,
});
}
// invite by link need admin to approve
return await this.permissions.acceptWorkspaceInvitation(
inviteId,
workspaceId,
isTeam
? WorkspaceMemberStatus.UnderReview
: WorkspaceMemberStatus.Accepted
);
} }
} }
} }
@@ -675,10 +658,8 @@ export class WorkspaceResolver {
if (!success) throw new UserNotFound(); if (!success) throw new UserNotFound();
} }
return await this.permissions.acceptWorkspaceInvitation( await this.models.workspaceUser.accept(inviteId);
inviteId, return true;
workspaceId
);
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
@@ -692,8 +673,19 @@ export class WorkspaceResolver {
}) })
_workspaceName?: string _workspaceName?: string
) { ) {
await this.permissions.checkWorkspace(workspaceId, user.id); const role = await this.models.workspaceUser.getActive(
const success = this.permissions.revokeWorkspace(workspaceId, user.id); workspaceId,
user.id
);
if (!role) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
if (role.type === WorkspaceRole.Owner) {
throw new OwnerCanNotLeaveWorkspace();
}
await this.models.workspaceUser.delete(workspaceId, user.id);
if (sendLeaveMail) { if (sendLeaveMail) {
this.event.emit('workspace.members.leave', { this.event.emit('workspace.members.leave', {
@@ -705,6 +697,6 @@ export class WorkspaceResolver {
}); });
} }
return success; return true;
} }
} }

View File

@@ -3,7 +3,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
export class MigrateInviteStatus1732861452428 { export class MigrateInviteStatus1732861452428 {
// do the migration // do the migration
static async up(db: PrismaClient) { static async up(db: PrismaClient) {
await db.workspaceUserPermission.updateMany({ await db.workspaceUserRole.updateMany({
where: { where: {
accepted: true, accepted: true,
}, },

View File

@@ -12,3 +12,8 @@ export interface Doc {
} }
export type DocEditor = Pick<User, 'id' | 'name' | 'avatarUrl'>; export type DocEditor = Pick<User, 'id' | 'name' | 'avatarUrl'>;
export enum PublicDocMode {
Page,
Edgeless,
}

View File

@@ -1,3 +1,3 @@
export * from './doc'; export * from './doc';
export * from './feature'; export * from './feature';
export * from './page'; export * from './role';

View File

@@ -1,4 +0,0 @@
export enum PublicPageMode {
Page,
Edgeless,
}

View File

@@ -0,0 +1,14 @@
export enum DocRole {
External = 0,
Reader = 10,
Editor = 20,
Manager = 30,
Owner = 99,
}
export enum WorkspaceRole {
External = -99,
Collaborator = 1,
Admin = 10,
Owner = 99,
}

View File

@@ -0,0 +1,203 @@
import assert from 'node:assert';
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { WorkspaceDocUserRole } from '@prisma/client';
import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
import { BaseModel } from './base';
import { DocRole } from './common';
@Injectable()
export class DocUserModel extends BaseModel {
/**
* Set or update the [Owner] of a doc.
* The old [Owner] will be changed to [Manager] if there is already an [Owner].
*/
@Transactional()
async setOwner(workspaceId: string, docId: string, userId: string) {
const oldOwner = await this.db.workspaceDocUserRole.findFirst({
where: {
workspaceId,
docId,
type: DocRole.Owner,
},
});
if (oldOwner) {
await this.db.workspaceDocUserRole.update({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId: oldOwner.userId,
},
},
data: {
type: DocRole.Manager,
},
});
}
await this.db.workspaceDocUserRole.upsert({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
update: {
type: DocRole.Owner,
},
create: {
workspaceId,
docId,
userId,
type: DocRole.Owner,
},
});
if (oldOwner) {
this.logger.log(
`Transfer doc owner of [${workspaceId}/${docId}] from [${oldOwner.userId}] to [${userId}]`
);
} else {
this.logger.log(
`Set doc owner of [${workspaceId}/${docId}] to [${userId}]`
);
}
}
/**
* Set or update the Role of a user in a doc.
*
* NOTE: do not use this method to set the [Owner] of a doc. Use {@link setOwner} instead.
*/
@Transactional()
async set(workspaceId: string, docId: string, userId: string, role: DocRole) {
// internal misuse, throw directly
assert(role !== DocRole.Owner, 'Cannot set Owner role of a doc to a user.');
const oldRole = await this.get(workspaceId, docId, userId);
if (oldRole && oldRole.type === role) {
return oldRole;
}
const newRole = await this.db.workspaceDocUserRole.upsert({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
update: {
type: role,
},
create: {
workspaceId,
docId,
userId,
type: role,
},
});
return newRole;
}
async batchSetUserRoles(
workspaceId: string,
docId: string,
userIds: string[],
role: DocRole
) {
if (userIds.length === 0) {
return 0;
}
if (role === DocRole.Owner) {
throw new CanNotBatchGrantDocOwnerPermissions();
}
const result = await this.db.workspaceDocUserRole.createMany({
skipDuplicates: true,
data: userIds.map(userId => ({
workspaceId,
docId,
userId,
type: role,
})),
});
return result.count;
}
async delete(workspaceId: string, docId: string, userId: string) {
await this.db.workspaceDocUserRole.deleteMany({
where: {
workspaceId,
docId,
userId,
},
});
}
async getOwner(workspaceId: string, docId: string) {
return await this.db.workspaceDocUserRole.findFirst({
where: {
workspaceId,
docId,
type: DocRole.Owner,
},
});
}
async get(workspaceId: string, docId: string, userId: string) {
return await this.db.workspaceDocUserRole.findUnique({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
});
}
count(workspaceId: string, docId: string) {
return this.db.workspaceDocUserRole.count({
where: {
workspaceId,
docId,
},
});
}
async paginate(
workspaceId: string,
docId: string,
pagination: PaginationInput
): Promise<[WorkspaceDocUserRole[], number]> {
return await Promise.all([
this.db.workspaceDocUserRole.findMany({
where: {
workspaceId,
docId,
createdAt: pagination.after
? {
gte: pagination.after,
}
: undefined,
},
orderBy: {
createdAt: 'asc',
},
take: pagination.first,
skip: pagination.offset + (pagination.after ? 1 : 0),
}),
this.count(workspaceId, docId),
]);
}
}

View File

@@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core';
import { ApplyType } from '../base'; import { ApplyType } from '../base';
import { DocModel } from './doc'; import { DocModel } from './doc';
import { DocUserModel } from './doc-user';
import { FeatureModel } from './feature'; import { FeatureModel } from './feature';
import { PageModel } from './page'; import { PageModel } from './page';
import { MODELS_SYMBOL } from './provider'; import { MODELS_SYMBOL } from './provider';
@@ -18,6 +19,7 @@ import { UserFeatureModel } from './user-feature';
import { VerificationTokenModel } from './verification-token'; import { VerificationTokenModel } from './verification-token';
import { WorkspaceModel } from './workspace'; import { WorkspaceModel } from './workspace';
import { WorkspaceFeatureModel } from './workspace-feature'; import { WorkspaceFeatureModel } from './workspace-feature';
import { WorkspaceUserModel } from './workspace-user';
const MODELS = { const MODELS = {
user: UserModel, user: UserModel,
@@ -30,6 +32,8 @@ const MODELS = {
workspaceFeature: WorkspaceFeatureModel, workspaceFeature: WorkspaceFeatureModel,
doc: DocModel, doc: DocModel,
userDoc: UserDocModel, userDoc: UserDocModel,
workspaceUser: WorkspaceUserModel,
docUser: DocUserModel,
}; };
type ModelsType = { type ModelsType = {
@@ -83,6 +87,7 @@ export class ModelsModule {}
export * from './common'; export * from './common';
export * from './doc'; export * from './doc';
export * from './doc-user';
export * from './feature'; export * from './feature';
export * from './page'; export * from './page';
export * from './session'; export * from './session';
@@ -92,3 +97,4 @@ export * from './user-feature';
export * from './verification-token'; export * from './verification-token';
export * from './workspace'; export * from './workspace';
export * from './workspace-feature'; export * from './workspace-feature';
export * from './workspace-user';

View File

@@ -1,16 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional'; import { type WorkspaceDoc as Page } from '@prisma/client';
import {
type WorkspaceDoc as Page,
type WorkspaceDocUserPermission as PageUserPermission,
} from '@prisma/client';
import { WorkspaceRole } from '../core/permission';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { PublicPageMode } from './common'; import { PublicDocMode } from './common';
export type { Page }; export type { Page };
export type UpdatePageInput = { export type UpdatePageInput = {
mode?: PublicPageMode; mode?: PublicDocMode;
public?: boolean; public?: boolean;
}; };
@@ -82,118 +77,4 @@ export class PageModel extends BaseModel {
} }
// #endregion // #endregion
// #region page member and permission
/**
* Grant the page member with the given permission.
*/
@Transactional()
async grantMember(
workspaceId: string,
docId: string,
userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
): Promise<PageUserPermission> {
let data = await this.db.workspaceDocUserPermission.findUnique({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
});
// If the user is already accepted and the new permission is owner, we need to revoke old owner
if (!data || data.type !== permission) {
if (data) {
// Update the permission
data = await this.db.workspaceDocUserPermission.update({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
data: { type: permission },
});
} else {
// Create a new permission
data = await this.db.workspaceDocUserPermission.create({
data: {
workspaceId,
docId,
userId,
type: permission,
},
});
}
// If the new permission is owner, we need to revoke old owner
if (permission === WorkspaceRole.Owner) {
await this.db.workspaceDocUserPermission.updateMany({
where: {
workspaceId,
docId,
type: WorkspaceRole.Owner,
userId: { not: userId },
},
data: { type: WorkspaceRole.Admin },
});
this.logger.log(
`Change owner of workspace ${workspaceId} doc ${docId} to user ${userId}`
);
}
return data;
}
// nothing to do
return data;
}
/**
* Returns whether a given user is a member of a workspace and has the given or higher permission.
* Default to read permission.
*/
async isMember(
workspaceId: string,
docId: string,
userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
const count = await this.db.workspaceDocUserPermission.count({
where: {
workspaceId,
docId,
userId,
type: {
gte: permission,
},
},
});
return count > 0;
}
/**
* Delete a page member
* Except the owner, the owner can't be deleted.
*/
async deleteMember(workspaceId: string, docId: string, userId: string) {
const { count } = await this.db.workspaceDocUserPermission.deleteMany({
where: {
workspaceId,
docId,
userId,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: WorkspaceRole.Owner,
},
},
});
return count;
}
// #endregion
} }

View File

@@ -10,6 +10,7 @@ import {
WrongSignInMethod, WrongSignInMethod,
} from '../base'; } from '../base';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { WorkspaceRole } from './common';
import type { Workspace } from './workspace'; import type { Workspace } from './workspace';
const publicUserSelect = { const publicUserSelect = {
@@ -215,12 +216,17 @@ export class UserModel extends BaseModel {
} }
async delete(id: string) { async delete(id: string) {
const ownedWorkspaceIds = await this.models.workspace.findOwnedIds(id); const ownedWorkspaces = await this.models.workspaceUser.getUserActiveRoles(
id,
{
role: WorkspaceRole.Owner,
}
);
const user = await this.db.user.delete({ where: { id } }); const user = await this.db.user.delete({ where: { id } });
this.event.emit('user.deleted', { this.event.emit('user.deleted', {
...user, ...user,
ownedWorkspaces: ownedWorkspaceIds, ownedWorkspaces: ownedWorkspaces.map(r => r.workspaceId),
}); });
return user; return user;

View File

@@ -0,0 +1,385 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { WorkspaceMemberStatus } from '@prisma/client';
import { groupBy } from 'lodash-es';
import { EventBus, PaginationInput } from '../base';
import { BaseModel } from './base';
import { WorkspaceRole } from './common';
export { WorkspaceMemberStatus };
declare global {
interface Events {
'workspace.owner.changed': {
workspaceId: string;
from: string;
to: string;
};
'workspace.members.roleChanged': {
userId: string;
workspaceId: string;
role: WorkspaceRole;
};
// below are business events, should be declare somewhere else
'workspace.members.updated': {
workspaceId: string;
count: number;
};
'workspace.members.reviewRequested': {
inviteId: string;
};
'workspace.members.requestApproved': {
inviteId: string;
};
'workspace.members.requestDeclined': {
userId: string;
workspaceId: string;
};
'workspace.members.removed': {
userId: string;
workspaceId: string;
};
'workspace.members.leave': {
workspaceId: string;
user: {
id: string;
email: string;
};
};
}
}
@Injectable()
export class WorkspaceUserModel extends BaseModel {
constructor(private readonly event: EventBus) {
super();
}
/**
* Set or update the [Owner] of a workspace.
* The old [Owner] will be changed to [Admin] if there is already an [Owner].
*/
@Transactional()
async setOwner(workspaceId: string, userId: string) {
const oldOwner = await this.db.workspaceUserRole.findFirst({
where: {
workspaceId,
type: WorkspaceRole.Owner,
},
});
// If there is already an owner, we need to change the old owner to admin
if (oldOwner) {
await this.db.workspaceUserRole.update({
where: {
id: oldOwner.id,
},
data: {
type: WorkspaceRole.Admin,
},
});
}
await this.db.workspaceUserRole.upsert({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
update: {
type: WorkspaceRole.Owner,
},
create: {
workspaceId,
userId,
type: WorkspaceRole.Owner,
status: WorkspaceMemberStatus.Accepted,
},
});
if (oldOwner) {
this.event.emit('workspace.owner.changed', {
workspaceId,
from: oldOwner.userId,
to: userId,
});
this.logger.log(
`Transfer workspace owner of [${workspaceId}] from [${oldOwner.userId}] to [${userId}]`
);
} else {
this.logger.log(`Set workspace owner of [${workspaceId}] to [${userId}]`);
}
}
/**
* Set or update the Role of a user in a workspace.
*
* NOTE: do not use this method to set the [Owner] of a workspace. Use {@link setOwner} instead.
*/
@Transactional()
async set(
workspaceId: string,
userId: string,
role: WorkspaceRole,
defaultStatus: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
) {
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
}
const oldRole = await this.get(workspaceId, userId);
if (oldRole) {
if (oldRole.type === role) {
return oldRole;
}
const newRole = await this.db.workspaceUserRole.update({
where: { id: oldRole.id },
data: { type: role },
});
if (oldRole.status === WorkspaceMemberStatus.Accepted) {
this.event.emit('workspace.members.roleChanged', {
userId,
workspaceId,
role: newRole.type,
});
}
return newRole;
} else {
return await this.db.workspaceUserRole.create({
data: {
workspaceId,
userId,
type: role,
status: defaultStatus,
},
});
}
}
async setStatus(
workspaceId: string,
userId: string,
status: WorkspaceMemberStatus
) {
return await this.db.workspaceUserRole.update({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
data: {
status,
},
});
}
async accept(id: string) {
await this.db.workspaceUserRole.update({
where: { id },
data: { status: WorkspaceMemberStatus.Accepted },
});
}
async delete(workspaceId: string, userId: string) {
await this.db.workspaceUserRole.deleteMany({
where: {
workspaceId,
userId,
},
});
}
async get(workspaceId: string, userId: string) {
return await this.db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
});
}
async getById(id: string) {
return await this.db.workspaceUserRole.findUnique({
where: { id },
});
}
/**
* Get the **accepted** Role of a user in a workspace.
*/
async getActive(workspaceId: string, userId: string) {
return await this.db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: { workspaceId, userId },
status: WorkspaceMemberStatus.Accepted,
},
});
}
async getOwner(workspaceId: string) {
const role = await this.db.workspaceUserRole.findFirst({
include: {
user: true,
},
where: {
workspaceId,
type: WorkspaceRole.Owner,
},
});
if (!role) {
throw new Error('Workspace owner not found');
}
return role.user;
}
async getAdmins(workspaceId: string) {
const list = await this.db.workspaceUserRole.findMany({
include: {
user: true,
},
where: {
workspaceId,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
},
});
return list.map(l => l.user);
}
async count(workspaceId: string) {
return this.db.workspaceUserRole.count({
where: {
workspaceId,
},
});
}
async getUserActiveRoles(
userId: string,
filter: { role?: WorkspaceRole } = {}
) {
return await this.db.workspaceUserRole.findMany({
where: {
userId,
status: WorkspaceMemberStatus.Accepted,
type: filter.role,
},
});
}
async paginate(workspaceId: string, pagination: PaginationInput) {
return await Promise.all([
this.db.workspaceUserRole.findMany({
include: {
user: true,
},
where: {
workspaceId,
createdAt: pagination.after
? {
gte: pagination.after,
}
: undefined,
},
orderBy: {
createdAt: 'asc',
},
take: pagination.first,
skip: pagination.offset + (pagination.after ? 1 : 0),
}),
this.count(workspaceId),
]);
}
async search(
workspaceId: string,
query: string,
pagination: PaginationInput
) {
return await this.db.workspaceUserRole.findMany({
include: { user: true },
where: {
workspaceId,
status: WorkspaceMemberStatus.Accepted,
user: {
OR: [
{
email: {
contains: query,
},
},
{
name: {
contains: query,
},
},
],
},
},
orderBy: { createdAt: 'asc' },
take: pagination.first,
skip: pagination.offset + (pagination.after ? 1 : 0),
});
}
@Transactional()
async refresh(workspaceId: string, memberLimit: number) {
const usedCount = await this.db.workspaceUserRole.count({
where: { workspaceId, status: WorkspaceMemberStatus.Accepted },
});
const availableCount = memberLimit - usedCount;
if (availableCount <= 0) {
return;
}
const members = await this.db.workspaceUserRole.findMany({
select: { id: true, status: true },
where: {
workspaceId,
status: {
in: [
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
],
},
},
orderBy: { createdAt: 'asc' },
});
const needChange = members.slice(0, availableCount);
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
needChange,
m => m.status
);
const toPendings = NeedMoreSeat ?? [];
if (toPendings.length > 0) {
await this.db.workspaceUserRole.updateMany({
where: { id: { in: toPendings.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.Pending },
});
}
const toUnderReviewUserIds = NeedMoreSeatAndReview ?? [];
if (toUnderReviewUserIds.length > 0) {
await this.db.workspaceUserRole.updateMany({
where: { id: { in: toUnderReviewUserIds.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.UnderReview },
});
}
}
}

View File

@@ -1,78 +1,25 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional'; import { Transactional } from '@nestjs-cls/transactional';
import { import { type Workspace } from '@prisma/client';
type Workspace,
WorkspaceMemberStatus,
type WorkspaceUserPermission,
} from '@prisma/client';
import { groupBy } from 'lodash-es';
import { EventBus } from '../base'; import { DocIsNotPublic, EventBus } from '../base';
import { WorkspaceRole } from '../core/permission';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { DocRole, PublicDocMode } from './common';
declare global { declare global {
interface Events { interface Events {
'workspace.members.reviewRequested': { inviteId: string };
'workspace.members.requestDeclined': {
userId: string;
workspaceId: string;
};
'workspace.members.requestApproved': { inviteId: string };
'workspace.members.roleChanged': {
userId: string;
workspaceId: string;
permission: number;
};
'workspace.members.ownershipTransferred': {
from: string;
to: string;
workspaceId: string;
};
'workspace.members.updated': {
workspaceId: string;
count: number;
};
'workspace.members.leave': {
user: {
id: string;
email: string;
};
workspaceId: string;
};
'workspace.members.removed': {
workspaceId: string;
userId: string;
};
'workspace.deleted': { 'workspace.deleted': {
id: string; id: string;
}; };
'workspace.blob.delete': {
workspaceId: string;
key: string;
};
'workspace.blob.sync': {
workspaceId: string;
key: string;
};
} }
} }
export { WorkspaceMemberStatus };
export type { Workspace }; export type { Workspace };
export type UpdateWorkspaceInput = Pick< export type UpdateWorkspaceInput = Pick<
Partial<Workspace>, Partial<Workspace>,
'public' | 'enableAi' | 'enableUrlPreview' 'public' | 'enableAi' | 'enableUrlPreview'
>; >;
export interface FindWorkspaceMembersOptions {
skip?: number;
/**
* Default to `8`
*/
take?: number;
}
@Injectable() @Injectable()
export class WorkspaceModel extends BaseModel { export class WorkspaceModel extends BaseModel {
constructor(private readonly event: EventBus) { constructor(private readonly event: EventBus) {
@@ -80,25 +27,16 @@ export class WorkspaceModel extends BaseModel {
} }
// #region workspace // #region workspace
/** /**
* Create a new workspace for the user, default to private. * Create a new workspace for the user, default to private.
*/ */
@Transactional()
async create(userId: string) { async create(userId: string) {
const workspace = await this.db.workspace.create({ const workspace = await this.db.workspace.create({
data: { data: { public: false },
public: false,
permissions: {
create: {
type: WorkspaceRole.Owner,
userId: userId,
accepted: true,
status: WorkspaceMemberStatus.Accepted,
},
},
},
}); });
this.logger.log(`Created workspace ${workspace.id} for user ${userId}`); this.logger.log(`Workspace created with id ${workspace.id}`);
await this.models.workspaceUser.setOwner(workspace.id, userId);
return workspace; return workspace;
} }
@@ -106,7 +44,7 @@ export class WorkspaceModel extends BaseModel {
* Update the workspace with the given data. * Update the workspace with the given data.
*/ */
async update(workspaceId: string, data: UpdateWorkspaceInput) { async update(workspaceId: string, data: UpdateWorkspaceInput) {
await this.db.workspace.update({ const workspace = await this.db.workspace.update({
where: { where: {
id: workspaceId, id: workspaceId,
}, },
@@ -115,6 +53,7 @@ export class WorkspaceModel extends BaseModel {
this.logger.log( this.logger.log(
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}` `Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
); );
return workspace;
} }
async get(workspaceId: string) { async get(workspaceId: string) {
@@ -125,422 +64,104 @@ export class WorkspaceModel extends BaseModel {
}); });
} }
async findMany(ids: string[]) {
return await this.db.workspace.findMany({
where: {
id: { in: ids },
},
});
}
async delete(workspaceId: string) { async delete(workspaceId: string) {
await this.db.workspace.deleteMany({ const rawResult = await this.db.workspace.deleteMany({
where: { where: {
id: workspaceId, id: workspaceId,
}, },
}); });
this.logger.log(`Deleted workspace ${workspaceId}`);
if (rawResult.count > 0) {
this.event.emit('workspace.deleted', { id: workspaceId });
this.logger.log(`Workspace [${workspaceId}] deleted`);
}
} }
/** async allowUrlPreview(workspaceId: string) {
* Find the workspace ids that the user is a member of owner. const workspace = await this.get(workspaceId);
*/ return workspace?.enableUrlPreview ?? false;
async findOwnedIds(userId: string) {
const rows = await this.db.workspaceUserPermission.findMany({
where: {
userId,
type: WorkspaceRole.Owner,
OR: this.acceptedCondition,
},
select: {
workspaceId: true,
},
});
return rows.map(row => row.workspaceId);
} }
/**
* Find the accessible workspaces for the user.
*/
async findAccessibleWorkspaces(userId: string) {
return await this.db.workspaceUserPermission.findMany({
where: {
userId,
OR: this.acceptedCondition,
},
include: {
workspace: true,
},
});
}
// #endregion // #endregion
// #region workspace member and permission // #region doc
async getDoc(workspaceId: string, docId: string) {
/** return await this.db.workspaceDoc.findUnique({
* Grant the workspace member with the given permission and status.
*/
@Transactional()
async grantMember(
workspaceId: string,
userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
): Promise<WorkspaceUserPermission> {
const data = await this.db.workspaceUserPermission.findUnique({
where: { where: {
workspaceId_userId: { workspaceId_docId: { workspaceId, docId },
workspaceId,
userId,
},
},
});
if (!data) {
// Create a new permission
const created = await this.db.workspaceUserPermission.create({
data: {
workspaceId,
userId,
type: permission,
status:
permission === WorkspaceRole.Owner
? WorkspaceMemberStatus.Accepted
: status,
},
});
this.logger.log(
`Granted workspace ${workspaceId} member ${userId} with permission ${WorkspaceRole[permission]}`
);
await this.notifyMembersUpdated(workspaceId);
return created;
}
// If the user is already accepted and the new permission is owner, we need to revoke old owner
if (data.status === WorkspaceMemberStatus.Accepted || data.accepted) {
const updated = await this.db.workspaceUserPermission.update({
where: {
workspaceId_userId: { workspaceId, userId },
},
data: { type: permission },
});
// If the new permission is owner, we need to revoke old owner
if (permission === WorkspaceRole.Owner) {
await this.db.workspaceUserPermission.updateMany({
where: {
workspaceId,
type: WorkspaceRole.Owner,
userId: { not: userId },
},
data: { type: WorkspaceRole.Admin },
});
this.logger.log(
`Change owner of workspace ${workspaceId} to ${userId}`
);
}
return updated;
}
// If the user is not accepted, we can update the status directly
const allowedStatus = this.getAllowedStatusSource(data.status);
if (allowedStatus.includes(status)) {
const updated = await this.db.workspaceUserPermission.update({
where: { workspaceId_userId: { workspaceId, userId } },
data: {
status,
// TODO(fengmk2): should we update the permission here?
// type: permission,
},
});
return updated;
}
// nothing to do
return data;
}
/**
* Get the workspace member invitation.
*/
async getMemberInvitation(invitationId: string) {
return await this.db.workspaceUserPermission.findUnique({
where: {
id: invitationId,
}, },
}); });
} }
/** async isPublicPage(workspaceId: string, docId: string) {
* Accept the workspace member invitation. const doc = await this.getDoc(workspaceId, docId);
* @param status: the status to update to, default to `Accepted`. Can be `Accepted` or `UnderReview`. if (doc?.public) {
*/ return true;
async acceptMemberInvitation( }
invitationId: string,
const workspace = await this.get(workspaceId);
return workspace?.public ?? false;
}
async publishDoc(
workspaceId: string, workspaceId: string,
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted docId: string,
mode: PublicDocMode = PublicDocMode.Page
) { ) {
const { count } = await this.db.workspaceUserPermission.updateMany({ return await this.db.workspaceDoc.upsert({
where: { where: { workspaceId_docId: { workspaceId, docId } },
id: invitationId, update: { public: true, mode },
workspaceId: workspaceId, create: { workspaceId, docId, public: true, mode },
// TODO(fengmk2): should we check the status here?
AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }],
},
data: { accepted: true, status },
}); });
}
@Transactional()
async revokePublicDoc(workspaceId: string, docId: string) {
const doc = await this.getDoc(workspaceId, docId);
if (!doc?.public) {
throw new DocIsNotPublic();
}
return await this.db.workspaceDoc.update({
where: { workspaceId_docId: { workspaceId, docId } },
data: { public: false },
});
}
async hasPublicDoc(workspaceId: string) {
const count = await this.db.workspaceDoc.count({
where: {
workspaceId,
public: true,
},
});
return count > 0; return count > 0;
} }
/** async getPublicDocs(workspaceId: string) {
* Get a workspace member in accepted status by workspace id and user id. return await this.db.workspaceDoc.findMany({
*/
async getMember(workspaceId: string, userId: string) {
return await this.db.workspaceUserPermission.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
OR: this.acceptedCondition,
},
});
}
/**
* Get a workspace member in any status by workspace id and user id.
*/
async getMemberInAnyStatus(workspaceId: string, userId: string) {
return await this.db.workspaceUserPermission.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
});
}
/**
* Returns whether a given user is a member of a workspace and has the given or higher permission.
* Default to read permission.
*/
async isMember(
workspaceId: string,
userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
const count = await this.db.workspaceUserPermission.count({
where: { where: {
workspaceId, workspaceId,
userId, public: true,
OR: this.acceptedCondition,
type: {
gte: permission,
},
},
});
return count > 0;
}
/**
* Get the workspace owner.
*/
async getOwner(workspaceId: string) {
return await this.db.workspaceUserPermission.findFirst({
where: {
workspaceId,
type: WorkspaceRole.Owner,
OR: this.acceptedCondition,
},
include: {
user: true,
}, },
}); });
} }
/** async setDocDefaultRole(workspaceId: string, docId: string, role: DocRole) {
* Find the workspace admins. await this.db.workspaceDoc.upsert({
*/ where: { workspaceId_docId: { workspaceId, docId } },
async findAdmins(workspaceId: string) { update: { defaultRole: role },
return await this.db.workspaceUserPermission.findMany({ create: { workspaceId, docId, defaultRole: role },
where: {
workspaceId,
type: WorkspaceRole.Admin,
OR: this.acceptedCondition,
},
include: {
user: true,
},
}); });
} }
/**
* Find the workspace members.
*/
async findMembers(
workspaceId: string,
options: FindWorkspaceMembersOptions = {}
) {
return await this.db.workspaceUserPermission.findMany({
where: {
workspaceId,
},
skip: options.skip,
take: options.take || 8,
orderBy: [{ createdAt: 'asc' }, { type: 'desc' }],
include: {
user: true,
},
});
}
/**
* Delete a workspace member by workspace id and user id.
* Except the owner, the owner can't be deleted.
*/
async deleteMember(workspaceId: string, userId: string) {
const member = await this.getMemberInAnyStatus(workspaceId, userId);
// We shouldn't revoke owner permission
// should auto deleted by workspace/user delete cascading
if (!member || member.type === WorkspaceRole.Owner) {
return false;
}
await this.db.workspaceUserPermission.deleteMany({
where: {
workspaceId,
userId,
},
});
this.logger.log(
`Deleted workspace member ${userId} from workspace ${workspaceId}`
);
await this.notifyMembersUpdated(workspaceId);
if (
member.status === WorkspaceMemberStatus.UnderReview ||
member.status === WorkspaceMemberStatus.NeedMoreSeatAndReview
) {
this.event.emit('workspace.members.requestDeclined', {
workspaceId,
userId,
});
}
return true;
}
private async notifyMembersUpdated(workspaceId: string) {
const count = await this.getMemberTotalCount(workspaceId);
this.event.emit('workspace.members.updated', {
workspaceId,
count,
});
}
/**
* Get the workspace member total count, including pending and accepted.
*/
async getMemberTotalCount(workspaceId: string) {
return await this.db.workspaceUserPermission.count({
where: {
workspaceId,
},
});
}
/**
* Get the workspace member used count, only count the accepted member
*/
async getMemberUsedCount(workspaceId: string) {
return await this.db.workspaceUserPermission.count({
where: {
workspaceId,
OR: this.acceptedCondition,
},
});
}
/**
* Refresh the workspace member seat status.
*/
@Transactional()
async refreshMemberSeatStatus(workspaceId: string, memberLimit: number) {
const usedCount = await this.getMemberUsedCount(workspaceId);
const availableCount = memberLimit - usedCount;
if (availableCount <= 0) {
return;
}
const members = await this.db.workspaceUserPermission.findMany({
select: { id: true, status: true },
where: {
workspaceId,
status: {
in: [
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
],
},
},
// find the oldest members first
orderBy: { createdAt: 'asc' },
});
const needChange = members.slice(0, availableCount);
const groups = groupBy(needChange, m => m.status);
const toPendings = groups.NeedMoreSeat;
if (toPendings) {
// NeedMoreSeat => Pending
await this.db.workspaceUserPermission.updateMany({
where: { id: { in: toPendings.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.Pending },
});
}
const toUnderReviews = groups.NeedMoreSeatAndReview;
if (toUnderReviews) {
// NeedMoreSeatAndReview => UnderReview
await this.db.workspaceUserPermission.updateMany({
where: { id: { in: toUnderReviews.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.UnderReview },
});
}
}
/**
* Accepted condition for workspace member.
*/
private get acceptedCondition() {
return [
{
// keep compatibility with old data
accepted: true,
},
{
status: WorkspaceMemberStatus.Accepted,
},
];
}
/**
* NeedMoreSeat => Pending
*
* NeedMoreSeatAndReview => UnderReview
*
* Pending | UnderReview => Accepted
*/
private getAllowedStatusSource(
to: WorkspaceMemberStatus
): WorkspaceMemberStatus[] {
switch (to) {
case WorkspaceMemberStatus.NeedMoreSeat:
return [WorkspaceMemberStatus.Pending];
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
return [WorkspaceMemberStatus.UnderReview];
case WorkspaceMemberStatus.Pending:
case WorkspaceMemberStatus.UnderReview: // need admin to review in team workspace
return [WorkspaceMemberStatus.Accepted];
default:
return [];
}
}
// #endregion // #endregion
} }

View File

@@ -31,7 +31,7 @@ import {
} from '../../base'; } from '../../base';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { Admin } from '../../core/common'; import { Admin } from '../../core/common';
import { PermissionService } from '../../core/permission'; import { AccessController } from '../../core/permission';
import { UserType } from '../../core/user'; import { UserType } from '../../core/user';
import { PromptService } from './prompt'; import { PromptService } from './prompt';
import { ChatSessionService } from './session'; import { ChatSessionService } from './session';
@@ -299,7 +299,7 @@ export class CopilotType {
@Resolver(() => CopilotType) @Resolver(() => CopilotType)
export class CopilotResolver { export class CopilotResolver {
constructor( constructor(
private readonly permissions: PermissionService, private readonly ac: AccessController,
private readonly mutex: RequestMutex, private readonly mutex: RequestMutex,
private readonly chatSession: ChatSessionService, private readonly chatSession: ChatSessionService,
private readonly storage: CopilotStorage private readonly storage: CopilotStorage
@@ -339,7 +339,11 @@ export class CopilotResolver {
@Args('options', { nullable: true }) options?: QueryChatSessionsInput @Args('options', { nullable: true }) options?: QueryChatSessionsInput
) { ) {
if (!copilot.workspaceId) return []; if (!copilot.workspaceId) return [];
await this.permissions.checkCloudWorkspace(copilot.workspaceId, user.id); await this.ac
.user(user.id)
.workspace(copilot.workspaceId)
.allowLocal()
.assert('Workspace.Copilot');
return await this.chatSession.listSessions( return await this.chatSession.listSessions(
user.id, user.id,
copilot.workspaceId, copilot.workspaceId,
@@ -360,14 +364,17 @@ export class CopilotResolver {
if (!workspaceId) { if (!workspaceId) {
return []; return [];
} else if (docId) { } else if (docId) {
await this.permissions.checkCloudPagePermission( await this.ac
workspaceId, .user(user.id)
docId, .doc({ workspaceId, docId })
'Doc.Read', .allowLocal()
user.id .assert('Doc.Read');
);
} else { } else {
await this.permissions.checkCloudWorkspace(workspaceId, user.id); await this.ac
.user(user.id)
.workspace(workspaceId)
.allowLocal()
.assert('Workspace.Copilot');
} }
const histories = await this.chatSession.listHistories( const histories = await this.chatSession.listHistories(
@@ -393,12 +400,7 @@ export class CopilotResolver {
@Args({ name: 'options', type: () => CreateChatSessionInput }) @Args({ name: 'options', type: () => CreateChatSessionInput })
options: CreateChatSessionInput options: CreateChatSessionInput
) { ) {
await this.permissions.checkCloudPagePermission( await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
options.workspaceId,
options.docId,
'Doc.Update',
user.id
);
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`; const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag); await using lock = await this.mutex.acquire(lockFlag);
if (!lock) { if (!lock) {
@@ -432,12 +434,11 @@ export class CopilotResolver {
throw new CopilotSessionNotFound(); throw new CopilotSessionNotFound();
} }
const { workspaceId, docId } = session.config; const { workspaceId, docId } = session.config;
await this.permissions.checkCloudPagePermission( await this.ac
workspaceId, .user(user.id)
docId, .doc(workspaceId, docId)
'Doc.Update', .allowLocal()
user.id .assert('Doc.Update');
);
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`; const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag); await using lock = await this.mutex.acquire(lockFlag);
if (!lock) { if (!lock) {
@@ -460,12 +461,7 @@ export class CopilotResolver {
@Args({ name: 'options', type: () => ForkChatSessionInput }) @Args({ name: 'options', type: () => ForkChatSessionInput })
options: ForkChatSessionInput options: ForkChatSessionInput
) { ) {
await this.permissions.checkCloudPagePermission( await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
options.workspaceId,
options.docId,
'Doc.Update',
user.id
);
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`; const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag); await using lock = await this.mutex.acquire(lockFlag);
if (!lock) { if (!lock) {
@@ -494,12 +490,7 @@ export class CopilotResolver {
@Args({ name: 'options', type: () => DeleteSessionInput }) @Args({ name: 'options', type: () => DeleteSessionInput })
options: DeleteSessionInput options: DeleteSessionInput
) { ) {
await this.permissions.checkCloudPagePermission( await this.ac.user(user.id).doc(options).allowLocal().assert('Doc.Update');
options.workspaceId,
options.docId,
'Doc.Update',
user.id
);
if (!options.sessionIds.length) { if (!options.sessionIds.length) {
return new NotFoundException('Session not found'); return new NotFoundException('Session not found');
} }
@@ -567,7 +558,7 @@ export class CopilotResolver {
@Throttle() @Throttle()
@Resolver(() => UserType) @Resolver(() => UserType)
export class UserCopilotResolver { export class UserCopilotResolver {
constructor(private readonly permissions: PermissionService) {} constructor(private readonly ac: AccessController) {}
@ResolveField(() => CopilotType) @ResolveField(() => CopilotType)
async copilot( async copilot(
@@ -575,7 +566,11 @@ export class UserCopilotResolver {
@Args('workspaceId', { nullable: true }) workspaceId?: string @Args('workspaceId', { nullable: true }) workspaceId?: string
) { ) {
if (workspaceId) { if (workspaceId) {
await this.permissions.checkCloudWorkspace(workspaceId, user.id); await this.ac
.user(user.id)
.workspace(workspaceId)
.allowLocal()
.assert('Workspace.Copilot');
} }
return { workspaceId }; return { workspaceId };
} }

View File

@@ -11,7 +11,7 @@ import {
import { ActionForbidden, Config } from '../../base'; import { ActionForbidden, Config } from '../../base';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { PermissionService, WorkspaceRole } from '../../core/permission'; import { AccessController } from '../../core/permission';
import { WorkspaceType } from '../../core/workspaces'; import { WorkspaceType } from '../../core/workspaces';
import { SubscriptionRecurring } from '../payment/types'; import { SubscriptionRecurring } from '../payment/types';
import { LicenseService } from './service'; import { LicenseService } from './service';
@@ -39,7 +39,7 @@ export class LicenseResolver {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly service: LicenseService, private readonly service: LicenseService,
private readonly permission: PermissionService private readonly ac: AccessController
) {} ) {}
@ResolveField(() => License, { @ResolveField(() => License, {
@@ -58,12 +58,10 @@ export class LicenseResolver {
return null; return null;
} }
await this.permission.checkWorkspaceIs( await this.ac
workspace.id, .user(user.id)
user.id, .workspace(workspace.id)
WorkspaceRole.Owner .assert('Workspace.Payment.Manage');
);
return this.service.getLicense(workspace.id); return this.service.getLicense(workspace.id);
} }
@@ -77,11 +75,10 @@ export class LicenseResolver {
throw new ActionForbidden(); throw new ActionForbidden();
} }
await this.permission.checkWorkspaceIs( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Owner .assert('Workspace.Payment.Manage');
);
return this.service.activateTeamLicense(workspaceId, license); return this.service.activateTeamLicense(workspaceId, license);
} }
@@ -95,11 +92,10 @@ export class LicenseResolver {
throw new ActionForbidden(); throw new ActionForbidden();
} }
await this.permission.checkWorkspaceIs( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Owner .assert('Workspace.Payment.Manage');
);
return this.service.deactivateTeamLicense(workspaceId); return this.service.deactivateTeamLicense(workspaceId);
} }
@@ -113,11 +109,10 @@ export class LicenseResolver {
throw new ActionForbidden(); throw new ActionForbidden();
} }
await this.permission.checkWorkspaceIs( await this.ac
workspaceId, .user(user.id)
user.id, .workspace(workspaceId)
WorkspaceRole.Owner .assert('Workspace.Payment.Manage');
);
const { url } = await this.service.createCustomerPortal(workspaceId); const { url } = await this.service.createCustomerPortal(workspaceId);

View File

@@ -11,7 +11,6 @@ import {
UserFriendlyError, UserFriendlyError,
WorkspaceLicenseAlreadyExists, WorkspaceLicenseAlreadyExists,
} from '../../base'; } from '../../base';
import { PermissionService } from '../../core/permission';
import { Models } from '../../models'; import { Models } from '../../models';
import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types'; import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types';
@@ -30,7 +29,6 @@ export class LicenseService implements OnModuleInit {
private readonly config: Config, private readonly config: Config,
private readonly db: PrismaClient, private readonly db: PrismaClient,
private readonly event: EventBus, private readonly event: EventBus,
private readonly permission: PermissionService,
private readonly models: Models private readonly models: Models
) {} ) {}
@@ -63,7 +61,7 @@ export class LicenseService implements OnModuleInit {
memberLimit: quantity, memberLimit: quantity,
} }
); );
await this.permission.refreshSeatStatus(workspaceId, quantity); await this.models.workspaceUser.refresh(workspaceId, quantity);
break; break;
default: default:
break; break;

View File

@@ -11,6 +11,7 @@ import {
SubscriptionPlanNotFound, SubscriptionPlanNotFound,
URLHelper, URLHelper,
} from '../../../base'; } from '../../../base';
import { Models } from '../../../models';
import { import {
KnownStripeInvoice, KnownStripeInvoice,
KnownStripePrice, KnownStripePrice,
@@ -48,7 +49,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
stripe: Stripe, stripe: Stripe,
db: PrismaClient, db: PrismaClient,
private readonly url: URLHelper, private readonly url: URLHelper,
private readonly event: EventBus private readonly event: EventBus,
private readonly models: Models
) { ) {
super(stripe, db); super(stripe, db);
} }
@@ -101,11 +103,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
return { allow_promotion_codes: true }; return { allow_promotion_codes: true };
})(); })();
const count = await this.db.workspaceUserPermission.count({ const count = await this.models.workspaceUser.count(args.workspaceId);
where: {
workspaceId: args.workspaceId,
},
});
return this.stripe.checkout.sessions.create({ return this.stripe.checkout.sessions.create({
customer: customer.stripeCustomerId, customer: customer.stripeCustomerId,

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnEvent } from '../../base'; import { OnEvent } from '../../base';
import { PermissionService } from '../../core/permission';
import { WorkspaceService } from '../../core/workspaces/resolvers'; import { WorkspaceService } from '../../core/workspaces/resolvers';
import { Models } from '../../models'; import { Models } from '../../models';
import { SubscriptionPlan } from './types'; import { SubscriptionPlan } from './types';
@@ -9,7 +8,6 @@ import { SubscriptionPlan } from './types';
@Injectable() @Injectable()
export class QuotaOverride { export class QuotaOverride {
constructor( constructor(
private readonly permission: PermissionService,
private readonly workspace: WorkspaceService, private readonly workspace: WorkspaceService,
private readonly models: Models private readonly models: Models
) {} ) {}
@@ -32,7 +30,7 @@ export class QuotaOverride {
memberLimit: quantity, memberLimit: quantity,
} }
); );
await this.permission.refreshSeatStatus(workspaceId, quantity); await this.models.workspaceUser.refresh(workspaceId, quantity);
if (!isTeam) { if (!isTeam) {
// this event will triggered when subscription is activated or changed // this event will triggered when subscription is activated or changed
// we only send emails when the team workspace is activated // we only send emails when the team workspace is activated

View File

@@ -27,7 +27,7 @@ import {
WorkspaceIdRequiredToUpdateTeamSubscription, WorkspaceIdRequiredToUpdateTeamSubscription,
} from '../../base'; } from '../../base';
import { CurrentUser, Public } from '../../core/auth'; import { CurrentUser, Public } from '../../core/auth';
import { PermissionService, WorkspaceRole } from '../../core/permission'; import { AccessController } from '../../core/permission';
import { UserType } from '../../core/user'; import { UserType } from '../../core/user';
import { WorkspaceType } from '../../core/workspaces'; import { WorkspaceType } from '../../core/workspaces';
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager'; import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
@@ -520,7 +520,7 @@ export class WorkspaceSubscriptionResolver {
constructor( constructor(
private readonly service: WorkspaceSubscriptionManager, private readonly service: WorkspaceSubscriptionManager,
private readonly db: PrismaClient, private readonly db: PrismaClient,
private readonly permission: PermissionService private readonly ac: AccessController
) {} ) {}
@ResolveField(() => SubscriptionType, { @ResolveField(() => SubscriptionType, {
@@ -542,11 +542,11 @@ export class WorkspaceSubscriptionResolver {
@CurrentUser() me: CurrentUser, @CurrentUser() me: CurrentUser,
@Parent() workspace: WorkspaceType @Parent() workspace: WorkspaceType
) { ) {
await this.permission.checkWorkspace( await this.ac
workspace.id, .user(me.id)
me.id, .workspace(workspace.id)
WorkspaceRole.Owner .assert('Workspace.Payment.Manage');
);
return this.db.invoice.count({ return this.db.invoice.count({
where: { where: {
targetId: workspace.id, targetId: workspace.id,
@@ -562,11 +562,10 @@ export class WorkspaceSubscriptionResolver {
take: number, take: number,
@Args('skip', { type: () => Int, nullable: true }) skip?: number @Args('skip', { type: () => Int, nullable: true }) skip?: number
) { ) {
await this.permission.checkWorkspace( await this.ac
workspace.id, .user(me.id)
me.id, .workspace(workspace.id)
WorkspaceRole.Owner .assert('Workspace.Payment.Manage');
);
return this.db.invoice.findMany({ return this.db.invoice.findMany({
where: { where: {

View File

@@ -339,6 +339,7 @@ enum ErrorNames {
CANNOT_DELETE_OWN_ACCOUNT CANNOT_DELETE_OWN_ACCOUNT
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS
CAN_NOT_REVOKE_YOURSELF
CAPTCHA_VERIFICATION_FAILED CAPTCHA_VERIFICATION_FAILED
COPILOT_ACTION_TAKEN COPILOT_ACTION_TAKEN
COPILOT_CONTEXT_FILE_NOT_SUPPORTED COPILOT_CONTEXT_FILE_NOT_SUPPORTED
@@ -399,6 +400,7 @@ enum ErrorNames {
NO_COPILOT_PROVIDER_AVAILABLE NO_COPILOT_PROVIDER_AVAILABLE
OAUTH_ACCOUNT_ALREADY_CONNECTED OAUTH_ACCOUNT_ALREADY_CONNECTED
OAUTH_STATE_EXPIRED OAUTH_STATE_EXPIRED
OWNER_CAN_NOT_LEAVE_WORKSPACE
PASSWORD_REQUIRED PASSWORD_REQUIRED
QUERY_TOO_LONG QUERY_TOO_LONG
RUNTIME_CONFIG_NOT_FOUND RUNTIME_CONFIG_NOT_FOUND
@@ -677,7 +679,7 @@ type Mutation {
"""add a doc to context""" """add a doc to context"""
addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]! addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
approveMember(userId: String!, workspaceId: String!): String! approveMember(userId: String!, workspaceId: String!): Boolean!
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
changeEmail(email: String!, token: String!): UserType! changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!, userId: String): Boolean! changePassword(newPassword: String!, token: String!, userId: String): Boolean!
@@ -725,7 +727,7 @@ type Mutation {
forkCopilotSession(options: ForkChatSessionInput!): String! forkCopilotSession(options: ForkChatSessionInput!): String!
generateLicenseKey(sessionId: String!): String! generateLicenseKey(sessionId: String!): String!
grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean! grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean!
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String! grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean!
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String! invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String!
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean! leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
@@ -854,13 +856,10 @@ type Query {
getInviteInfo(inviteId: String!): InvitationType! getInviteInfo(inviteId: String!): InvitationType!
"""Get is admin of workspace""" """Get is admin of workspace"""
isAdmin(workspaceId: String!): Boolean! isAdmin(workspaceId: String!): Boolean! @deprecated(reason: "use WorkspaceType[role] instead")
"""Get is owner of workspace""" """Get is owner of workspace"""
isOwner(workspaceId: String!): Boolean! isOwner(workspaceId: String!): Boolean! @deprecated(reason: "use WorkspaceType[role] instead")
"""List blobs of workspace"""
listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead")
"""List all copilot prompts""" """List all copilot prompts"""
listCopilotPrompts: [CopilotPromptType!]! listCopilotPrompts: [CopilotPromptType!]!
@@ -892,7 +891,7 @@ type Query {
workspace(id: String!): WorkspaceType! workspace(id: String!): WorkspaceType!
"""Get workspace role permissions""" """Get workspace role permissions"""
workspaceRolePermissions(id: String!): WorkspaceRolePermissions! workspaceRolePermissions(id: String!): WorkspaceRolePermissions! @deprecated(reason: "use WorkspaceType[permissions] instead")
"""Get all accessible workspaces for current user""" """Get all accessible workspaces for current user"""
workspaces: [WorkspaceType!]! workspaces: [WorkspaceType!]!
@@ -1266,13 +1265,20 @@ type WorkspacePermissionNotFoundDataType {
} }
type WorkspacePermissions { type WorkspacePermissions {
Workspace_Adminitrators_Manage: Boolean!
Workspace_Blobs_List: Boolean!
Workspace_Blobs_Read: Boolean!
Workspace_Blobs_Write: Boolean!
Workspace_Copilot: Boolean!
Workspace_CreateDoc: Boolean! Workspace_CreateDoc: Boolean!
Workspace_Delete: Boolean! Workspace_Delete: Boolean!
Workspace_Organize_Read: Boolean! Workspace_Organize_Read: Boolean!
Workspace_Payment_Manage: Boolean!
Workspace_Properties_Create: Boolean! Workspace_Properties_Create: Boolean!
Workspace_Properties_Delete: Boolean! Workspace_Properties_Delete: Boolean!
Workspace_Properties_Read: Boolean! Workspace_Properties_Read: Boolean!
Workspace_Properties_Update: Boolean! Workspace_Properties_Update: Boolean!
Workspace_Read: Boolean!
Workspace_Settings_Read: Boolean! Workspace_Settings_Read: Boolean!
Workspace_Settings_Update: Boolean! Workspace_Settings_Update: Boolean!
Workspace_Sync: Boolean! Workspace_Sync: Boolean!
@@ -1354,6 +1360,9 @@ type WorkspaceType {
"""Cloud page metadata of workspace""" """Cloud page metadata of workspace"""
pageMeta(pageId: String!): WorkspacePageMeta! pageMeta(pageId: String!): WorkspacePageMeta!
"""map of action permissions"""
permissions: WorkspacePermissions!
"""is Public workspace""" """is Public workspace"""
public: Boolean! public: Boolean!

View File

@@ -92,7 +92,7 @@ export async function addUserToWorkspace(
if (workspace == null) { if (workspace == null) {
throw new Error(`workspace ${workspaceId} not found`); throw new Error(`workspace ${workspaceId} not found`);
} }
await client.workspaceUserPermission.create({ await client.workspaceUserRole.create({
data: { data: {
workspaceId: workspace.id, workspaceId: workspace.id,
userId, userId,