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
+15
View File
@@ -555,6 +555,21 @@ describe('POST /api/users/:id/mfa/reset', () => {
expect(db.getUserMfa(userId)).toBeUndefined();
expect(db.getUser(userId)!.token_version).toBeGreaterThan(before);
});
it('writes exactly one audit row, labeled as a reset and never as user creation', async () => {
const db = DatabaseService.getInstance();
const { userId } = await seedMfaUser('victim3', 'victim3pass123');
const res = await request(app)
.post(`/api/users/${userId}/mfa/reset`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
// The route no longer writes its own audit row; the middleware writes one.
const { entries } = db.getAuditLogs({ search: `/users/${userId}/mfa/reset` });
const resetRows = entries.filter((e) => e.path === `/api/users/${userId}/mfa/reset`);
expect(resetRows).toHaveLength(1);
expect(resetRows[0].summary).toBe(`Reset two-factor authentication: ${userId}`);
});
});
// ─── SSO bypass toggle ────────────────────────────────────────────────────────