fix: lock role count check

This commit is contained in:
Philipinho
2026-09-08 03:21:20 +01:00
parent 1d467d8c35
commit a962c17c22
4 changed files with 199 additions and 148 deletions
@@ -10,7 +10,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely';
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
import { Space, User } from '@docmost/db/types/entity.types';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
@@ -218,41 +218,18 @@ export class SpaceMemberService {
dto: RemoveSpaceMemberDto,
workspaceId: string,
): Promise<void> {
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
const memberTypeId = dto.userId
? { userId: dto.userId }
: dto.groupId
? { groupId: dto.groupId }
: null;
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
if (!memberTypeId) {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
let affectedUserIds: string[] = [];
if (dto.userId) {
affectedUserIds = [dto.userId];
@@ -262,7 +239,29 @@ export class SpaceMemberService {
);
}
await executeTx(this.db, async (trx) => {
const { space, spaceMember } = await executeTx(this.db, async (trx) => {
const space = await this.spaceRepo.findById(
dto.spaceId,
workspaceId,
{ withLock: true, trx },
);
if (!space) {
throw new NotFoundException('Space not found');
}
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
memberTypeId,
trx,
);
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
}
await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id,
dto.spaceId,
@@ -280,6 +279,8 @@ export class SpaceMemberService {
dto.spaceId,
{ trx },
);
return { space, spaceMember };
});
this.auditService.log({
@@ -304,49 +305,41 @@ export class SpaceMemberService {
dto: UpdateSpaceMemberRoleDto,
workspaceId: string,
): Promise<void> {
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
const memberTypeId = dto.userId
? { userId: dto.userId }
: dto.groupId
? { groupId: dto.groupId }
: null;
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
if (!memberTypeId) {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
const result = await executeTx(this.db, async (trx) => {
const space = await this.spaceRepo.findById(
dto.spaceId,
workspaceId,
{ withLock: true, trx },
);
if (!space) {
throw new NotFoundException('Space not found');
}
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
memberTypeId,
trx,
);
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === dto.role) {
return;
return { changed: false, space, spaceMember };
}
await executeTx(this.db, async (trx) => {
await trx
.selectFrom('spaces')
.select('id')
.where('id', '=', dto.spaceId)
.forUpdate()
.executeTakeFirst();
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
}
@@ -357,8 +350,16 @@ export class SpaceMemberService {
dto.spaceId,
trx,
);
return { changed: true, space, spaceMember };
});
if (!result.changed) {
return;
}
const { space, spaceMember } = result;
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
resourceType: AuditResource.SPACE_MEMBER,
@@ -387,7 +388,7 @@ export class SpaceMemberService {
spaceId,
trx,
);
if (spaceOwnerCount === 1) {
if (spaceOwnerCount <= 1) {
throw new BadRequestException(
'There must be at least one space admin with full access',
);
@@ -747,15 +747,25 @@ export class WorkspaceService {
userRoleDto: UpdateWorkspaceUserRoleDto,
workspaceId: string,
) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
const newRole = userRoleDto.role.toLowerCase();
const result = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(
userRoleDto.userId,
workspaceId,
{ trx },
);
if (!user) {
throw new BadRequestException('Workspace member not found');
}
// prevent ADMIN from managing OWNER role
if (
isAdminActingOnOwner(authUser.role, newRole) ||
isAdminActingOnOwner(authUser.role, user.role)
@@ -764,18 +774,15 @@ export class WorkspaceService {
}
if (user.role === newRole) {
return user;
return { changed: false, user };
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
if (
user.role === UserRole.OWNER &&
!user.deletedAt &&
!user.deactivatedAt
) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
await this.userRepo.updateUser(
@@ -784,8 +791,18 @@ export class WorkspaceService {
},
user.id,
workspaceId,
trx,
);
return { changed: true, user };
});
if (!result.changed) {
return result.user;
}
const { user } = result;
this.auditService.log({
event: AuditEvent.USER_ROLE_CHANGED,
resourceType: AuditResource.USER,
@@ -848,8 +865,16 @@ export class WorkspaceService {
userId: string,
workspaceId: string,
): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId);
const user = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const user = await this.userRepo.findById(userId, workspaceId, { trx });
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
@@ -869,19 +894,9 @@ export class WorkspaceService {
}
if (user.role === UserRole.OWNER) {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{ deactivatedAt: new Date() },
userId,
@@ -889,6 +904,8 @@ export class WorkspaceService {
trx,
);
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
return user;
});
this.auditService.log({
@@ -951,21 +968,18 @@ export class WorkspaceService {
userId: string,
workspaceId: string,
): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId);
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
const user = await executeTx(this.db, async (trx) => {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLock: true,
trx,
});
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
const user = await this.userRepo.findById(userId, workspaceId, { trx });
if (!user || user.deletedAt) {
throw new BadRequestException('Workspace member not found');
}
if (authUser.id === userId) {
@@ -973,10 +987,15 @@ export class WorkspaceService {
}
if (isAdminActingOnOwner(authUser.role, user.role)) {
throw new BadRequestException('You cannot delete a user with owner role');
throw new BadRequestException(
'You cannot delete a user with owner role',
);
}
if (user.role === UserRole.OWNER && !user.deactivatedAt) {
await this.validateLastWorkspaceOwner(workspaceId, trx);
}
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{
name: 'Deleted user',
@@ -1009,6 +1028,8 @@ export class WorkspaceService {
});
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
return user;
});
this.auditService.log({
@@ -1030,4 +1051,20 @@ export class WorkspaceService {
// empty
}
}
private async validateLastWorkspaceOwner(
workspaceId: string,
trx: KyselyTransaction,
): Promise<void> {
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
trx,
);
if (workspaceOwnerCount <= 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
}
}
@@ -25,7 +25,11 @@ export class SpaceRepo {
async findById(
spaceId: string,
workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
opts?: {
includeMemberCount?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
): Promise<Space> {
const db = dbOrTx(this.db, opts?.trx);
@@ -41,6 +45,11 @@ export class SpaceRepo {
} else {
query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`);
}
if (opts?.withLock && opts?.trx) {
query = query.forUpdate();
}
return query.executeTakeFirst();
}
@@ -145,12 +145,16 @@ export class UserRepo {
async roleCountByWorkspaceId(
role: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<number> {
const { count } = await this.db
const db = dbOrTx(this.db, trx);
const { count } = await db
.selectFrom('users')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.executeTakeFirst();
return count as number;