import {Transaction} from 'kysely'; import {NachklangAdminDB} from '../Admin.db.js'; import { AdminDatabase, AppName, AppPermission, AppRole, ACCESS_ROLE, appsOf, isAppName, isAppRole } from '../admin.schema.js'; const db = NachklangAdminDB.db; /** * Everything that reads or writes permissions. Two callers with very different * hot-path requirements share this file: admin.middleware.ts runs * `loadAccess` on *every* admin-authenticated request (which is why it is one * query joining `user.disabled` and the permission rows - see the plan's * decision to run without better-auth's cookieCache), and the /admin/users * routes run the rest. */ export interface UserAccess { id: string; email: string; displayName: string; disabled: boolean; /** Every (app, role) grant this user holds. */ permissions: AppPermission[]; /** The distinct apps the above grants any access to. Derived, kept because * most callers only ever ask "may they open this app at all?". */ apps: AppName[]; } export type UserStatus = 'aktiv' | 'deaktiviert'; export interface UserListEntry { id: string; email: string; name: string; permissions: AppPermission[]; apps: AppName[]; status: UserStatus; createdAt: Date; lastSignInAt: Date | null; } export interface UserSessionEntry { id: string; createdAt: Date; expiresAt: Date; ipAddress: string | null; userAgent: string | null; } export interface UserDetail extends UserListEntry { sessions: UserSessionEntry[]; passkeyCount: number; } /** * The single per-request lookup behind requireAppAccess. Returns null when the * user row is gone; `disabled` is returned rather than filtered so the * middleware can answer 403 (account deactivated) instead of a misleading 401. */ export const loadAccess = async (userId: string): Promise => { const rows = await db .selectFrom('user') .leftJoin('user_app_permissions', 'user_app_permissions.user_id', 'user.id') .where('user.id', '=', userId) .select([ 'user.id as id', 'user.email as email', 'user.name as name', 'user.disabled as disabled', 'user_app_permissions.app as app', 'user_app_permissions.role as role' ]) .execute(); if (rows.length === 0) { return null; } const permissions = toPermissionRows(rows); return { id: rows[0].id, email: rows[0].email, displayName: rows[0].name, // MySQL TINYINT(1) comes back as 0/1 through mysql2. disabled: Boolean(rows[0].disabled), permissions, apps: appsOf(permissions) }; }; /** * Turns joined permission rows into AppPermission[]. The left join produces one * row with a null app for a user who holds nothing, and a role written directly * into the database that no longer appears in APP_ROLES is dropped rather than * trusted - the table is the store, APP_ROLES is the contract. */ const toPermissionRows = (rows: {app: AppName | null; role: string | null}[]): AppPermission[] => { return rows .filter((row): row is {app: AppName; role: string} => isAppName(row.app) && isAppRole(row.app, row.role)) .map(row => ({app: row.app, role: row.role})); }; export const listUsers = async (): Promise => { const users = await db .selectFrom('user') .select(['id', 'email', 'name', 'disabled', 'createdAt']) .orderBy('name', 'asc') .execute(); const permissions = await db .selectFrom('user_app_permissions') .select(['user_id', 'app', 'role']) .execute(); // Last sign-in is derived from the newest session rather than stored: a // session row is created on every sign-in and we never update its // createdAt, so max(createdAt) is exactly that, with no extra column to // keep in sync. // // The expiry filter must match getUserDetail's. better-auth only deletes an // expired session when someone actually presents it, so expired rows linger // - without this, the list would report a last sign-in for someone the // detail view shows as never having signed in. const lastSessions = await db .selectFrom('session') .where('expiresAt', '>', new Date()) .select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')]) .groupBy('userId') .execute(); const permissionsByUser = new Map(); for (const row of permissions) { if (!isAppRole(row.app, row.role)) { continue; } const held = permissionsByUser.get(row.user_id) || []; held.push({app: row.app, role: row.role}); permissionsByUser.set(row.user_id, held); } const lastSignInByUser = new Map( lastSessions.map(row => [row.userId, row.lastSignInAt as Date | null]) ); return users.map(user => { const held = permissionsByUser.get(user.id) || []; return { id: user.id, email: user.email, name: user.name, permissions: held, apps: appsOf(held), status: user.disabled ? ('deaktiviert' as const) : ('aktiv' as const), createdAt: user.createdAt, lastSignInAt: lastSignInByUser.get(user.id) ?? null }; }); }; export const getUserDetail = async (userId: string): Promise => { const user = await db .selectFrom('user') .select(['id', 'email', 'name', 'disabled', 'createdAt']) .where('id', '=', userId) .executeTakeFirst(); if (!user) { return null; } const [permissions, sessions, passkeys] = await Promise.all([ db .selectFrom('user_app_permissions') .select(['app', 'role']) .where('user_id', '=', userId) .execute(), db .selectFrom('session') .select(['id', 'createdAt', 'expiresAt', 'ipAddress', 'userAgent']) .where('userId', '=', userId) .where('expiresAt', '>', new Date()) .orderBy('createdAt', 'desc') .execute(), db .selectFrom('passkey') .select(({fn}) => fn.countAll().as('count')) .where('userId', '=', userId) .executeTakeFirst() ]); const held = toPermissionRows(permissions); return { id: user.id, email: user.email, name: user.name, permissions: held, apps: appsOf(held), status: user.disabled ? 'deaktiviert' : 'aktiv', createdAt: user.createdAt, lastSignInAt: sessions.length > 0 ? sessions[0].createdAt : null, sessions, passkeyCount: Number(passkeys?.count ?? 0) }; }; /** * Replaces a user's permission set. Written as delete-then-insert inside one * transaction rather than a diff: the set is at most four rows, and a diff * would only add branches for no measurable gain. */ /** The rows a permission list becomes. One row per (app, role). */ const permissionRows = ( userId: string, permissions: AppPermission[], grantedBy: string | null ) => { return permissions.map(permission => ({ user_id: userId, app: permission.app, role: permission.role, granted_by: grantedBy, granted_at: new Date() })); }; /** Drops anything not in APP_ROLES and de-duplicates on (app, role). */ const validPermissions = (permissions: AppPermission[]): AppPermission[] => { const seen = new Set(); return permissions.filter(permission => { if (!isAppName(permission.app) || !isAppRole(permission.app, permission.role)) { return false; } const key = `${permission.app}:${permission.role}`; if (seen.has(key)) { return false; } seen.add(key); return true; }); }; export const setPermissions = async ( userId: string, permissions: AppPermission[], grantedBy: string | null ): Promise => { const valid = validPermissions(permissions); await db.transaction().execute(async trx => { await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute(); if (valid.length > 0) { await trx .insertInto('user_app_permissions') .values(permissionRows(userId, valid, grantedBy)) .execute(); } }); }; /** * Why the guards live down here rather than in the router: they are * check-then-act, and the check has to happen inside the same transaction as * the write, over locked rows. Two admins each removing the other's `admin` * permission at the same moment would otherwise both read a count of 2, both * pass, and both commit - leaving nobody who can administer anything, with * ADMIN_BOOTSTRAP_EMAIL at the next restart as the only way back in. * * `SELECT ... FOR UPDATE` makes the second transaction wait and re-read the * count the first one just changed. */ export type LastAdminGuardResult = 'ok' | 'last-admin'; const countActiveAdminsForUpdate = async (trx: Transaction): Promise => { const row = await trx .selectFrom('user_app_permissions') .innerJoin('user', 'user.id', 'user_app_permissions.user_id') .where('user_app_permissions.app', '=', 'admin') .where('user.disabled', '=', false) // countDistinct, not countAll: with (user_id, app, role) as the key one // user can hold several roles on `admin`, and counting rows would make a // single admin with two roles look like two admins - defeating the guard // at exactly the moment it matters. .select(({fn}) => fn.count('user_app_permissions.user_id').distinct().as('count')) .forUpdate() .executeTakeFirst(); return Number(row?.count ?? 0); }; /** * Replaces a user's permissions, refusing to remove the last active admin. * Returns 'last-admin' instead of throwing so the router can answer 409. */ export const setPermissionsGuarded = async ( userId: string, permissions: AppPermission[], grantedBy: string | null ): Promise => { const valid = validPermissions(permissions); const keepsAdmin = valid.some(permission => permission.app === 'admin'); return db.transaction().execute(async trx => { const target = await trx .selectFrom('user_app_permissions') .innerJoin('user', 'user.id', 'user_app_permissions.user_id') .where('user_app_permissions.user_id', '=', userId) .where('user_app_permissions.app', '=', 'admin') .select(['user.disabled as disabled']) .limit(1) .forUpdate() .executeTakeFirst(); const losesAdmin = Boolean(target) && !keepsAdmin; if (losesAdmin && !target?.disabled && (await countActiveAdminsForUpdate(trx)) <= 1) { return 'last-admin'; } await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute(); if (valid.length > 0) { await trx .insertInto('user_app_permissions') .values(permissionRows(userId, valid, grantedBy)) .execute(); } return 'ok'; }); }; /** * Disables a user and revokes every session, refusing to disable the last * active admin. Same locking rationale as setPermissionsGuarded. */ export const disableUserGuarded = async (userId: string): Promise => { return db.transaction().execute(async trx => { const isAdmin = await trx .selectFrom('user_app_permissions') .innerJoin('user', 'user.id', 'user_app_permissions.user_id') .where('user_app_permissions.user_id', '=', userId) .where('user_app_permissions.app', '=', 'admin') .where('user.disabled', '=', false) .select('user_app_permissions.user_id') .limit(1) .forUpdate() .executeTakeFirst(); if (isAdmin && (await countActiveAdminsForUpdate(trx)) <= 1) { return 'last-admin'; } await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute(); await trx.deleteFrom('session').where('userId', '=', userId).execute(); return 'ok'; }); }; export const grantPermission = async ( userId: string, app: AppName, grantedBy: string | null, role: AppRole = ACCESS_ROLE ): Promise => { await db .insertInto('user_app_permissions') .values({user_id: userId, app, role, granted_by: grantedBy, granted_at: new Date()}) // The row already existing is the success case - this is "make sure they // hold it", not "re-grant it" - so nothing is overwritten and granted_by // keeps naming whoever granted it first. .onDuplicateKeyUpdate({role}) .execute(); }; /** Disabling revokes every session: a disabled user must lose access now, not * when their 30-day cookie happens to expire. */ export const disableUser = async (userId: string): Promise => { await db.transaction().execute(async trx => { await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute(); await trx.deleteFrom('session').where('userId', '=', userId).execute(); }); }; export const enableUser = async (userId: string): Promise => { await db.updateTable('user').set({disabled: false}).where('id', '=', userId).execute(); }; export const revokeSession = async (userId: string, sessionId: string): Promise => { const result = await db .deleteFrom('session') .where('id', '=', sessionId) .where('userId', '=', userId) .executeTakeFirst(); return Number(result.numDeletedRows) > 0; }; /** * Guard input for the self-lockout rules: how many enabled users still hold the * `admin` permission. Disabled admins do not count - they cannot sign in, so * leaving only disabled admins is the same lockout as leaving none. */ export const countActiveAdmins = async (): Promise => { const row = await db .selectFrom('user_app_permissions') .innerJoin('user', 'user.id', 'user_app_permissions.user_id') .where('user_app_permissions.app', '=', 'admin') .where('user.disabled', '=', false) .select(({fn}) => fn.countAll().as('count')) .executeTakeFirst(); return Number(row?.count ?? 0); }; export const userExists = async (userId: string): Promise => { const row = await db.selectFrom('user').select('id').where('id', '=', userId).executeTakeFirst(); return Boolean(row); }; export const findUserByEmail = async (email: string): Promise<{id: string; email: string} | null> => { const row = await db .selectFrom('user') .select(['id', 'email']) .where('email', '=', email) .executeTakeFirst(); return row ?? null; };