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
+13
View File
@@ -126,6 +126,19 @@ describe('getAuditSummary()', () => {
expect(getAuditSummary('DELETE', '/nodes/5')).toBe('Deleted node: 5');
expect(getAuditSummary('DELETE', 'nodes/5')).toBe('Deleted node: 5');
});
it('matches user management routes', () => {
expect(getAuditSummary('POST', '/users')).toBe('Created user');
expect(getAuditSummary('PUT', '/users/42')).toBe('Updated user: 42');
expect(getAuditSummary('DELETE', '/users/42')).toBe('Deleted user: 42');
expect(getAuditSummary('POST', '/users/42/roles')).toBe('Assigned role: 42');
expect(getAuditSummary('DELETE', '/users/42/roles/7')).toBe('Removed role assignment: 42');
});
it('labels an MFA reset distinctly and never as user creation', () => {
expect(getAuditSummary('POST', '/users/42/mfa/reset')).toBe('Reset two-factor authentication: 42');
expect(getAuditSummary('POST', '/users/42/mfa/reset')).not.toBe('Created user: 42');
});
});
// ---- DatabaseService audit methods ----
+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 ────────────────────────────────────────────────────────
+79
View File
@@ -563,6 +563,85 @@ describe('Last-admin protection', () => {
});
});
// ---- Seat Limit Enforcement On Promotion ----
describe('Seat limit enforcement on role promotion', () => {
it('rejects promoting a viewer to admin when the admin seat limit is reached', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const viewerId = db.addUser({ username: 'promoteme', password_hash: hash, role: 'viewer' });
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: 1, maxViewers: null });
const res = await request(app)
.put(`/api/users/${viewerId}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'admin' });
expect(res.status).toBe(403);
expect(res.body.error).toContain('maximum');
// The role must remain unchanged when the cap blocks the promotion.
expect(db.getUser(viewerId)!.role).toBe('viewer');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
db.deleteUser(viewerId);
});
it('allows promoting a viewer to admin when admin seats are unlimited', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const viewerId = db.addUser({ username: 'promoteok', password_hash: hash, role: 'viewer' });
// Global beforeAll mock already returns unlimited seats; the gate must not over-block.
const res = await request(app)
.put(`/api/users/${viewerId}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'admin' });
expect(res.status).toBe(200);
expect(db.getUser(viewerId)!.role).toBe('admin');
db.deleteUser(viewerId);
});
});
// ---- Atomic Last-Admin Guard (TOCTOU protection) ----
describe('Atomic last-admin guard', () => {
// These lock the guard contract: the admin-count re-check and the mutation run
// in one transaction, so a refusal writes nothing (no partial state) and the
// count is unchanged. That re-check inside the transaction is what closes the
// TOCTOU window a route-level pre-check left open.
it('updateUserIfNotLastAdmin refuses to demote the sole admin and applies otherwise', async () => {
const db = DatabaseService.getInstance();
expect(db.getAdminCount()).toBe(1);
const sole = db.getUserByUsername(TEST_USERNAME)!;
expect(db.updateUserIfNotLastAdmin(sole.id, { role: 'viewer' })).toBe(false);
// Refusal is side-effect free: role intact and count unchanged.
expect(db.getUser(sole.id)!.role).toBe('admin');
expect(db.getAdminCount()).toBe(1);
const hash = await bcrypt.hash('password123', 1);
const extra = db.addUser({ username: 'raceadmin', password_hash: hash, role: 'admin' });
expect(db.updateUserIfNotLastAdmin(extra, { role: 'viewer' })).toBe(true);
expect(db.getUser(extra)!.role).toBe('viewer');
expect(db.getAdminCount()).toBe(1);
db.deleteUser(extra);
});
it('deleteUserIfNotLastAdmin refuses to delete the sole admin and applies otherwise', async () => {
const db = DatabaseService.getInstance();
expect(db.getAdminCount()).toBe(1);
const sole = db.getUserByUsername(TEST_USERNAME)!;
expect(db.deleteUserIfNotLastAdmin(sole.id)).toBe(false);
// Refusal is side-effect free: row intact and count unchanged.
expect(db.getUser(sole.id)).toBeTruthy();
expect(db.getAdminCount()).toBe(1);
const hash = await bcrypt.hash('password123', 1);
const extra = db.addUser({ username: 'raceadmin2', password_hash: hash, role: 'admin' });
expect(db.deleteUserIfNotLastAdmin(extra)).toBe(true);
expect(db.getAdminCount()).toBe(1);
});
});
// ---- Orphaned Role Assignment Cleanup ----
describe('Orphaned role assignment cleanup', () => {