import { getPermissions, requirePermission, } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { softDeleteAccount } from '$lib/server/auth/account-deletion'; import { error, fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { avatarUrl } from '$lib/avatar'; import { deleteUploadedAvatar } from '$lib/server/avatars'; import { canRemoveAuthenticationMethod, getAuthenticationMethods, } from '$lib/server/auth/methods'; import { getPagination } from '$lib/server/pagination'; import { purgeExpiredLoginMetadata } from '$lib/server/auth/session'; import { recordIsacAudit } from '$lib/server/audit'; import { createNotification } from '$lib/server/notifications'; export const load: PageServerLoad = async ({ cookies, params, url }) => { const operator = await requirePermission( await getSessionUser(cookies), 'users.view', ); const database = await getDatabase(); await purgeExpiredLoginMetadata(); const loginQuery = url.searchParams.get('loginQ')?.trim() ?? ''; const loginStatus = ['success', 'failed'].includes( url.searchParams.get('loginStatus') ?? '', ) ? String(url.searchParams.get('loginStatus')) : ''; const buildQuery = url.searchParams.get('buildQ')?.trim() ?? ''; const buildVisibility = ['draft', 'private', 'public'].includes( url.searchParams.get('buildVisibility') ?? '', ) ? String(url.searchParams.get('buildVisibility')) : ''; const commentQuery = url.searchParams.get('commentQ')?.trim() ?? ''; const pattern = (value: string) => `%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`; const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\' OR COALESCE(client_name,'') LIKE ? ESCAPE '\\' OR COALESCE(operating_system,'') LIKE ? ESCAPE '\\' OR COALESCE(failure_reason,'') LIKE ? ESCAPE '\\')${loginStatus ? ' AND succeeded=?' : ''}`; const loginPattern = pattern(loginQuery); const loginArgs: unknown[] = [ params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : []), ]; const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`; const buildArgs: unknown[] = [ params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : []), ]; const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') LIKE ? ESCAPE '\\' COLLATE NOCASE)`; const commentArgs: unknown[] = [ params.id, pattern(commentQuery), pattern(commentQuery), ]; const [loginCount, buildCount, commentCount] = await Promise.all([ ( await database.prepare( `SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`, ) ).get(...loginArgs), ( await database.prepare( `SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`, ) ).get(...buildArgs), ( await database.prepare(`SELECT COUNT(*) AS count FROM comments JOIN comment_threads ON comment_threads.id=comments.thread_id LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id WHERE ${commentWhere}`) ).get(...commentArgs), ]); const loginPagination = getPagination( url, Number(loginCount?.count ?? 0), 'login', ); const buildPagination = getPagination( url, Number(buildCount?.count ?? 0), 'build', ); const commentPagination = getPagination( url, Number(commentCount?.count ?? 0), 'comment', ); const [ profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods, emails, passkeys, ] = await Promise.all([ ( await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name, users.role,users.discord_id,users.created_at, CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password, discord_profiles.username AS discord_username,discord_profiles.display_name AS discord_display_name, discord_profiles.avatar_hash AS discord_avatar_hash,user_avatars.source AS avatar_source, user_avatars.value AS avatar_value,user_avatars.byte_size AS avatar_byte_size, user_avatars.updated_at AS avatar_updated_at,user_status.*, deleted_accounts.deleted_at,deleted_accounts.deleted_by,deleted_accounts.reason AS deletion_reason FROM users LEFT JOIN user_status ON user_status.user_id=users.id LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN user_avatars ON user_avatars.user_id=users.id LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`) ).get(params.id), (await database.prepare('SELECT * FROM roles ORDER BY name')).all(), (await database.prepare('SELECT * FROM permissions ORDER BY name')).all(), ( await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?') ).all(params.id), ( await database.prepare( 'SELECT permission_id FROM user_permissions WHERE user_id=?', ) ).all(params.id), ( await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at, login_events.ip_address,login_events.user_agent,login_events.client_name,login_events.operating_system, login_events.device_type,login_events.location_country,login_events.location_region,login_events.location_city, login_events.failure_reason,login_events.risk_flags,login_events.credential_id,login_events.session_id, login_events.session_ended_at,login_events.session_end_reason,login_events.sensitive_purged_at, sessions.expires_at AS session_expires_at FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`) ).all(...loginArgs, loginPagination.perPage, loginPagination.offset), ( await database.prepare( `SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`, ) ).all(...buildArgs, buildPagination.perPage, buildPagination.offset), ( await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at, COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug FROM comments JOIN comment_threads ON comment_threads.id=comments.thread_id LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`) ).all(...commentArgs, commentPagination.perPage, commentPagination.offset), getAuthenticationMethods(database, params.id), ( await database.prepare( 'SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?', ) ).all(params.id), ( await database.prepare( 'SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC', ) ).all(params.id), ]); if (!profile) error(404, 'User not found'); return { profile: { ...profile, avatar_url: avatarUrl( profile.avatar_source, profile.avatar_value, String(profile.id), profile.avatar_updated_at, ), }, roles, permissions, assignedRoleIds: assignedRoles.map((row) => String(row.role_id)), directPermissionIds: directPermissions.map((row) => String(row.permission_id), ), logins, builds, comments, emails, passkeys, historyFilters: { loginQuery, loginStatus, buildQuery, buildVisibility, commentQuery, }, historyPagination: { login: loginPagination, build: buildPagination, comment: commentPagination, }, authenticationMethods, operatorPermissions: await getPermissions(operator), isSelf: operator.id === params.id, }; }; export const actions: Actions = { removeCustomAvatar: async ({ cookies, params, request }) => { const operator = await requirePermission( await getSessionUser(cookies), 'avatars.review', ); const reason = String((await request.formData()).get('reason') ?? '') .trim() .slice(0, 500); if (reason.length < 3) return fail(400, { avatarModerationError: 'Provide a reason for removing this avatar.', }); const database = await getDatabase(); const avatar = await ( await database.prepare( 'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?', ) ).get(params.id); if (avatar?.source !== 'upload') { return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.', }); } await deleteUploadedAvatar(database, params.id); await ( await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`) ).run( new Date().toISOString(), operator.id, reason, params.id, avatar.updated_at, ); await recordIsacAudit({ actorId: operator.id, category: 'user_moderation', action: 'avatar.removed', entityId: params.id, before: { source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size, }, after: { source: 'default', reviewSource: 'agent_operations', reason }, }); await createNotification({ userId: params.id, category: 'isac', source: 'ISAC USER OPERATIONS', title: 'Custom avatar removed', body: `Your custom avatar was removed by Oversight. Reason: ${reason}`, link: '/account#avatar', mandatory: true, }); return { avatarRemoved: true }; }, setAccess: async ({ cookies, request, params }) => { const operator = await requirePermission( await getSessionUser(cookies), 'users.roles', ); const form = await request.formData(); const roleIds = [...new Set(form.getAll('roleIds').map(String))]; const permissionIds = [ ...new Set(form.getAll('permissionIds').map(String)), ]; const database = await getDatabase(); const target = await ( await database.prepare('SELECT role FROM users WHERE id=?') ).get(params.id); if (!target) return fail(404, { accessError: 'User not found.' }); const operatorPermissions = await getPermissions(operator); const targetAdminRole = await ( await database.prepare( `SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`, ) ).get(params.id); const changesAdminRole = roleIds.includes('role-admin') !== Boolean(targetAdminRole); if ( (target.role === 'admin' || changesAdminRole) && !operatorPermissions.includes('*') ) { return fail(403, { accessError: 'Only an administrator can grant, remove, or modify administrator access.', }); } const validRoles = roleIds.length ? Number( ( await ( await database.prepare( `SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`, ) ).get(...roleIds) )?.count, ) : 0; const validPermissions = permissionIds.length ? Number( ( await ( await database.prepare( `SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`, ) ).get(...permissionIds) )?.count, ) : 0; if ( validRoles !== roleIds.length || validPermissions !== permissionIds.length ) return fail(400, { accessError: 'One or more access selections are invalid.', }); const before = { roleIds: ( await ( await database.prepare( 'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id', ) ).all(params.id) ).map((row) => row.role_id), permissionIds: ( await ( await database.prepare( 'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id', ) ).all(params.id) ).map((row) => row.permission_id), }; await ( await database.prepare('DELETE FROM user_roles WHERE user_id=?') ).run(params.id); await ( await database.prepare('DELETE FROM user_permissions WHERE user_id=?') ).run(params.id); for (const roleId of roleIds) await ( await database.prepare( 'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)', ) ).run(params.id, roleId); for (const permissionId of permissionIds) await ( await database.prepare( 'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)', ) ).run(params.id, permissionId); await ( await database.prepare( 'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)', ) ).run( randomUUID(), operator.id, 'user_access', params.id, 'access.updated', JSON.stringify(before), JSON.stringify({ roleIds, permissionIds }), new Date().toISOString(), ); return { accessSaved: true }; }, moderate: async ({ cookies, request, params }) => { const operator = await requirePermission( await getSessionUser(cookies), 'users.moderate', ); if (operator.id === params.id) return fail(400, { moderationError: 'You cannot restrict your own account.', }); const form = await request.formData(), state = String(form.get('state') ?? ''), reason = String(form.get('reason') ?? '').trim(); if (!['active', 'blocked', 'banned'].includes(state)) return fail(400, { moderationError: 'Choose a valid access state.' }); if (state !== 'active' && reason.length < 3) return fail(400, { moderationError: 'Provide a moderation reason.' }); const untilRaw = String(form.get('bannedUntil') ?? ''); const bannedUntil = state === 'banned' ? new Date(untilRaw) : null; if ( state === 'banned' && (!untilRaw || Number.isNaN(bannedUntil?.getTime()) || bannedUntil! <= new Date()) ) return fail(400, { moderationError: 'Choose a future ban expiration.' }); const now = new Date().toISOString(), database = await getDatabase(); const target = await ( await database.prepare('SELECT role FROM users WHERE id=?') ).get(params.id); if (!target) return fail(404, { moderationError: 'User not found.' }); const targetAdminRole = await ( await database.prepare( `SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`, ) ).get(params.id); if ( (target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*') ) { return fail(403, { moderationError: 'Only an administrator can moderate another administrator.', }); } const before = await ( await database.prepare( 'SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?', ) ).get(params.id); await ( await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`) ).run( params.id, state === 'blocked' ? now : null, state === 'active' ? null : operator.id, state === 'banned' ? bannedUntil!.toISOString() : null, state === 'active' ? null : reason, now, ); if (state !== 'active') await ( await database.prepare('DELETE FROM sessions WHERE user_id=?') ).run(params.id); await ( await database.prepare( 'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)', ) ).run( randomUUID(), operator.id, 'user_moderation', params.id, `moderation.${state}`, JSON.stringify(before ?? null), JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now, ); return { moderationSaved: true }; }, deleteAccount: async ({ cookies, request, params }) => { const operator = await requirePermission( await getSessionUser(cookies), 'users.delete', ); if (operator.id === params.id) return fail(400, { adminDeleteError: 'Delete your own account from the agent profile page.', }); const reason = String( (await request.formData()).get('reason') ?? '', ).trim(); if (reason.length < 3) return fail(400, { adminDeleteError: 'Provide a deletion reason.' }); const database = await getDatabase(); const target = await ( await database.prepare('SELECT role FROM users WHERE id=?') ).get(params.id); if (!target) return fail(404, { adminDeleteError: 'User not found.' }); const targetAdminRole = await ( await database.prepare( `SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`, ) ).get(params.id); if ( (target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*') ) { return fail(403, { adminDeleteError: 'Only an administrator can delete another administrator.', }); } if (!(await softDeleteAccount(database, params.id, operator.id, reason))) { return fail(409, { adminDeleteError: 'This account has already been deleted.', }); } return { accountDeleted: true }; }, unlinkDiscord: async ({ cookies, params }) => { const operator = await requirePermission( await getSessionUser(cookies), 'users.identities', ); const database = await getDatabase(); const target = await ( await database.prepare('SELECT discord_id FROM users WHERE id=?') ).get(params.id); if (!target) return fail(404, { identityError: 'User not found.' }); if (!target.discord_id) return fail(409, { identityError: 'This user does not have a linked Discord account.', }); if ( !canRemoveAuthenticationMethod( await getAuthenticationMethods(database, params.id), 'discord', ) ) { return fail(400, { identityError: 'Discord cannot be unlinked because it is this user’s only sign-in method.', }); } const now = new Date().toISOString(); await ( await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?') ).run(params.id); await ( await database.prepare('DELETE FROM discord_profiles WHERE user_id=?') ).run(params.id); await ( await database.prepare( 'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)', ) ).run( randomUUID(), operator.id, 'identity', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now, ); return { identityUnlinked: true }; }, removeEmail: async ({ cookies, params }) => { const operator = await requirePermission( await getSessionUser(cookies), 'users.identities', ); const database = await getDatabase(); const email = await ( await database.prepare('SELECT email FROM user_emails WHERE user_id=?') ).get(params.id); if (!email) return fail(409, { identityError: 'This user does not have a verified email sign-in.', }); if ( !canRemoveAuthenticationMethod( await getAuthenticationMethods(database, params.id), 'email', ) ) { return fail(400, { identityError: 'Email cannot be removed because it is this user’s only sign-in method.', }); } await ( await database.prepare('DELETE FROM user_emails WHERE user_id=?') ).run(params.id); await ( await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?') ).run(params.id); await ( await database.prepare( 'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)', ) ).run( randomUUID(), operator.id, 'identity', params.id, 'identity.email_removed', JSON.stringify({ email: email.email }), new Date().toISOString(), ); return { emailRemoved: true }; }, removePasskey: async ({ cookies, request, params }) => { const operator = await requirePermission( await getSessionUser(cookies), 'users.identities', ); const passkeyId = String((await request.formData()).get('passkeyId') ?? ''); const database = await getDatabase(); const passkey = await ( await database.prepare( 'SELECT id,name FROM passkeys WHERE id=? AND user_id=?', ) ).get(passkeyId, params.id); if (!passkey) return fail(404, { identityError: 'That passkey was not found for this user.', }); if ( !canRemoveAuthenticationMethod( await getAuthenticationMethods(database, params.id), 'passkey', ) ) { return fail(400, { identityError: 'This passkey cannot be removed because it is the user’s only sign-in method.', }); } await ( await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?') ).run(passkeyId, params.id); await ( await database.prepare( 'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)', ) ).run( randomUUID(), operator.id, 'identity', params.id, 'identity.passkey_removed', JSON.stringify({ id: passkey.id, name: passkey.name }), new Date().toISOString(), ); return { passkeyRemoved: true }; }, };