bf7be65b03
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
440 lines
13 KiB
TypeScript
440 lines
13 KiB
TypeScript
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<UserAccess | null> => {
|
|
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<UserListEntry[]> => {
|
|
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<string, AppPermission[]>();
|
|
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<string, Date | null>(
|
|
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<UserDetail | null> => {
|
|
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<number>().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<string>();
|
|
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<void> => {
|
|
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<AdminDatabase>): Promise<number> => {
|
|
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<number>('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<LastAdminGuardResult> => {
|
|
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<LastAdminGuardResult> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
await db.updateTable('user').set({disabled: false}).where('id', '=', userId).execute();
|
|
};
|
|
|
|
export const revokeSession = async (userId: string, sessionId: string): Promise<boolean> => {
|
|
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<number> => {
|
|
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<number>().as('count'))
|
|
.executeTakeFirst();
|
|
|
|
return Number(row?.count ?? 0);
|
|
};
|
|
|
|
export const userExists = async (userId: string): Promise<boolean> => {
|
|
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;
|
|
};
|