238 lines
19 KiB
TypeScript
238 lines
19 KiB
TypeScript
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 };
|
||
}
|
||
};
|