fix(server): batch grant page roles (#10007)

This commit is contained in:
forehalo
2025-02-07 05:55:07 +00:00
parent b9ad53ae68
commit 12cc94f32a
6 changed files with 70 additions and 98 deletions

View File

@@ -987,7 +987,7 @@ test('should be able to grant and revoke doc user role', async t => {
revokeDocUserRoles(input: { revokeDocUserRoles(input: {
workspaceId: "${ws.id}", workspaceId: "${ws.id}",
docId: "${pageId}", docId: "${pageId}",
userIds: ["${external.id}"] userId: "${external.id}"
}) })
} }
`, `,
@@ -1010,7 +1010,7 @@ test('should be able to grant and revoke doc user role', async t => {
revokeDocUserRoles(input: { revokeDocUserRoles(input: {
workspaceId: "${ws.id}", workspaceId: "${ws.id}",
docId: "${pageId}", docId: "${pageId}",
userIds: ["${read.id}"] userId: "${read.id}"
}) })
} }
`, `,
@@ -1019,7 +1019,7 @@ test('should be able to grant and revoke doc user role', async t => {
t.like(externalRes.body, { t.like(externalRes.body, {
errors: [ errors: [
{ {
message: `You do not have permission to access Space ${ws.id}.`, message: `You do not have permission to access doc ${pageId} under Space ${ws.id}.`,
}, },
], ],
}); });

View File

@@ -474,6 +474,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'invalid_input', type: 'invalid_input',
message: 'Page default role can not be owner.', message: 'Page default role can not be owner.',
}, },
can_not_batch_grant_page_owner_permissions: {
type: 'invalid_input',
message: 'Can not batch grant page owner permissions.',
},
// Subscription Errors // Subscription Errors
unsupported_subscription_plan: { unsupported_subscription_plan: {

View File

@@ -411,6 +411,12 @@ export class PageDefaultRoleCanNotBeOwner extends UserFriendlyError {
super('invalid_input', 'page_default_role_can_not_be_owner', message); super('invalid_input', 'page_default_role_can_not_be_owner', message);
} }
} }
export class CanNotBatchGrantPageOwnerPermissions extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'can_not_batch_grant_page_owner_permissions', message);
}
}
@ObjectType() @ObjectType()
class UnsupportedSubscriptionPlanDataType { class UnsupportedSubscriptionPlanDataType {
@Field() plan!: string @Field() plan!: string
@@ -768,6 +774,7 @@ export enum ErrorNames {
FAILED_TO_UPSERT_SNAPSHOT, FAILED_TO_UPSERT_SNAPSHOT,
ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE, ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE,
PAGE_DEFAULT_ROLE_CAN_NOT_BE_OWNER, PAGE_DEFAULT_ROLE_CAN_NOT_BE_OWNER,
CAN_NOT_BATCH_GRANT_PAGE_OWNER_PERMISSIONS,
UNSUPPORTED_SUBSCRIPTION_PLAN, UNSUPPORTED_SUBSCRIPTION_PLAN,
FAILED_TO_CHECKOUT, FAILED_TO_CHECKOUT,
INVALID_CHECKOUT_PARAMETERS, INVALID_CHECKOUT_PARAMETERS,

View File

@@ -1,15 +1,15 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma, WorkspacePageUserPermission } from '@prisma/client';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import { groupBy } from 'lodash-es'; import { groupBy } from 'lodash-es';
import { import {
CanNotBatchGrantPageOwnerPermissions,
DocAccessDenied, DocAccessDenied,
EventBus, EventBus,
OnEvent, OnEvent,
SpaceAccessDenied, SpaceAccessDenied,
SpaceOwnerNotFound, SpaceOwnerNotFound,
SpaceShouldHaveOnlyOneOwner,
WorkspacePermissionNotFound, WorkspacePermissionNotFound,
} from '../../base'; } from '../../base';
import { import {
@@ -737,17 +737,15 @@ export class PermissionService {
].filter(Boolean) as Prisma.PrismaPromise<any>[] ].filter(Boolean) as Prisma.PrismaPromise<any>[]
); );
return p; return p as WorkspacePageUserPermission;
} }
async revokePage(ws: string, page: string, users: string[]) { async revokePage(ws: string, page: string, user: string) {
const result = await this.prisma.workspacePageUserPermission.deleteMany({ const result = await this.prisma.workspacePageUserPermission.deleteMany({
where: { where: {
workspaceId: ws, workspaceId: ws,
pageId: page, pageId: page,
userId: { userId: user,
in: users,
},
type: { type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: DocRole.Owner, not: DocRole.Owner,
@@ -758,74 +756,30 @@ export class PermissionService {
return result.count > 0; return result.count > 0;
} }
async grantPagePermission( async batchGrantPage(
workspaceId: string, workspaceId: string,
pageId: string, pageId: string,
userIds: string[], userIds: string[],
role: DocRole role: DocRole
) { ) {
if (userIds.length === 0) { if (userIds.length === 0) {
return []; return 0;
}
if (role === DocRole.Owner && userIds.length > 1) {
throw new SpaceShouldHaveOnlyOneOwner({ spaceId: workspaceId });
} }
return await this.prisma.$transaction(async tx => if (role === DocRole.Owner) {
Promise.all( throw new CanNotBatchGrantPageOwnerPermissions();
userIds.map(id => }
tx.workspacePageUserPermission.upsert({
where: {
workspaceId_pageId_userId: {
workspaceId,
pageId,
userId: id,
},
},
create: {
workspaceId,
pageId,
userId: id,
type: role,
},
update: {
type: role,
},
})
)
)
);
}
async updatePagePermission( const result = await this.prisma.workspacePageUserPermission.createMany({
workspaceId: string, skipDuplicates: true,
pageId: string, data: userIds.map(id => ({
userId: string,
role: DocRole
) {
const permission = await this.prisma.workspacePageUserPermission.findFirst({
where: {
workspaceId, workspaceId,
pageId, pageId,
userId, userId: id,
},
});
if (!permission) {
return this.grantPage(workspaceId, pageId, userId, role);
}
return await this.prisma.workspacePageUserPermission.update({
where: {
workspaceId_pageId_userId: {
workspaceId,
pageId,
userId,
},
},
data: {
type: role, type: role,
}, })),
}); });
return result.count;
} }
} }

View File

@@ -37,7 +37,6 @@ import {
mapDocRoleToPermissions, mapDocRoleToPermissions,
PermissionService, PermissionService,
PublicPageMode, PublicPageMode,
WorkspaceRole,
} from '../../permission'; } from '../../permission';
import { PublicUserType } from '../../user'; import { PublicUserType } from '../../user';
import { DocID } from '../../utils/doc'; import { DocID } from '../../utils/doc';
@@ -94,15 +93,15 @@ class UpdateDocUserRoleInput {
} }
@InputType() @InputType()
class RevokeDocUserRolesInput { class RevokeDocUserRoleInput {
@Field(() => String) @Field(() => String)
docId!: string; docId!: string;
@Field(() => String) @Field(() => String)
workspaceId!: string; workspaceId!: string;
@Field(() => [String]) @Field(() => String)
userIds!: string[]; userId!: string;
} }
@InputType() @InputType()
@@ -263,10 +262,18 @@ export class PagePermissionResolver {
complexity: 4, complexity: 4,
}) })
async pageGrantedUsersList( async pageGrantedUsersList(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType, @Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string, @Args('pageId') pageId: string,
@Args('pagination') pagination: PaginationInput @Args('pagination') pagination: PaginationInput
): Promise<PaginatedGrantedDocUserType> { ): Promise<PaginatedGrantedDocUserType> {
await this.permission.checkPagePermission(
workspace.id,
pageId,
'Doc.Users.Read',
user.id
);
const docId = new DocID(pageId, workspace.id); const docId = new DocID(pageId, workspace.id);
const [permissions, totalCount] = await this.prisma.$transaction(tx => { const [permissions, totalCount] = await this.prisma.$transaction(tx => {
return Promise.all([ return Promise.all([
@@ -454,7 +461,7 @@ export class PagePermissionResolver {
'Doc.Users.Manage', 'Doc.Users.Manage',
user.id user.id
); );
await this.permission.grantPagePermission( await this.permission.batchGrantPage(
doc.workspace, doc.workspace,
doc.guid, doc.guid,
input.userIds, input.userIds,
@@ -471,7 +478,7 @@ export class PagePermissionResolver {
@Mutation(() => Boolean) @Mutation(() => Boolean)
async revokeDocUserRoles( async revokeDocUserRoles(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args('input') input: RevokeDocUserRolesInput @Args('input') input: RevokeDocUserRoleInput
): Promise<boolean> { ): Promise<boolean> {
const doc = new DocID(input.docId, input.workspaceId); const doc = new DocID(input.docId, input.workspaceId);
const pairs = { const pairs = {
@@ -488,15 +495,16 @@ export class PagePermissionResolver {
'Expect doc not to be workspace' 'Expect doc not to be workspace'
); );
} }
await this.permission.checkWorkspace( await this.permission.checkPagePermission(
doc.workspace, doc.workspace,
user.id, doc.guid,
WorkspaceRole.Collaborator 'Doc.Users.Manage',
user.id
); );
await this.permission.revokePage(doc.workspace, doc.guid, input.userIds); await this.permission.revokePage(doc.workspace, doc.guid, input.userId);
this.logger.log('Revoke doc user roles', { this.logger.log('Revoke doc user roles', {
...pairs, ...pairs,
userIds: input.userIds, userId: input.userId,
}); });
return true; return true;
} }
@@ -521,38 +529,36 @@ export class PagePermissionResolver {
'Expect doc not to be workspace' 'Expect doc not to be workspace'
); );
} }
await this.permission.checkWorkspace(
await this.permission.checkPagePermission(
doc.workspace, doc.workspace,
user.id, doc.guid,
WorkspaceRole.Collaborator input.role === DocRole.Owner ? 'Doc.TransferOwner' : 'Doc.Users.Manage',
user.id
); );
await this.permission.grantPage(
doc.workspace,
doc.guid,
input.userId,
input.role
);
if (input.role === DocRole.Owner) { if (input.role === DocRole.Owner) {
const ret = await this.permission.grantPagePermission(
doc.workspace,
doc.guid,
[input.userId],
input.role
);
this.logger.log('Transfer doc owner', { this.logger.log('Transfer doc owner', {
...pairs, ...pairs,
userId: input.userId, userId: input.userId,
role: input.role, role: input.role,
}); });
return ret.length > 0;
} else { } else {
await this.permission.updatePagePermission(
doc.workspace,
doc.guid,
input.userId,
input.role
);
this.logger.log('Update doc user role', { this.logger.log('Update doc user role', {
...pairs, ...pairs,
userId: input.userId, userId: input.userId,
role: input.role, role: input.role,
}); });
return true;
} }
return true;
} }
@Mutation(() => Boolean) @Mutation(() => Boolean)
@@ -580,7 +586,7 @@ export class PagePermissionResolver {
); );
} }
try { try {
await this.permission.checkCloudPagePermission( await this.permission.checkPagePermission(
doc.workspace, doc.workspace,
doc.guid, doc.guid,
'Doc.Users.Manage', 'Doc.Users.Manage',

View File

@@ -254,6 +254,7 @@ enum ErrorNames {
CANNOT_DELETE_ALL_ADMIN_ACCOUNT CANNOT_DELETE_ALL_ADMIN_ACCOUNT
CANNOT_DELETE_OWN_ACCOUNT CANNOT_DELETE_OWN_ACCOUNT
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION
CAN_NOT_BATCH_GRANT_PAGE_OWNER_PERMISSIONS
CAPTCHA_VERIFICATION_FAILED CAPTCHA_VERIFICATION_FAILED
COPILOT_ACTION_TAKEN COPILOT_ACTION_TAKEN
COPILOT_FAILED_TO_CREATE_MESSAGE COPILOT_FAILED_TO_CREATE_MESSAGE
@@ -629,7 +630,7 @@ type Mutation {
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
revoke(userId: String!, workspaceId: String!): Boolean! revoke(userId: String!, workspaceId: String!): Boolean!
revokeDocUserRoles(input: RevokeDocUserRolesInput!): Boolean! revokeDocUserRoles(input: RevokeDocUserRoleInput!): Boolean!
revokeInviteLink(workspaceId: String!): Boolean! revokeInviteLink(workspaceId: String!): Boolean!
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
@@ -809,9 +810,9 @@ type RemoveAvatar {
success: Boolean! success: Boolean!
} }
input RevokeDocUserRolesInput { input RevokeDocUserRoleInput {
docId: String! docId: String!
userIds: [String!]! userId: String!
workspaceId: String! workspaceId: String!
} }