fix(rbac): harden user management with token versioning, session invalidation, and test coverage (#558)

Security fixes:
- Map deploy-only API tokens to deployer role (not admin)
- Add token versioning to invalidate sessions on password/role changes
- Reject deleted users immediately in auth middleware (no 24h JWT grace)
- Use DB role instead of JWT role so changes take effect instantly
- Block password setting on SSO-provisioned users
- Check proxy variant in scoped permission resolver

Cleanup and logging:
- Remove orphaned role assignments when stacks/nodes are deleted
- Add standard logging for login, user CRUD, role assignments, password changes
- Add diagnostic logging gated behind developer_mode setting
- Extract issueSessionCookie helper (DRY across 5 JWT signing sites)

Frontend:
- Replace Select with Combobox in UsersSection (design system compliance)
- Add strokeWidth={1.5} to Lucide icons
- Hide password fields for SSO-provisioned users

Testing:
- Add 42-test RBAC suite covering user CRUD, token versioning, scoped
  assignments, permissions endpoint, password management, seat limits,
  last-admin protection, and orphan cleanup
This commit is contained in:
Anso
2026-04-13 14:31:34 -04:00
committed by GitHub
parent ed67a2d928
commit c261fbc327
8 changed files with 823 additions and 96 deletions
+11
View File
@@ -80,6 +80,7 @@ export interface User {
auth_provider: AuthProvider;
provider_id: string | null;
email: string | null;
token_version: number;
created_at: number;
updated_at: number;
}
@@ -587,6 +588,7 @@ export class DatabaseService {
maybeAddCol('users', 'auth_provider', "TEXT NOT NULL DEFAULT 'local'");
maybeAddCol('users', 'provider_id', 'TEXT DEFAULT NULL');
maybeAddCol('users', 'email', 'TEXT DEFAULT NULL');
maybeAddCol('users', 'token_version', 'INTEGER NOT NULL DEFAULT 1');
this.db.exec(`
CREATE TABLE IF NOT EXISTS sso_config (
@@ -996,6 +998,7 @@ export class DatabaseService {
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
this.deleteRoleAssignmentsByResource('node', String(id));
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
})();
}
@@ -1185,6 +1188,10 @@ export class DatabaseService {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role != 'admin'").get() as { count: number })?.count || 0;
}
public bumpTokenVersion(userId: number): void {
this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ? WHERE id = ?').run(Date.now(), userId);
}
// --- Role Assignments ---
public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] {
@@ -1219,6 +1226,10 @@ export class DatabaseService {
this.db.prepare('DELETE FROM role_assignments WHERE user_id = ?').run(userId);
}
public deleteRoleAssignmentsByResource(resourceType: ResourceType, resourceId: string): void {
this.db.prepare('DELETE FROM role_assignments WHERE resource_type = ? AND resource_id = ?').run(resourceType, resourceId);
}
// --- SSO Config ---
public getSSOConfigs(): SSOConfig[] {