Files
shd.build/src/routes/isac/users/[id]/+page.server.ts
T
2026-07-25 20:55:13 -04:00

238 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.user_id=? AND (comments.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR builds.title 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 builds ON builds.id=comments.build_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,comments.created_at,builds.title,builds.slug
FROM comments JOIN builds ON builds.id=comments.build_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 };
}
};