Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #12.
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user