fix(rbac): enforce admin seat cap on promotion and harden last-admin and audit paths (#1266)

Promoting a user to admin now respects the per-tier admin seat limit the same
way user creation does, closing a path that let an operator exceed the cap by
creating an account and then editing its role to admin.

The last-admin guard for demote and delete now runs the admin-count re-check
and the write in a single transaction, so two concurrent admin changes can no
longer race the admin count to zero and lock everyone out.

Admin two-factor resets are now recorded once with their own audit summary
instead of being mislabeled as a user creation by the audit middleware.
This commit is contained in:
Anso
2026-06-01 13:01:22 -04:00
committed by GitHub
parent 4248ac0e72
commit b61388c675
7 changed files with 187 additions and 31 deletions
+32
View File
@@ -2622,6 +2622,38 @@ export class DatabaseService {
this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
/**
* Atomically apply `updates` unless doing so would demote the last remaining
* admin. Returns false (nothing written) when the change would leave zero
* admins, true otherwise. The current-role read, the admin count, and the
* write run in a single transaction so a concurrent demote or delete of the
* other admin cannot race the count to zero.
*/
public updateUserIfNotLastAdmin(id: number, updates: Partial<{ username: string; password_hash: string; role: string; email: string }>): boolean {
return this.transaction(() => {
if (updates.role !== undefined && updates.role !== 'admin') {
const current = this.db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string } | undefined;
if (current?.role === 'admin' && this.getAdminCount() <= 1) return false;
}
this.updateUser(id, updates);
return true;
});
}
/**
* Atomically delete the user unless it is the last remaining admin. Returns
* false (nothing deleted) in that case, true otherwise. Same single-
* transaction guard as {@link updateUserIfNotLastAdmin}.
*/
public deleteUserIfNotLastAdmin(id: number): boolean {
return this.transaction(() => {
const current = this.db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string } | undefined;
if (current?.role === 'admin' && this.getAdminCount() <= 1) return false;
this.deleteUser(id);
return true;
});
}
public getUserCount(): number {
return (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
}