fix(users): add safe superuser management

Expose promotion and demotion to existing superusers while keeping shared and self targets blocked. Serialize role lifecycle changes, preserve one active superuser, and invalidate stale credentials.

Closes #1208
This commit is contained in:
neon
2026-08-30 05:44:37 +00:00
parent 67da9a2491
commit 3ea6063f31
17 changed files with 729 additions and 75 deletions
+61 -1
View File
@@ -2,7 +2,7 @@
import { Button } from '@/components/ui/button'
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { X } from '@lucide/vue'
import { ShieldCheck, X } from '@lucide/vue'
import { api } from '@/lib/api'
import { Permission, PERMISSION_LABELS } from '@bookorbit/types'
import type { AuthUser } from '@bookorbit/types'
@@ -27,11 +27,14 @@ const props = defineProps<{
user: Partial<AuthUser> | null
libraries: Library[]
defaultLibraryIds?: number[]
canManageSuperuser?: boolean
currentUserId?: number
}>()
const emit = defineEmits<{
close: []
saved: [resetUrl?: string]
requestSuperuserChange: [isSuperuser: boolean]
}>()
const { t } = useI18n()
@@ -71,6 +74,15 @@ const { search: searchGenres } = useGenreSearchWithIds()
const isEdit = computed(() => !!props.user?.id)
const isSuperuserTarget = computed(() => !!props.user?.isSuperuser)
const isOwnAccount = computed(() => props.user?.id === props.currentUserId)
const canChangeSuperuser = computed(
() =>
isEdit.value &&
props.canManageSuperuser === true &&
props.currentUserId !== undefined &&
!isOwnAccount.value &&
props.user?.provisioningMethod !== 'shared',
)
const isMobile = useMediaQuery('(max-width: 767px)')
function toggleLibrary(libraryId: number) {
@@ -298,6 +310,11 @@ function applyAdminPermissions() {
function clearPermissions() {
applyPreset('clear')
}
function requestSuperuserChange() {
if (!canChangeSuperuser.value) return
emit('requestSuperuserChange', !isSuperuserTarget.value)
}
</script>
<template>
@@ -393,6 +410,49 @@ function clearPermissions() {
</div>
<div v-if="currentTab === 'access'" class="space-y-6">
<section
v-if="isEdit && canManageSuperuser"
aria-labelledby="superuser-access-heading"
class="rounded-lg border border-border bg-muted p-4"
>
<div class="flex items-start gap-3">
<div class="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10">
<ShieldCheck :size="18" class="text-primary" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 id="superuser-access-heading" class="text-sm font-semibold text-foreground">
{{ t('adminFeature.userForm.superuserAccess.title') }}
</h3>
<Badge variant="secondary">
{{ isSuperuserTarget ? t('adminFeature.userForm.superuserAccess.enabled') : t('adminFeature.userForm.superuserAccess.disabled') }}
</Badge>
</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">
{{
isOwnAccount
? t('adminFeature.userForm.superuserAccess.selfHint')
: isSharedAccount
? t('adminFeature.userForm.superuserAccess.sharedHint')
: isSuperuserTarget
? t('adminFeature.userForm.superuserAccess.enabledHint')
: t('adminFeature.userForm.superuserAccess.disabledHint')
}}
</p>
<Button
v-if="canChangeSuperuser"
:variant="isSuperuserTarget ? 'destructive' : 'outline'"
size="sm"
type="button"
class="mt-3"
@click="requestSuperuserChange"
>
{{ isSuperuserTarget ? t('adminFeature.userForm.superuserAccess.demote') : t('adminFeature.userForm.superuserAccess.promote') }}
</Button>
</div>
</div>
</section>
<div v-if="libraries.length > 0" class="space-y-3">
<label class="settings-label">{{ t('adminFeature.userForm.libraryAccess') }}</label>
<div class="space-y-1.5 rounded-md border border-border p-3">
+83
View File
@@ -9,6 +9,7 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import { api } from '@/lib/api'
import { formatNumber } from '@/i18n/formatters'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useAuth } from '@/features/auth/composables/useAuth'
import { useUsers, type UserRow } from './composables/useUsers'
import { useSelfRegistration } from './composables/useSelfRegistration'
import UserFormDrawer from './UserFormDrawer.vue'
@@ -22,6 +23,7 @@ const HEADER_ACTIONS_TARGET = '#settings-header-actions'
const { t } = useI18n()
const { isSuperuser, hasPermission } = usePermissions()
const { user: currentUser } = useAuth()
const {
users,
@@ -56,7 +58,10 @@ const drawerOpen = ref(false)
const editingUser = ref<Partial<AuthUser> | null>(null)
const resetUrl = ref<string | null>(null)
const deleteConfirmUser = ref<UserRow | null>(null)
const superuserConfirm = ref<{ user: UserRow; isSuperuser: boolean } | null>(null)
const deleting = ref(false)
const changingSuperuser = ref(false)
const superuserError = ref<string | null>(null)
const actionError = ref<string | null>(null)
const busyUserId = ref<number | null>(null)
/**
@@ -95,6 +100,23 @@ const pageSubtitle = computed(() =>
}),
)
const superuserDialogTitle = computed(() =>
superuserConfirm.value?.isSuperuser
? t('adminFeature.userForm.superuserAccess.promoteDialogTitle')
: t('adminFeature.userForm.superuserAccess.demoteDialogTitle'),
)
const superuserDialogDescription = computed(() => {
const pending = superuserConfirm.value
if (!pending) return ''
return pending.isSuperuser
? t('adminFeature.userForm.superuserAccess.promoteDialogBody', { name: pending.user.name })
: t('adminFeature.userForm.superuserAccess.demoteDialogBody', { name: pending.user.name })
})
const superuserConfirmLabel = computed(() => {
if (changingSuperuser.value) return t('adminFeature.userForm.superuserAccess.changing')
return superuserConfirm.value?.isSuperuser ? t('adminFeature.userForm.superuserAccess.promote') : t('adminFeature.userForm.superuserAccess.demote')
})
onMounted(async () => {
headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null
await Promise.all([reload(), loadStatic()])
@@ -216,6 +238,51 @@ function cancelDeleteUser() {
deleteConfirmUser.value = null
}
function requestSuperuserChange(nextIsSuperuser: boolean) {
if (
!isSuperuser.value ||
currentUser.value?.id === undefined ||
!editingUser.value?.id ||
editingUser.value.id === currentUser.value?.id ||
editingUser.value.provisioningMethod === 'shared'
) {
return
}
superuserError.value = null
superuserConfirm.value = { user: editingUser.value as UserRow, isSuperuser: nextIsSuperuser }
}
function cancelSuperuserChange() {
if (changingSuperuser.value) return
superuserConfirm.value = null
superuserError.value = null
}
async function confirmSuperuserChange() {
const pending = superuserConfirm.value
if (!pending || changingSuperuser.value) return
changingSuperuser.value = true
superuserError.value = null
try {
const res = await api(`/api/v1/users/${pending.user.id}/superuser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isSuperuser: pending.isSuperuser }),
})
if (!res.ok) {
superuserError.value = t('adminFeature.userForm.superuserAccess.changeError')
return
}
superuserConfirm.value = null
drawerOpen.value = false
await reload()
} catch {
superuserError.value = t('adminFeature.userForm.superuserAccess.changeError')
} finally {
changingSuperuser.value = false
}
}
async function confirmDeleteUser() {
if (!deleteConfirmUser.value || deleting.value) return
deleting.value = true
@@ -443,8 +510,11 @@ async function saveDefaultLibraryAccess() {
:user="editingUser"
:libraries="libraries"
:default-library-ids="defaultLibraryIdsArray"
:can-manage-superuser="isSuperuser"
:current-user-id="currentUser?.id"
@close="closeDrawer"
@saved="onSaved"
@request-superuser-change="requestSuperuserChange"
/>
<ResetLinkModal v-if="resetUrl" :reset-url="resetUrl" @close="clearResetUrl" />
@@ -457,5 +527,18 @@ async function saveDefaultLibraryAccess() {
@confirm="confirmDeleteUser"
@cancel="cancelDeleteUser"
/>
<ConfirmDialog
:open="superuserConfirm !== null"
:destructive="superuserConfirm?.isSuperuser === false"
:title="superuserDialogTitle"
:description="superuserDialogDescription"
:confirm-label="superuserConfirmLabel"
:busy="changingSuperuser"
@confirm="confirmSuperuserChange"
@cancel="cancelSuperuserChange"
>
<p v-if="superuserError" role="alert" class="mt-3 text-sm text-destructive">{{ superuserError }}</p>
</ConfirmDialog>
</div>
</template>
@@ -3,8 +3,9 @@ import { computed } from 'vue'
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import UsersPage from '../UsersPage.vue'
const { apiMock, permState } = vi.hoisted(() => ({
const { apiMock, authState, permState } = vi.hoisted(() => ({
apiMock: vi.fn<(input: string, init?: RequestInit) => Promise<unknown>>(),
authState: { userId: 1 },
permState: { isSuperuser: true, denied: [] as string[] },
}))
@@ -17,6 +18,12 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({
}),
}))
vi.mock('@/features/auth/composables/useAuth', () => ({
useAuth: () => ({
user: computed(() => ({ id: authState.userId })),
}),
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
@@ -71,6 +78,7 @@ function stubApi(overrides: StubOptions = {}) {
if (input === '/api/v1/app-settings/allow_registration') return jsonResponse({ key: 'allow_registration', value: 'true' })
if (/\/api\/v1\/users\/\d+\/unlock$/.test(input)) return jsonResponse({})
if (/\/api\/v1\/users\/\d+\/reset-password$/.test(input)) return jsonResponse({ resetUrl: 'https://example.test/reset' })
if (/\/api\/v1\/users\/\d+\/superuser$/.test(input)) return jsonResponse({})
return { ok: false, json: async () => ({}) }
})
}
@@ -91,11 +99,21 @@ function stateButton(wrapper: ReturnType<typeof mount>, label: string) {
return wrapper.findAll('[role="group"] button').find((button) => button.text().startsWith(label))
}
async function openUserAccess(wrapper: ReturnType<typeof mount>) {
const edit = wrapper.findAll('button').find((button) => button.text().trim() === 'Edit')
await edit?.trigger('click')
await flushPromises()
const access = wrapper.findAll('button').find((button) => button.text().trim() === 'access')
await access?.trigger('click')
await flushPromises()
}
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
permState.isSuperuser = true
permState.denied = []
authState.userId = 1
stubApi()
})
@@ -430,6 +448,94 @@ describe('UsersPage roster', () => {
})
})
describe('UsersPage superuser access', () => {
it('promotes an eligible account through an explicit confirmation', async () => {
const wrapper = mount(UsersPage, { attachTo: document.body })
await flushPromises()
await openUserAccess(wrapper)
const promote = wrapper.findAll('button').find((button) => button.text().trim() === 'Promote to superuser')
expect(promote).toBeDefined()
await promote?.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('Grant Ada Lovelace unrestricted server access.')
const confirm = Array.from(document.body.querySelectorAll('button'))
.filter((button) => button.textContent?.trim() === 'Promote to superuser')
.at(-1)
confirm?.click()
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/v1/users/4/superuser', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isSuperuser: true }),
})
expect(wrapper.find('section[aria-labelledby="superuser-access-heading"]').exists()).toBe(false)
wrapper.unmount()
})
it('offers demotion and preserves the distinction from the Admin permission preset', async () => {
stubApi({ users: [{ ...USER, isSuperuser: true, permissions: ['manage_users'] }], summary: { admins: 1 } })
const wrapper = mount(UsersPage)
await flushPromises()
await openUserAccess(wrapper)
const panel = wrapper.find('section[aria-labelledby="superuser-access-heading"]')
expect(panel.text()).toContain('Superuser access')
expect(panel.text()).toContain('Its saved permissions, libraries, and restrictions will apply again after demotion.')
expect(panel.findAll('button').some((button) => button.text().trim() === 'Remove superuser access')).toBe(true)
})
it('does not expose the privilege action to a permission-based administrator', async () => {
permState.isSuperuser = false
const wrapper = mount(UsersPage)
await flushPromises()
await openUserAccess(wrapper)
expect(wrapper.find('section[aria-labelledby="superuser-access-heading"]').exists()).toBe(false)
})
it('explains why self and shared targets cannot be promoted', async () => {
authState.userId = USER.id
const selfWrapper = mount(UsersPage)
await flushPromises()
await openUserAccess(selfWrapper)
expect(selfWrapper.text()).toContain('You cannot change your own superuser access.')
expect(selfWrapper.findAll('button').some((button) => button.text().trim() === 'Promote to superuser')).toBe(false)
authState.userId = 1
stubApi({ users: [{ ...USER, provisioningMethod: 'shared' }] })
const sharedWrapper = mount(UsersPage)
await flushPromises()
await openUserAccess(sharedWrapper)
expect(sharedWrapper.text()).toContain('Shared accounts cannot be promoted to superuser.')
expect(sharedWrapper.findAll('button').some((button) => button.text().trim() === 'Promote to superuser')).toBe(false)
})
it('keeps the confirmation open and shows localized copy when the transition fails', async () => {
const wrapper = mount(UsersPage, { attachTo: document.body })
await flushPromises()
await openUserAccess(wrapper)
await wrapper
.findAll('button')
.find((button) => button.text().trim() === 'Promote to superuser')
?.trigger('click')
await flushPromises()
apiMock.mockResolvedValueOnce({ ok: false, json: async () => ({ message: 'internal server wording' }) })
const confirm = Array.from(document.body.querySelectorAll('button'))
.filter((button) => button.textContent?.trim() === 'Promote to superuser')
.at(-1)
confirm?.click()
await flushPromises()
expect(document.body.textContent).toContain('Failed to update superuser access. Reload the account and try again.')
expect(document.body.textContent).not.toContain('internal server wording')
wrapper.unmount()
})
})
describe('UsersPage attention band', () => {
const LOCKED = {
id: 9,
+17
View File
@@ -3037,6 +3037,23 @@
},
"superuserTarget": "Superuser Target",
"superuserTargetHint": "Content restrictions cannot be applied to superusers.",
"superuserAccess": {
"title": "Superuser access",
"enabled": "Enabled",
"disabled": "Disabled",
"enabledHint": "This account has unrestricted server access. Its saved permissions, libraries, and restrictions will apply again after demotion.",
"disabledHint": "Promotion grants unrestricted server access and bypasses permissions, library assignments, and content restrictions.",
"selfHint": "You cannot change your own superuser access.",
"sharedHint": "Shared accounts cannot be promoted to superuser.",
"promote": "Promote to superuser",
"demote": "Remove superuser access",
"promoteDialogTitle": "Promote to superuser?",
"promoteDialogBody": "Grant {name} unrestricted server access. Their active sessions will end, and they must sign in again.",
"demoteDialogTitle": "Remove superuser access?",
"demoteDialogBody": "Remove unrestricted access from {name}. Their saved permissions, libraries, and restrictions will take effect, and they must sign in again.",
"changing": "Updating access...",
"changeError": "Failed to update superuser access. Reload the account and try again."
},
"noRestrictions": "No content restrictions",
"noRestrictionsHint": "This user has full access to all books in their assigned libraries.",
"addRestrictions": "+ Add Content Restrictions",
@@ -11,7 +11,7 @@ export type AuditRequest = Omit<FastifyRequest, 'params' | 'body'> & {
};
export interface AuditableOptions {
action: AuditAction;
action: AuditAction | ((req: AuditRequest, responseBody: unknown) => AuditAction);
resource?: AuditResource;
getResourceId?: (req: AuditRequest, responseBody: unknown) => number | undefined;
getMeta?: (req: AuditRequest, responseBody: unknown) => Record<string, unknown> | undefined;
@@ -38,7 +38,7 @@ describe('AuditInterceptor', () => {
get: vi.fn().mockImplementation((key: string) => {
if (key !== AUDITABLE_KEY) return undefined;
return {
action: 'smart_scope.create',
action: (_req: unknown, body: { source: string }) => (body.source === 'test' ? 'smart_scope.create' : 'smart_scope.update'),
resource: 'smart_scope',
getResourceId: (_req: unknown, body: { id: number }) => body.id,
getMeta: (_req: unknown, body: { source: string }) => ({ source: body.source }),
@@ -24,13 +24,14 @@ export class AuditInterceptor implements NestInterceptor {
return next.handle().pipe(
tap((responseBody) => {
const user = req.user;
const action = typeof options.action === 'function' ? options.action(req, responseBody) : options.action;
const description = typeof options.description === 'function' ? options.description(req, responseBody) : options.description;
const meta = options.getMeta?.(req, responseBody);
this.auditEvents.emit(AUDIT_EVENT, {
userId: user?.id ?? null,
actorUsername: user?.username ?? 'system',
action: options.action,
action,
resource: options.resource,
resourceId: options.getResourceId?.(req, responseBody),
description,
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SetSuperuserDto {
@IsBoolean()
isSuperuser!: boolean;
}
@@ -171,7 +171,7 @@ describe('UserController', () => {
await controller.updateUser(8, { name: 'updated' } as any, requester);
await controller.deleteUser(8, requester);
await controller.setPermissions(8, dto as any, requester);
await controller.setSuperuser(8, true, requester);
await controller.setSuperuser(8, { isSuperuser: true }, requester);
await controller.getLibraries(8);
await controller.setLibraries(8, setLibrariesDto as any, requester);
await controller.adminResetPassword(8, requester);
@@ -239,10 +239,13 @@ describe('UserController', () => {
expect(deleteAudit.description({ params: { id: '16' } }, null)).toBe('Deleted user #16');
const superuserAudit = Reflect.getMetadata(AUDITABLE_KEY, UserController.prototype.setSuperuser) as {
action: (req: { body: { isSuperuser: boolean } }, responseBody: unknown) => AuditAction;
getResourceId: (req: { params: Record<string, string> }) => number;
description: (req: { params: Record<string, string>; body: { isSuperuser?: boolean } }, responseBody: unknown) => string;
};
expect(superuserAudit.getResourceId({ params: { id: '17' } })).toBe(17);
expect(superuserAudit.action({ body: { isSuperuser: true } }, null)).toBe(AuditAction.UserSuperuserEnable);
expect(superuserAudit.action({ body: { isSuperuser: false } }, null)).toBe(AuditAction.UserSuperuserDisable);
expect(superuserAudit.description({ params: { id: '17' }, body: { isSuperuser: true } }, null)).toBe('Enabled superuser for user #17');
expect(superuserAudit.description({ params: { id: '17' }, body: { isSuperuser: false } }, null)).toBe('Disabled superuser for user #17');
});
+4 -8
View File
@@ -10,7 +10,6 @@ import {
HttpCode,
HttpStatus,
Param,
ParseBoolPipe,
ParseIntPipe,
Patch,
Post,
@@ -34,6 +33,7 @@ import { CreateSharedUserDto } from './dto/create-shared-user.dto';
import { ListUsersDto } from './dto/list-users.dto';
import { SetLibrariesDto } from './dto/set-libraries.dto';
import { SetPermissionsDto } from './dto/set-permissions.dto';
import { SetSuperuserDto } from './dto/set-superuser.dto';
import { SetContentFiltersDto } from './dto/set-content-filters.dto';
import { UpdateMeDto } from './dto/update-me.dto';
import { UpdateMeSettingsDto } from './dto/update-me-settings.dto';
@@ -258,7 +258,7 @@ export class UserController {
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.ManageUsers)
@Auditable({
action: AuditAction.UserSuperuserEnable,
action: (req) => ((req.body as SetSuperuserDto).isSuperuser ? AuditAction.UserSuperuserEnable : AuditAction.UserSuperuserDisable),
resource: AuditResource.User,
getResourceId: (req) => parseInt(req.params['id'] as string, 10),
description: (req) => {
@@ -267,12 +267,8 @@ export class UserController {
return `${action} superuser for user #${req.params['id']}`;
},
})
setSuperuser(
@Param('id', ParseIntPipe) id: number,
@Body('isSuperuser', ParseBoolPipe) isSuperuser: boolean,
@CurrentUser() requestingUser: RequestUser,
) {
return this.userService.setSuperuser(id, isSuperuser, requestingUser);
setSuperuser(@Param('id', ParseIntPipe) id: number, @Body() dto: SetSuperuserDto, @CurrentUser() requestingUser: RequestUser) {
return this.userService.setSuperuser(id, dto.isSuperuser, requestingUser);
}
@Get(':id/libraries')
+7
View File
@@ -7,6 +7,7 @@ import { Permission } from '@bookorbit/types';
import { CreateUserDto } from './dto/create-user.dto';
import { SetLibrariesDto } from './dto/set-libraries.dto';
import { SetPermissionsDto } from './dto/set-permissions.dto';
import { SetSuperuserDto } from './dto/set-superuser.dto';
import { UpdateMeDto } from './dto/update-me.dto';
import { UpdateMeSettingsDto } from './dto/update-me-settings.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@@ -35,6 +36,12 @@ describe('User DTO validation', () => {
expect(await hasErrors(plainToInstance(SetPermissionsDto, { permissionNames: [] }))).toBe(false);
});
it('SetSuperuserDto requires a boolean', async () => {
expect(await hasErrors(plainToInstance(SetSuperuserDto, { isSuperuser: true }))).toBe(false);
expect(await hasErrors(plainToInstance(SetSuperuserDto, { isSuperuser: 'true' }))).toBe(true);
expect(await hasErrors(plainToInstance(SetSuperuserDto, {}))).toBe(true);
});
it('SetLibrariesDto requires positive integer IDs', async () => {
expect(await hasErrors(plainToInstance(SetLibrariesDto, { libraryIds: [1, 2] }))).toBe(false);
expect(await hasErrors(plainToInstance(SetLibrariesDto, { libraryIds: [0] }))).toBe(true);
+144 -5
View File
@@ -481,18 +481,157 @@ describe('UserRepository', () => {
);
});
it('delete and setSuperuser issue scoped user updates', async () => {
it('delete issues a scoped user update', async () => {
const deleteWhere = vi.fn().mockResolvedValue(undefined);
db.delete.mockReturnValue({ where: deleteWhere });
await repo.delete(12);
expect(db.delete).toHaveBeenCalledWith(schema.users);
expect(deleteWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.users.id, right: 12 }));
});
await repo.setSuperuser(12, true);
expect(db.update).toHaveBeenCalledWith(schema.users);
expect(updateSet).toHaveBeenCalledWith({ isSuperuser: true });
expect(updateWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.users.id, right: 12 }));
it('setSuperuser serializes the transition and revokes target sessions in the same transaction', async () => {
const execute = vi.fn().mockResolvedValue(undefined);
const lifecycleWhere = vi.fn().mockResolvedValue([
{ id: 1, active: true, isSuperuser: true, provisioningMethod: 'local' },
{ id: 12, active: true, isSuperuser: false, provisioningMethod: 'local' },
]);
const lifecycleFrom = vi.fn().mockReturnValue({ where: lifecycleWhere });
const txSelect = vi.fn().mockReturnValue({ from: lifecycleFrom });
const userWhere = vi.fn().mockResolvedValue(undefined);
const refreshWhere = vi.fn().mockResolvedValue(undefined);
const passwordResetWhere = vi.fn().mockResolvedValue(undefined);
const userSet = vi.fn().mockReturnValue({ where: userWhere });
const refreshSet = vi.fn().mockReturnValue({ where: refreshWhere });
const passwordResetSet = vi.fn().mockReturnValue({ where: passwordResetWhere });
const txUpdate = vi.fn().mockImplementation((table) => ({
set: table === schema.users ? userSet : table === schema.refreshTokens ? refreshSet : passwordResetSet,
}));
const tx = { execute, select: txSelect, update: txUpdate };
db.transaction.mockImplementation((callback) => callback(tx as never));
await expect(repo.setSuperuser(1, 12, true)).resolves.toBe('updated');
expect(execute).toHaveBeenCalledOnce();
expect(txUpdate).toHaveBeenNthCalledWith(1, schema.users);
expect(userSet).toHaveBeenCalledWith(
expect.objectContaining({ isSuperuser: true, tokenVersion: expect.objectContaining({ op: 'sql' }), updatedAt: expect.any(Date) }),
);
expect(txUpdate).toHaveBeenNthCalledWith(2, schema.refreshTokens);
expect(refreshSet).toHaveBeenCalledWith({ revokedAt: expect.any(Date) });
expect(txUpdate).toHaveBeenNthCalledWith(3, schema.passwordResetTokens);
expect(passwordResetSet).toHaveBeenCalledWith({ usedAt: expect.any(Date) });
expect(userWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.users.id, right: 12 }));
expect(refreshWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'and' }));
expect(passwordResetWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'and' }));
});
it('setSuperuser rechecks the requester after taking the lifecycle lock', async () => {
const execute = vi.fn().mockResolvedValue(undefined);
const lifecycleWhere = vi.fn().mockResolvedValue([
{ id: 1, active: true, isSuperuser: false, provisioningMethod: 'local' },
{ id: 12, active: true, isSuperuser: true, provisioningMethod: 'local' },
]);
const lifecycleFrom = vi.fn().mockReturnValue({ where: lifecycleWhere });
const tx = { execute, select: vi.fn().mockReturnValue({ from: lifecycleFrom }), update: vi.fn() };
db.transaction.mockImplementation((callback) => callback(tx as never));
await expect(repo.setSuperuser(1, 12, false)).resolves.toBe('requester_not_superuser');
expect(tx.update).not.toHaveBeenCalled();
});
it('setSuperuser rejects a shared-account promotion without writing', async () => {
const lifecycleWhere = vi.fn().mockResolvedValue([
{ id: 1, active: true, isSuperuser: true, provisioningMethod: 'local' },
{ id: 12, active: true, isSuperuser: false, provisioningMethod: 'shared' },
]);
const lifecycleFrom = vi.fn().mockReturnValue({ where: lifecycleWhere });
const tx = {
execute: vi.fn().mockResolvedValue(undefined),
select: vi.fn().mockReturnValue({ from: lifecycleFrom }),
update: vi.fn(),
};
db.transaction.mockImplementation((callback) => callback(tx as never));
await expect(repo.setSuperuser(1, 12, true)).resolves.toBe('shared_target');
expect(tx.update).not.toHaveBeenCalled();
});
it('setSuperuser leaves sessions untouched for an idempotent transition', async () => {
const lifecycleWhere = vi.fn().mockResolvedValue([
{ id: 1, active: true, isSuperuser: true, provisioningMethod: 'local' },
{ id: 12, active: true, isSuperuser: false, provisioningMethod: 'local' },
]);
const lifecycleFrom = vi.fn().mockReturnValue({ where: lifecycleWhere });
const tx = {
execute: vi.fn().mockResolvedValue(undefined),
select: vi.fn().mockReturnValue({ from: lifecycleFrom }),
update: vi.fn(),
};
db.transaction.mockImplementation((callback) => callback(tx as never));
await expect(repo.setSuperuser(1, 12, false)).resolves.toBe('unchanged');
expect(tx.update).not.toHaveBeenCalled();
});
it('setSuperuser prevents demotion of the last active superuser', async () => {
const lifecycleWhere = vi.fn().mockResolvedValue([
{ id: 1, active: true, isSuperuser: true, provisioningMethod: 'local' },
{ id: 12, active: true, isSuperuser: true, provisioningMethod: 'local' },
]);
const lifecycleFrom = vi.fn().mockReturnValue({ where: lifecycleWhere });
const countWhere = vi.fn().mockResolvedValue([{ total: 0 }]);
const countFrom = vi.fn().mockReturnValue({ where: countWhere });
const tx = {
execute: vi.fn().mockResolvedValue(undefined),
select: vi.fn().mockReturnValueOnce({ from: lifecycleFrom }).mockReturnValueOnce({ from: countFrom }),
update: vi.fn(),
};
db.transaction.mockImplementation((callback) => callback(tx as never));
await expect(repo.setSuperuser(1, 12, false)).resolves.toBe('last_superuser');
expect(tx.update).not.toHaveBeenCalled();
});
it('updateManagedUser serializes deactivation and protects the last active superuser', async () => {
const lifecycleWhere = vi.fn().mockResolvedValue([
{ id: 1, active: true, isSuperuser: true, provisioningMethod: 'local' },
{ id: 12, active: true, isSuperuser: true, provisioningMethod: 'local' },
]);
const lifecycleFrom = vi.fn().mockReturnValue({ where: lifecycleWhere });
const countWhere = vi.fn().mockResolvedValue([{ total: 0 }]);
const countFrom = vi.fn().mockReturnValue({ where: countWhere });
const tx = {
execute: vi.fn().mockResolvedValue(undefined),
select: vi.fn().mockReturnValueOnce({ from: lifecycleFrom }).mockReturnValueOnce({ from: countFrom }),
update: vi.fn(),
};
db.transaction.mockImplementation((callback) => callback(tx as never));
await expect(repo.updateManagedUser(1, 12, { active: false })).resolves.toEqual({ status: 'last_superuser' });
expect(tx.execute).toHaveBeenCalledOnce();
expect(tx.update).not.toHaveBeenCalled();
});
it('deleteManagedUser serializes deletion and protects the last active superuser', async () => {
const lifecycleWhere = vi.fn().mockResolvedValue([
{ id: 1, active: true, isSuperuser: true, provisioningMethod: 'local' },
{ id: 12, active: true, isSuperuser: true, provisioningMethod: 'local' },
]);
const lifecycleFrom = vi.fn().mockReturnValue({ where: lifecycleWhere });
const countWhere = vi.fn().mockResolvedValue([{ total: 0 }]);
const countFrom = vi.fn().mockReturnValue({ where: countWhere });
const tx = {
execute: vi.fn().mockResolvedValue(undefined),
select: vi.fn().mockReturnValueOnce({ from: lifecycleFrom }).mockReturnValueOnce({ from: countFrom }),
delete: vi.fn(),
};
db.transaction.mockImplementation((callback) => callback(tx as never));
await expect(repo.deleteManagedUser(1, 12)).resolves.toBe('last_superuser');
expect(tx.execute).toHaveBeenCalledOnce();
expect(tx.delete).not.toHaveBeenCalled();
});
it('clearLockout resets the attempt counter and the lock timestamp', async () => {
+114 -2
View File
@@ -12,6 +12,12 @@ import { DB } from '../../db';
import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
const SUPERUSER_LIFECYCLE_LOCK_KEY = 'bookorbit:superuser-lifecycle';
export type ManagedUserMutationStatus = 'updated' | 'target_not_found' | 'requester_not_superuser' | 'last_superuser';
export type SuperuserTransitionStatus = ManagedUserMutationStatus | 'unchanged' | 'self_target' | 'shared_target';
/**
* An account needs an administrator's attention when it is locked out, is still on the
@@ -393,10 +399,56 @@ export class UserRepository {
return user;
}
async updateManagedUser(
requestingUserId: number,
targetUserId: number,
data: Partial<Pick<typeof schema.users.$inferInsert, 'name' | 'email' | 'active'>>,
): Promise<{ status: ManagedUserMutationStatus; user?: Awaited<ReturnType<UserRepository['update']>> }> {
return this.db.transaction(async (tx) => {
await this.lockSuperuserLifecycle(tx);
const { requester, target } = await this.findLifecycleUsers(tx, requestingUserId, targetUserId);
if (!target) return { status: 'target_not_found' };
if (target.isSuperuser && !this.isActiveSuperuser(requester)) return { status: 'requester_not_superuser' };
if (data.active === false && target.isSuperuser && (await this.countOtherActiveSuperusers(tx, targetUserId)) === 0) {
return { status: 'last_superuser' };
}
const [user] = await tx
.update(schema.users)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.users.id, targetUserId))
.returning({
id: schema.users.id,
username: schema.users.username,
name: schema.users.name,
email: schema.users.email,
active: schema.users.active,
isDefaultPassword: schema.users.isDefaultPassword,
settings: schema.users.settings,
createdAt: schema.users.createdAt,
updatedAt: schema.users.updatedAt,
});
return user ? { status: 'updated', user } : { status: 'target_not_found' };
});
}
async delete(id: number) {
await this.db.delete(schema.users).where(eq(schema.users.id, id));
}
async deleteManagedUser(requestingUserId: number, targetUserId: number): Promise<ManagedUserMutationStatus> {
return this.db.transaction(async (tx) => {
await this.lockSuperuserLifecycle(tx);
const { requester, target } = await this.findLifecycleUsers(tx, requestingUserId, targetUserId);
if (!target) return 'target_not_found';
if (target.isSuperuser && !this.isActiveSuperuser(requester)) return 'requester_not_superuser';
if (target.isSuperuser && (await this.countOtherActiveSuperusers(tx, targetUserId)) === 0) return 'last_superuser';
await tx.delete(schema.users).where(eq(schema.users.id, targetUserId));
return 'updated';
});
}
async setPermissions(userId: number, permissionNames: Permission[]) {
await this.db.transaction(async (tx) => {
await tx.delete(schema.userPermissions).where(eq(schema.userPermissions.userId, userId));
@@ -406,8 +458,36 @@ export class UserRepository {
});
}
async setSuperuser(userId: number, isSuperuser: boolean) {
await this.db.update(schema.users).set({ isSuperuser }).where(eq(schema.users.id, userId));
async setSuperuser(requestingUserId: number, targetUserId: number, isSuperuser: boolean): Promise<SuperuserTransitionStatus> {
return this.db.transaction(async (tx) => {
await this.lockSuperuserLifecycle(tx);
const { requester, target } = await this.findLifecycleUsers(tx, requestingUserId, targetUserId);
if (requestingUserId === targetUserId) return 'self_target';
if (!this.isActiveSuperuser(requester)) return 'requester_not_superuser';
if (!target) return 'target_not_found';
if (isSuperuser && target.provisioningMethod === 'shared') return 'shared_target';
if (target.isSuperuser === isSuperuser) return 'unchanged';
if (!isSuperuser && (await this.countOtherActiveSuperusers(tx, targetUserId)) === 0) return 'last_superuser';
const now = new Date();
await tx
.update(schema.users)
.set({
isSuperuser,
tokenVersion: sql`${schema.users.tokenVersion} + 1`,
updatedAt: now,
})
.where(eq(schema.users.id, targetUserId));
await tx
.update(schema.refreshTokens)
.set({ revokedAt: now })
.where(and(eq(schema.refreshTokens.userId, targetUserId), isNull(schema.refreshTokens.revokedAt)));
await tx
.update(schema.passwordResetTokens)
.set({ usedAt: now })
.where(and(eq(schema.passwordResetTokens.userId, targetUserId), isNull(schema.passwordResetTokens.usedAt)));
return 'updated';
});
}
async countOtherSuperusers(excludeUserId: number): Promise<number> {
@@ -418,6 +498,38 @@ export class UserRepository {
return Number(total);
}
private async lockSuperuserLifecycle(tx: Tx): Promise<void> {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${SUPERUSER_LIFECYCLE_LOCK_KEY})::bigint)`);
}
private async findLifecycleUsers(tx: Tx, requestingUserId: number, targetUserId: number) {
const rows = await tx
.select({
id: schema.users.id,
active: schema.users.active,
isSuperuser: schema.users.isSuperuser,
provisioningMethod: schema.users.provisioningMethod,
})
.from(schema.users)
.where(inArray(schema.users.id, [requestingUserId, targetUserId]));
return {
requester: rows.find((row) => row.id === requestingUserId),
target: rows.find((row) => row.id === targetUserId),
};
}
private isActiveSuperuser(user: { active: boolean; isSuperuser: boolean } | undefined): boolean {
return user?.active === true && user.isSuperuser;
}
private async countOtherActiveSuperusers(tx: Tx, excludeUserId: number): Promise<number> {
const [{ total }] = await tx
.select({ total: count() })
.from(schema.users)
.where(and(eq(schema.users.active, true), eq(schema.users.isSuperuser, true), ne(schema.users.id, excludeUserId)));
return Number(total);
}
async incrementTokenVersion(userId: number) {
await this.db
.update(schema.users)
+29 -22
View File
@@ -35,8 +35,10 @@ describe('UserService', () => {
findAll: vi.fn(),
findAssignable: vi.fn(),
update: vi.fn(),
updateManagedUser: vi.fn(),
countOtherSuperusers: vi.fn(),
delete: vi.fn(),
deleteManagedUser: vi.fn(),
setSuperuser: vi.fn(),
assignViewerLibraries: vi.fn(),
findLibraryIdsByUserId: vi.fn(),
@@ -75,6 +77,9 @@ describe('UserService', () => {
userRepo.findByEmail.mockResolvedValue(null);
userRepo.generateResetToken.mockResolvedValue('reset-token');
userRepo.findExistingLibraryIds.mockImplementation((ids: number[]) => Promise.resolve(ids));
userRepo.updateManagedUser.mockResolvedValue({ status: 'updated', user: { id: 2 } });
userRepo.deleteManagedUser.mockResolvedValue('updated');
userRepo.setSuperuser.mockResolvedValue('updated');
});
it('createUser rejects duplicate usernames', async () => {
@@ -226,7 +231,7 @@ describe('UserService', () => {
it('updateUser prevents deactivating the last administrator', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true });
userRepo.countOtherSuperusers.mockResolvedValue(0);
userRepo.updateManagedUser.mockResolvedValue({ status: 'last_superuser' });
await expect(service.updateUser(2, { active: false }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
});
@@ -240,7 +245,7 @@ describe('UserService', () => {
it('updateUser throws if repository update returns null after checks', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
userRepo.update.mockResolvedValue(null);
userRepo.updateManagedUser.mockResolvedValue({ status: 'updated' });
await expect(service.updateUser(2, { name: 'x' }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException);
});
@@ -441,32 +446,26 @@ describe('UserService', () => {
});
it('deleteUser throws when target user does not exist', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue(null);
userRepo.countOtherSuperusers.mockResolvedValue(0);
userRepo.deleteManagedUser.mockResolvedValue('target_not_found');
await expect(service.deleteUser(88, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException);
});
it('deleteUser blocks non-superusers from deleting superuser accounts', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true });
userRepo.countOtherSuperusers.mockResolvedValue(1);
userRepo.deleteManagedUser.mockResolvedValue('requester_not_superuser');
await expect(service.deleteUser(2, reqUser({ isSuperuser: false }))).rejects.toBeInstanceOf(ForbiddenException);
});
it('deleteUser blocks deleting the last superuser', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true });
userRepo.countOtherSuperusers.mockResolvedValue(0);
userRepo.deleteManagedUser.mockResolvedValue('last_superuser');
await expect(service.deleteUser(2, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
});
it('deleteUser deletes non-superuser targets', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
userRepo.countOtherSuperusers.mockResolvedValue(3);
await expect(service.deleteUser(2, reqUser({ isSuperuser: false }))).resolves.toBeUndefined();
expect(userRepo.delete).toHaveBeenCalledWith(2);
expect(userRepo.deleteManagedUser).toHaveBeenCalledWith(1, 2);
});
it('setPermissions blocks modifying own permissions', async () => {
@@ -508,32 +507,40 @@ describe('UserService', () => {
});
it('setSuperuser throws when target user does not exist', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue(null);
userRepo.setSuperuser.mockResolvedValue('target_not_found');
await expect(service.setSuperuser(22, false, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException);
});
it('setSuperuser prevents removing the last administrator', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true });
userRepo.countOtherSuperusers.mockResolvedValue(0);
userRepo.setSuperuser.mockResolvedValue('last_superuser');
await expect(service.setSuperuser(2, false, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
});
it('setSuperuser writes the target superuser flag when allowed', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
await expect(service.setSuperuser(2, true, reqUser({ isSuperuser: true }))).resolves.toBeUndefined();
expect(userRepo.setSuperuser).toHaveBeenCalledWith(2, true);
expect(userRepo.setSuperuser).toHaveBeenCalledWith(1, 2, true);
});
it('setSuperuser skips last-admin check when target is already non-superuser', async () => {
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
it('setSuperuser accepts an idempotent transition', async () => {
userRepo.setSuperuser.mockResolvedValue('unchanged');
await service.setSuperuser(2, false, reqUser({ isSuperuser: true }));
expect(userRepo.countOtherSuperusers).not.toHaveBeenCalled();
expect(userRepo.setSuperuser).toHaveBeenCalledWith(2, false);
expect(userRepo.setSuperuser).toHaveBeenCalledWith(1, 2, false);
});
it('setSuperuser blocks promoting a shared account', async () => {
userRepo.setSuperuser.mockResolvedValue('shared_target');
await expect(service.setSuperuser(2, true, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(BadRequestException);
});
it('setSuperuser rejects an actor demoted after request authentication', async () => {
userRepo.setSuperuser.mockResolvedValue('requester_not_superuser');
await expect(service.setSuperuser(2, true, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ForbiddenException);
});
it('getLibraryIds throws when target user does not exist', async () => {
+16 -29
View File
@@ -208,16 +208,12 @@ export class UserService {
await this.assertEmailAvailable(dto.email, id);
}
if (dto.active === false && target.isSuperuser) {
const otherSuperusers = await this.userRepo.countOtherSuperusers(id);
if (otherSuperusers === 0) {
throw new ConflictException('Cannot deactivate the last administrator');
}
}
const user = await this.userRepo.update(id, dto);
if (!user) throw new NotFoundException('User not found');
return user;
const result = await this.userRepo.updateManagedUser(requestingUser.id, id, dto);
if (result.status === 'target_not_found') throw new NotFoundException('User not found');
if (result.status === 'requester_not_superuser') throw new ForbiddenException('Only administrators can edit administrator accounts');
if (result.status === 'last_superuser') throw new ConflictException('Cannot deactivate the last administrator');
if (!result.user) throw new NotFoundException('User not found');
return result.user;
}
async updateMe(userId: number, dto: UpdateMeDto) {
@@ -334,13 +330,10 @@ export class UserService {
if (id === requestingUser.id) {
throw new ConflictException('You cannot delete your own account');
}
const [target, otherSuperusers] = await Promise.all([this.userRepo.findByIdWithPermissions(id), this.userRepo.countOtherSuperusers(id)]);
if (!target) throw new NotFoundException('User not found');
if (target?.isSuperuser) {
if (!requestingUser.isSuperuser) throw new ForbiddenException('Only administrators can delete administrator accounts');
if (otherSuperusers === 0) throw new ConflictException('Cannot delete the last administrator');
}
await this.userRepo.delete(id);
const result = await this.userRepo.deleteManagedUser(requestingUser.id, id);
if (result === 'target_not_found') throw new NotFoundException('User not found');
if (result === 'requester_not_superuser') throw new ForbiddenException('Only administrators can delete administrator accounts');
if (result === 'last_superuser') throw new ConflictException('Cannot delete the last administrator');
}
setPermissionsDirectly(userId: number, permissionNames: Permission[]) {
@@ -369,18 +362,12 @@ export class UserService {
if (targetUserId === requestingUser.id) {
throw new ConflictException('You cannot change your own superuser status');
}
const target = await this.userRepo.findByIdWithPermissions(targetUserId);
if (!target) throw new NotFoundException('User not found');
if (target.provisioningMethod === 'shared') {
throw new BadRequestException('Shared accounts cannot be made superuser');
}
if (!isSuperuser && target.isSuperuser) {
const otherSuperusers = await this.userRepo.countOtherSuperusers(targetUserId);
if (otherSuperusers === 0) {
throw new ConflictException('Cannot remove the last administrator');
}
}
await this.userRepo.setSuperuser(targetUserId, isSuperuser);
const result = await this.userRepo.setSuperuser(requestingUser.id, targetUserId, isSuperuser);
if (result === 'self_target') throw new ConflictException('You cannot change your own superuser status');
if (result === 'requester_not_superuser') throw new ForbiddenException('Only administrators can change superuser status');
if (result === 'target_not_found') throw new NotFoundException('User not found');
if (result === 'shared_target') throw new BadRequestException('Shared accounts cannot be made superuser');
if (result === 'last_superuser') throw new ConflictException('Cannot remove the last administrator');
}
async getLibraryIds(userId: number): Promise<number[]> {
@@ -290,7 +290,7 @@ async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<strin
return body.accessToken;
}
async function loginForToken(app: NestFastifyApplication, username: string, password: string): Promise<string | null> {
export async function loginForToken(app: NestFastifyApplication, username: string, password: string): Promise<string | null> {
const response = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
+132 -2
View File
@@ -3,7 +3,11 @@ import { mkdir, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import type { Permission } from '@bookorbit/types';
import { Permission as PermissionEnum } from '@bookorbit/types';
import { AuditAction, Permission as PermissionEnum } from '@bookorbit/types';
import { and, count, eq, inArray } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { UserRepository } from '../src/modules/user/user.repository';
import {
authHeader,
@@ -12,6 +16,7 @@ import {
createOidcUser,
createUserAndLogin,
createUsersAdminLifecycleE2EContext,
loginForToken,
setUserActive,
type CreatedLibrary,
type OidcUserSeed,
@@ -57,6 +62,17 @@ function expectError(response: Awaited<ReturnType<UsersAdminLifecycleE2EContext[
expect(message).toContain(messageFragment);
}
async function waitForAuditAction(ctx: UsersAdminLifecycleE2EContext, resourceId: number, action: AuditAction): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
const row = await ctx.db.query.auditLog.findFirst({
where: and(eq(schema.auditLog.resourceId, resourceId), eq(schema.auditLog.action, action)),
});
if (row) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`Audit action ${action} was not recorded for user ${resourceId}`);
}
function parseTokenFromResetUrl(resetUrl: string): string {
const token = new URL(resetUrl).searchParams.get('token');
if (!token) {
@@ -449,8 +465,53 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
expectError(nonSuperSetSuperuser, 403, 'Only administrators can change superuser status');
});
it('allows superuser toggling for positive superuser transfer flows', async () => {
it('strictly validates superuser transition payloads', async () => {
const candidate = await createUserAndLogin(ctx);
const invalidPayloads: Array<Record<string, unknown>> = [{ isSuperuser: 'true' }, {}, { isSuperuser: true, unexpected: true }];
for (const payload of invalidPayloads) {
const response = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/users/${candidate.userId}/superuser`,
headers: authHeader(ctx.adminToken),
payload,
});
expect(response.statusCode).toBe(400);
}
});
it('rejects promotion of shared accounts', async () => {
const suffix = randomUUID().replaceAll('-', '');
const createShared = await ctx.app.inject({
method: 'POST',
url: '/api/v1/users/shared',
headers: authHeader(ctx.adminToken),
payload: {
username: `users-admin-lifecycle-shared-${suffix}`,
name: `Users Admin Lifecycle Shared ${suffix}`,
},
});
expect(createShared.statusCode).toBe(201);
const sharedUserId = (createShared.json() as { id: number }).id;
const promote = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/users/${sharedUserId}/superuser`,
headers: authHeader(ctx.adminToken),
payload: { isSuperuser: true },
});
expectError(promote, 400, 'Shared accounts cannot be made superuser');
});
it('toggles superuser access, invalidates existing sessions, and records distinct audit actions', async () => {
const candidate = await createUserAndLogin(ctx);
const createResetLink = await ctx.app.inject({
method: 'POST',
url: `/api/v1/users/${candidate.userId}/reset-password`,
headers: authHeader(manageUsersAdmin.accessToken),
});
expect(createResetLink.statusCode).toBe(201);
const prePromotionResetToken = parseTokenFromResetUrl((createResetLink.json() as { resetUrl: string }).resetUrl);
const promote = await ctx.app.inject({
method: 'PUT',
@@ -459,6 +520,24 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
payload: { isSuperuser: true },
});
expect(promote.statusCode).toBe(204);
await waitForAuditAction(ctx, candidate.userId, AuditAction.UserSuperuserEnable);
const oldSessionAfterPromote = await ctx.app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: authHeader(candidate.accessToken),
});
expect(oldSessionAfterPromote.statusCode).toBe(401);
const resetAfterPromote = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/reset-password',
payload: {
token: prePromotionResetToken,
newPassword: 'ShouldNotReplacePassword123',
},
});
expectError(resetAfterPromote, 400, 'Invalid or expired reset token');
const readAfterPromote = await ctx.app.inject({
method: 'GET',
@@ -468,6 +547,9 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
expect(readAfterPromote.statusCode).toBe(200);
expect((readAfterPromote.json() as { isSuperuser: boolean }).isSuperuser).toBe(true);
const promotedAccessToken = await loginForToken(ctx.app, candidate.username, candidate.password);
expect(promotedAccessToken).not.toBeNull();
const demote = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/users/${candidate.userId}/superuser`,
@@ -475,6 +557,14 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
payload: { isSuperuser: false },
});
expect(demote.statusCode).toBe(204);
await waitForAuditAction(ctx, candidate.userId, AuditAction.UserSuperuserDisable);
const promotedSessionAfterDemote = await ctx.app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: authHeader(promotedAccessToken!),
});
expect(promotedSessionAfterDemote.statusCode).toBe(401);
const readAfterDemote = await ctx.app.inject({
method: 'GET',
@@ -485,6 +575,46 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
expect((readAfterDemote.json() as { isSuperuser: boolean }).isSuperuser).toBe(false);
});
it('preserves one active superuser during concurrent cross-demotion attempts', async () => {
const originalSuperusers = await ctx.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.isSuperuser, true));
const first = await createUserAndLogin(ctx, { isSuperuser: true });
const second = await createUserAndLogin(ctx, { isSuperuser: true });
const userRepository = ctx.app.get(UserRepository);
try {
await ctx.db.update(schema.users).set({ isSuperuser: false });
await ctx.db
.update(schema.users)
.set({ isSuperuser: true })
.where(inArray(schema.users.id, [first.userId, second.userId]));
const statuses = await Promise.all([
userRepository.setSuperuser(first.userId, second.userId, false),
userRepository.setSuperuser(second.userId, first.userId, false),
]);
expect(statuses.toSorted()).toEqual(['requester_not_superuser', 'updated']);
const [{ total }] = await ctx.db
.select({ total: count() })
.from(schema.users)
.where(and(eq(schema.users.active, true), eq(schema.users.isSuperuser, true)));
expect(Number(total)).toBe(1);
} finally {
await ctx.db.update(schema.users).set({ isSuperuser: false });
if (originalSuperusers.length > 0) {
await ctx.db
.update(schema.users)
.set({ isSuperuser: true })
.where(
inArray(
schema.users.id,
originalSuperusers.map((user) => user.id),
),
);
}
}
});
it('blocks self-superuser toggle by a superuser', async () => {
const selfToggle = await ctx.app.inject({
method: 'PUT',