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
+634
View File
@@ -0,0 +1,634 @@
/**
* Tests for User Management, RBAC permissions, token versioning (session invalidation),
* scoped role assignments, password management, seat limits, and last-admin protection.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
/** Sign a JWT for a given user with optional token_version (tv). */
function authToken(username: string, role: string = 'admin', tv?: number): string {
const payload: Record<string, unknown> = { username, role };
if (tv !== undefined) payload.tv = tv;
return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' });
}
/** Sign admin token using the current DB token_version (reads live state). */
function adminToken(): string {
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(TEST_USERNAME)!;
return authToken(TEST_USERNAME, 'admin', user.token_version);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
// Mock LicenseService to return paid/admiral for RBAC tests
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
// ---- User CRUD Endpoints ----
describe('POST /api/users', () => {
it('creates a user with valid data (201)', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'newuser', password: 'password123', role: 'viewer' });
expect(res.status).toBe(201);
expect(res.body.username).toBe('newuser');
expect(res.body.role).toBe('viewer');
expect(res.body.id).toBeDefined();
});
it('rejects missing fields (400)', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'incomplete' });
expect(res.status).toBe(400);
});
it('rejects invalid username format (400)', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'a b', password: 'password123', role: 'viewer' });
expect(res.status).toBe(400);
});
it('rejects short password (400)', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'shortpw', password: '123', role: 'viewer' });
expect(res.status).toBe(400);
});
it('rejects invalid role (400)', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'badrole', password: 'password123', role: 'superadmin' });
expect(res.status).toBe(400);
});
it('rejects duplicate username (409)', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'newuser', password: 'password123', role: 'viewer' });
expect(res.status).toBe(409);
});
it('requires admin role (403 for viewers)', async () => {
const db = DatabaseService.getInstance();
const viewer = db.getUserByUsername('newuser')!;
const viewerToken = authToken('newuser', 'viewer', viewer.token_version);
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ username: 'test999', password: 'password123', role: 'viewer' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('blocks API tokens (403 SCOPE_DENIED)', async () => {
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(TEST_USERNAME);
db.addApiToken({ token_hash: tokenHash, name: `test-crud-${Date.now()}`, scope: 'full-admin', user_id: user!.id, created_at: Date.now(), expires_at: null });
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${rawToken}`)
.send({ username: 'fromtoken', password: 'password123', role: 'viewer' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
});
describe('PUT /api/users/:id', () => {
let viewerId: number;
beforeAll(() => {
const db = DatabaseService.getInstance();
const user = db.getUserByUsername('newuser');
viewerId = user!.id;
});
it('updates username', async () => {
const res = await request(app)
.put(`/api/users/${viewerId}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'renameduser' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Rename back for other tests
await request(app)
.put(`/api/users/${viewerId}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'newuser' });
});
it('updates role', async () => {
const res = await request(app)
.put(`/api/users/${viewerId}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer' });
expect(res.status).toBe(200);
// Revert
await request(app)
.put(`/api/users/${viewerId}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'viewer' });
});
it('prevents self-role-change (400)', async () => {
const db = DatabaseService.getInstance();
const adminUser = db.getUserByUsername(TEST_USERNAME)!;
const res = await request(app)
.put(`/api/users/${adminUser.id}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'viewer' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Cannot change your own role');
});
it('prevents demoting last admin (400)', async () => {
// testadmin is the only admin
const db = DatabaseService.getInstance();
const adminUser = db.getUserByUsername(TEST_USERNAME)!;
const res = await request(app)
.put(`/api/users/${adminUser.id}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'viewer' });
expect(res.status).toBe(400);
});
it('rejects password on SSO user (400)', async () => {
// Create an SSO user directly in DB
const db = DatabaseService.getInstance();
const ssoId = db.addUser({ username: 'sso-user', password_hash: '$sso$fake', role: 'viewer', auth_provider: 'oidc_google', provider_id: 'google-123', email: 'sso@test.com' });
const res = await request(app)
.put(`/api/users/${ssoId}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ password: 'newpassword123' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('SSO-provisioned');
// Cleanup
db.deleteUser(ssoId);
});
});
describe('DELETE /api/users/:id', () => {
it('deletes a user (200)', async () => {
// Create a disposable user
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'disposable', password_hash: hash, role: 'viewer' });
const res = await request(app)
.delete(`/api/users/${id}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('prevents self-deletion (400)', async () => {
const db = DatabaseService.getInstance();
const admin = db.getUserByUsername(TEST_USERNAME)!;
const res = await request(app)
.delete(`/api/users/${admin.id}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(400);
expect(res.body.error).toContain('Cannot delete your own account');
});
it('prevents deleting last admin (400)', async () => {
// Only one admin (testadmin), can't delete
const db = DatabaseService.getInstance();
const admin = db.getUserByUsername(TEST_USERNAME)!;
const res = await request(app)
.delete(`/api/users/${admin.id}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(400);
});
});
// ---- Token Version (Session Invalidation) ----
describe('Token version (session invalidation)', () => {
it('rejects a deleted user\'s JWT (401)', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'willdelete', password_hash: hash, role: 'viewer' });
const user = db.getUserById(id)!;
const token = authToken('willdelete', 'viewer', user.token_version);
// Token works before deletion
const before = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`);
expect(before.status).not.toBe(401);
// Delete the user
db.deleteUser(id);
// Token should be rejected after deletion
const after = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`);
expect(after.status).toBe(401);
expect(after.body.error).toContain('no longer exists');
});
it('rejects token after password change bumps tv', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('oldpass123', 1);
const id = db.addUser({ username: 'pwchange', password_hash: hash, role: 'viewer' });
const user = db.getUserById(id)!;
const oldToken = authToken('pwchange', 'viewer', user.token_version);
// Token works before bump
const before = await request(app).get('/api/stacks').set('Authorization', `Bearer ${oldToken}`);
expect(before.status).not.toBe(401);
// Bump token version (simulates password change)
db.bumpTokenVersion(id);
// Old token should be rejected
const after = await request(app).get('/api/stacks').set('Authorization', `Bearer ${oldToken}`);
expect(after.status).toBe(401);
expect(after.body.error).toContain('Session invalidated');
// Cleanup
db.deleteUser(id);
});
it('admin password reset bumps token_version', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'resetme', password_hash: hash, role: 'viewer' });
const userBefore = db.getUserById(id)!;
// Admin resets password via PUT /api/users/:id
await request(app)
.put(`/api/users/${id}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ password: 'newpassword123' });
const userAfter = db.getUserById(id)!;
expect(userAfter.token_version).toBe(userBefore.token_version + 1);
// Cleanup
db.deleteUser(id);
});
it('pre-migration token (no tv claim) still works', async () => {
// Sign without tv claim (simulates pre-migration token)
const token = jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app).get('/api/stacks').set('Authorization', `Bearer ${token}`);
// Should not be 401 (backward compat)
expect(res.status).not.toBe(401);
});
it('uses DB role so role changes take effect immediately', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'rolecheck', password_hash: hash, role: 'admin' });
const user = db.getUserById(id)!;
// Admin changes their role to viewer in DB directly (simulating a race)
db.updateUser(id, { role: 'viewer' });
// Don't bump tv, so the old token still passes version check
// But the middleware should use DB role (viewer), not JWT role (admin)
// The token was signed with role: admin, but DB says viewer.
// Auth check endpoint should reflect the DB role.
const token = authToken('rolecheck', 'admin', user.token_version);
const res = await request(app).get('/api/auth/check').set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.user.role).toBe('viewer');
// Cleanup
db.deleteUser(id);
});
});
// ---- Scoped Role Assignments ----
describe('Scoped Role Assignments', () => {
let targetUserId: number;
beforeAll(async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
targetUserId = db.addUser({ username: 'scopeuser', password_hash: hash, role: 'viewer' });
});
afterAll(() => {
const db = DatabaseService.getInstance();
db.deleteUser(targetUserId);
});
it('GET /api/users/:id/roles returns assignments', async () => {
const res = await request(app)
.get(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('POST /api/users/:id/roles creates assignment (201)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' });
expect(res.status).toBe(201);
expect(res.body.role).toBe('deployer');
expect(res.body.resource_type).toBe('stack');
});
it('POST /api/users/:id/roles rejects duplicate (409)', async () => {
const res = await request(app)
.post(`/api/users/${targetUserId}/roles`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' });
expect(res.status).toBe(409);
});
it('DELETE /api/users/:id/roles/:assignId removes assignment', async () => {
const db = DatabaseService.getInstance();
const assignments = db.getAllRoleAssignments(targetUserId);
const assignment = assignments[0];
const res = await request(app)
.delete(`/api/users/${targetUserId}/roles/${assignment.id}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
// ---- GET /api/permissions/me ----
describe('GET /api/permissions/me', () => {
it('returns correct structure for admin', async () => {
const res = await request(app)
.get('/api/permissions/me')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(res.body.globalRole).toBe('admin');
expect(Array.isArray(res.body.globalPermissions)).toBe(true);
expect(res.body.globalPermissions).toContain('stack:read');
expect(res.body.globalPermissions).toContain('system:users');
expect(typeof res.body.isAdmiral).toBe('boolean');
});
it('returns 401 when not authenticated', async () => {
const res = await request(app).get('/api/permissions/me');
expect(res.status).toBe(401);
});
it('includes scoped permissions when assignments exist', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const id = db.addUser({ username: 'permcheck', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' });
const user = db.getUserById(id)!;
const token = authToken('permcheck', 'viewer', user.token_version);
const res = await request(app)
.get('/api/permissions/me')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.globalRole).toBe('viewer');
expect(res.body.scopedPermissions['stack:my-stack']).toBeDefined();
// Cleanup
db.deleteRoleAssignmentsByUser(id);
db.deleteUser(id);
});
});
// ---- PUT /api/auth/password ----
describe('PUT /api/auth/password', () => {
it('changes password with valid old password', async () => {
const res = await request(app)
.put('/api/auth/password')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ oldPassword: TEST_PASSWORD, newPassword: 'newpassword123' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// Revert password for other tests (must use fresh token since tv was bumped)
const revert = await request(app)
.put('/api/auth/password')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ oldPassword: 'newpassword123', newPassword: TEST_PASSWORD });
expect(revert.status).toBe(200);
});
it('rejects wrong old password (401)', async () => {
const res = await request(app)
.put('/api/auth/password')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ oldPassword: 'wrongpassword', newPassword: 'newpassword123' });
expect(res.status).toBe(401);
});
it('rejects short new password (400)', async () => {
const res = await request(app)
.put('/api/auth/password')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ oldPassword: TEST_PASSWORD, newPassword: '123' });
expect(res.status).toBe(400);
});
it('rejects missing fields (400)', async () => {
const res = await request(app)
.put('/api/auth/password')
.set('Authorization', `Bearer ${adminToken()}`)
.send({});
expect(res.status).toBe(400);
});
it('blocks API tokens (403)', async () => {
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(TEST_USERNAME);
db.addApiToken({ token_hash: tokenHash, name: `test-pwchange-${Date.now()}`, scope: 'full-admin', user_id: user!.id, created_at: Date.now(), expires_at: null });
const res = await request(app)
.put('/api/auth/password')
.set('Authorization', `Bearer ${rawToken}`)
.send({ oldPassword: TEST_PASSWORD, newPassword: 'newpassword123' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
});
// ---- Seat Limit Enforcement ----
describe('Seat limit enforcement', () => {
it('rejects new admin when seat limit reached', async () => {
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: 1, maxViewers: null });
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'extraadmin', password: 'password123', role: 'admin' });
expect(res.status).toBe(403);
expect(res.body.error).toContain('maximum');
// Restore mock
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
});
it('rejects new viewer when viewer seat limit reached', async () => {
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: 0 });
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ username: 'extraviewer', password: 'password123', role: 'viewer' });
expect(res.status).toBe(403);
expect(res.body.error).toContain('maximum');
// Restore mock
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
});
});
// ---- Last-Admin Protection ----
describe('Last-admin protection', () => {
it('cannot demote the only admin', async () => {
const db = DatabaseService.getInstance();
const admin = db.getUserByUsername(TEST_USERNAME)!;
const res = await request(app)
.put(`/api/users/${admin.id}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'viewer' });
// Should fail with 400 (self-role-change) or last-admin check
expect(res.status).toBe(400);
});
it('cannot delete the only admin', async () => {
const db = DatabaseService.getInstance();
const admin = db.getUserByUsername(TEST_USERNAME)!;
const res = await request(app)
.delete(`/api/users/${admin.id}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(400);
});
it('can demote admin when another admin exists', async () => {
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
const secondAdminId = db.addUser({ username: 'secondadmin', password_hash: hash, role: 'admin' });
// Now demote second admin (testadmin does the demotion)
const res = await request(app)
.put(`/api/users/${secondAdminId}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ role: 'viewer' });
expect(res.status).toBe(200);
// Cleanup
db.deleteUser(secondAdminId);
});
});
// ---- Orphaned Role Assignment Cleanup ----
describe('Orphaned role assignment cleanup', () => {
it('deleting a node removes its role assignments', async () => {
const db = DatabaseService.getInstance();
// Create a test node
const nodeId = db.addNode({ name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:3000', api_token: '', compose_dir: '/tmp', is_default: false });
// Create a role assignment for this node
const hash = await bcrypt.hash('password123', 1);
const userId = db.addUser({ username: 'nodeorphan', password_hash: hash, role: 'viewer' });
db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) });
// Verify assignment exists
const before = db.getAllRoleAssignments(userId);
expect(before.length).toBe(1);
// Delete the node
db.deleteNode(nodeId);
// Assignments should be gone
const after = db.getAllRoleAssignments(userId);
expect(after.length).toBe(0);
// Cleanup
db.deleteUser(userId);
});
});
// ---- Role-Based Permission Checks (via API) ----
describe('ROLE_PERMISSIONS enforcement via API', () => {
it('viewer is blocked from deploying (403)', async () => {
const db = DatabaseService.getInstance();
const viewerUser = db.getUserByUsername('newuser');
if (!viewerUser) return; // Created in earlier test
const token = authToken('newuser', 'viewer', viewerUser.token_version);
const res = await request(app)
.post('/api/stacks/test-stack/deploy')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('viewer can read stacks', async () => {
const db = DatabaseService.getInstance();
const viewerUser = db.getUserByUsername('newuser');
if (!viewerUser) return;
const token = authToken('newuser', 'viewer', viewerUser.token_version);
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${token}`);
// Should not be 403 (may be 200 or 500 depending on Docker state)
expect(res.status).not.toBe(403);
});
it('viewer is blocked from system settings (403)', async () => {
const db = DatabaseService.getInstance();
const viewerUser = db.getUserByUsername('newuser');
if (!viewerUser) return;
const token = authToken('newuser', 'viewer', viewerUser.token_version);
const res = await request(app)
.get('/api/users')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
});
+90 -19
View File
@@ -432,7 +432,9 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string };
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number };
if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session');
// API token path: scope-based programmatic access
if (decoded.scope === 'api_token') {
@@ -450,7 +452,7 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
const creator = DatabaseService.getInstance().getUserById(apiToken.user_id);
const roleMap: Record<string, UserRole> = {
'read-only': 'viewer',
'deploy-only': 'admin',
'deploy-only': 'deployer',
'full-admin': 'admin',
};
req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer', userId: apiToken.user_id };
@@ -459,14 +461,14 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
return;
}
// Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens.
const dbUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as UserRole) || 'admin', userId: dbUser?.id ?? 0 };
// Distributed License Enforcement: trust tier headers only from authenticated node proxy requests.
// Browser sessions and API tokens cannot set these — only a valid node_proxy JWT (signed with
// this instance's JWT secret) unlocks the trusted path.
// Node proxy tokens: Sencho-to-Sencho communication, not user sessions.
// Handle before user resolution since proxy tokens have no username.
if (decoded.scope === 'node_proxy') {
req.user = { username: 'node-proxy', role: 'admin', userId: 0 };
// Distributed License Enforcement: trust tier headers only from authenticated node proxy requests.
// Browser sessions and API tokens cannot set these; only a valid node_proxy JWT (signed with
// this instance's JWT secret) unlocks the trusted path.
const tierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
const variantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
if (isLicenseTier(tierHeader)) {
@@ -477,8 +479,33 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
} else if (variantHeader === '') {
req.proxyVariant = null;
}
next();
return;
}
// User session tokens: resolve against the database for up-to-date role and existence checks.
const dbUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
// User must exist in the database (rejects deleted users immediately)
if (!dbUser) {
res.status(401).json({ error: 'User account no longer exists' });
return;
}
// Token version check: rejects sessions after password change, role change, or admin reset.
// Pre-migration tokens (no tv claim) are accepted for backward compat and expire within 24h.
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
if (isDebugEnabled()) console.log('[Auth:diag] Token version mismatch for:', decoded.username, 'jwt:', decoded.tv, 'db:', dbUser.token_version);
console.log('[Auth] Session rejected: token version mismatch for:', decoded.username);
res.status(401).json({ error: 'Session invalidated. Please log in again.' });
return;
}
if (isDebugEnabled()) console.log('[Auth:diag] User resolved:', dbUser.username, 'role:', dbUser.role, 'tv:', dbUser.token_version);
// Use the DB role (not the JWT role) so role changes take effect immediately
req.user = { username: dbUser.username, role: dbUser.role as UserRole, userId: dbUser.id };
next();
} catch (err) {
console.error('[Auth] Token validation failed:', (err as Error).message);
@@ -487,6 +514,21 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
}
};
/** Sign a session JWT and set it as an httpOnly cookie. */
function issueSessionCookie(
res: Response,
req: Request,
user: { username: string; role: string; token_version: number },
jwtSecret: string,
): void {
const token = jwt.sign(
{ username: user.username, role: user.role, tv: user.token_version },
jwtSecret,
{ expiresIn: '24h' },
);
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
}
// Rate limiter for auth endpoints - prevents brute-force attacks.
// Production: 5 attempts per 15-minute window per IP.
// Development: 100 attempts (so E2E tests and local tooling are not blocked).
@@ -578,8 +620,7 @@ app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response)
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
// Issue JWT and log user in
const token = jwt.sign({ username, role: 'admin' }, jwtSecret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
issueSessionCookie(res, req, { username, role: 'admin', token_version: 1 }, jwtSecret);
res.json({ success: true, message: 'Setup completed successfully' });
} catch (error) {
console.error('Setup error:', error);
@@ -606,13 +647,14 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response)
const settings = db.getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret missing from DB');
const token = jwt.sign({ username: user.username, role: user.role }, jwtSecret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
issueSessionCookie(res, req, user, jwtSecret);
console.log('[Auth] Login successful:', user.username);
res.json({ success: true, message: 'Login successful' });
return;
}
}
console.warn('[Auth] Login failed for username:', username);
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
console.error('Login error:', error);
@@ -655,9 +697,18 @@ app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response
dbSvc.updateUser(user.id, { password_hash: newHash });
// Keep global_settings in sync for backward compat
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
// Invalidate all other sessions for this user
dbSvc.bumpTokenVersion(user.id);
// Re-issue cookie with new token version so the current session survives
const settings = dbSvc.getGlobalSettings();
const updatedUser = dbSvc.getUserById(user.id);
if (settings.auth_jwt_secret && updatedUser) {
issueSessionCookie(res, req, updatedUser, settings.auth_jwt_secret);
}
console.log('[Auth] Password changed by:', req.user!.username);
res.json({ success: true, message: 'Password updated successfully' });
} catch (error) {
console.error('Password update error:', error);
console.error('[Auth] Password update error:', error);
res.status(500).json({ error: 'Failed to update password' });
}
});
@@ -748,8 +799,7 @@ app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Respon
// Issue JWT (same as local login)
const settings = DatabaseService.getInstance().getGlobalSettings();
const token = jwt.sign({ username: user.username, role: user.role }, settings.auth_jwt_secret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
res.json({ success: true, message: 'Login successful' });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'LDAP login failed';
@@ -861,8 +911,7 @@ app.get('/api/auth/sso/oidc/:provider/callback', async (req: Request, res: Respo
// Issue JWT + cookie (same as local login)
const settings = DatabaseService.getInstance().getGlobalSettings();
const token = jwt.sign({ username: user.username, role: user.role }, settings.auth_jwt_secret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
res.redirect('/');
} catch (error: unknown) {
@@ -1106,6 +1155,8 @@ function checkPermission(
const globalRole = req.user.role;
if (isDebugEnabled()) console.log('[RBAC:diag] checkPermission:', action, 'user:', req.user.username, 'globalRole:', globalRole, 'resource:', resourceType, resourceId);
// Admins always have full access
if (globalRole === 'admin') return true;
@@ -1114,9 +1165,11 @@ function checkPermission(
// Scoped assignments only apply when a resource is specified and license is Admiral
if (!resourceType || !resourceId) return false;
if (LicenseService.getInstance().getVariant() !== 'admiral') return false;
const variant = req.proxyVariant !== undefined ? req.proxyVariant : LicenseService.getInstance().getVariant();
if (variant !== 'admiral') return false;
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId);
for (const assignment of assignments) {
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
}
@@ -2518,6 +2571,7 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
const passwordHash = await bcrypt.hash(password, 10);
const id = db.addUser({ username, password_hash: passwordHash, role });
console.log('[Users] Created:', username, 'role:', role, 'by:', req.user!.username);
res.status(201).json({ id, username, role });
} catch (error) {
console.error('[Users] Create error:', error);
@@ -2525,6 +2579,8 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
}
});
// Note: requirePaid is intentionally not enforced on PUT/DELETE user endpoints.
// Admins must be able to manage existing users even if their license lapses (security consideration).
app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
@@ -2577,6 +2633,11 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P
}
if (password !== undefined) {
// Prevent setting passwords on SSO-provisioned users (would enable local login bypass)
if (user.auth_provider !== 'local') {
res.status(400).json({ error: 'Cannot set a password on an SSO-provisioned user.' });
return;
}
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
@@ -2585,6 +2646,11 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P
}
db.updateUser(id, updates);
// Invalidate the user's active sessions when their role or password changes
if (updates.role || updates.password_hash) {
db.bumpTokenVersion(id);
}
console.log('[Users] Updated user', id, 'fields:', Object.keys(updates).join(', '), 'by:', req.user!.username);
res.json({ success: true });
} catch (error) {
console.error('[Users] Update error:', error);
@@ -2620,6 +2686,7 @@ app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response)
}
db.deleteUser(id);
console.log('[Users] Deleted:', user.username, '(id:', id, ') by:', req.user!.username);
res.json({ success: true });
} catch (error) {
console.error('[Users] Delete error:', error);
@@ -2685,6 +2752,7 @@ app.post('/api/users/:id/roles', authMiddleware, (req: Request, res: Response):
try {
const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id });
console.log('[Roles] Assigned', role, 'on', resource_type, resource_id, 'to user', userId, 'by:', req.user!.username);
res.status(201).json({ id, user_id: userId, role, resource_type, resource_id });
} catch (err: unknown) {
if ((err as Error).message?.includes('UNIQUE constraint')) {
@@ -2718,6 +2786,7 @@ app.delete('/api/users/:id/roles/:assignId', authMiddleware, (req: Request, res:
}
db.deleteRoleAssignment(assignId);
console.log('[Roles] Removed assignment', assignId, 'from user', userId, 'by:', req.user!.username);
res.json({ success: true });
} catch (error) {
console.error('[Roles] Delete error:', error);
@@ -3726,6 +3795,8 @@ app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
// Prevent stale update badges for deleted stacks
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
// Clean up any scoped role assignments referencing this stack
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stack deleted: ${stackName}`);
+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[] {
+9
View File
@@ -107,6 +107,15 @@ SSO users cannot log in with a password; they must always authenticate through t
To set up identity provider authentication, see [SSO Authentication](/features/sso).
## Session security
Sencho enforces user changes immediately:
- **Account deletion**: A deleted user's active sessions are rejected on the next request. There is no delay or grace period.
- **Role changes**: When an admin changes a user's role, the new permissions take effect immediately for all of that user's active sessions.
- **Password changes**: Changing a password (either your own or as an admin reset) invalidates all other active sessions for that user. The session that performed the change remains valid.
- **SSO users**: Password fields are hidden for SSO-provisioned users. SSO accounts always authenticate through their identity provider.
## Migration from single-admin setup
When you upgrade to a Skipper or Admiral license, your existing single-admin credentials are automatically migrated. No manual action is required; your login continues to work as before, and your account is assigned the Admin role.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox } from '@/components/ui/combobox';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
@@ -21,6 +21,7 @@ interface UserItem {
id: number;
username: string;
role: UserRole;
auth_provider: string;
created_at: number;
}
@@ -240,7 +241,7 @@ export function UsersSection() {
</div>
{!showForm && (
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1" />Add User
<Plus className="w-4 h-4 mr-1" strokeWidth={1.5} />Add User
</Button>
)}
</div>
@@ -260,48 +261,53 @@ export function UsersSection() {
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={formRole} onValueChange={(v) => setFormRole(v as UserRole)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
{isPaid && license?.variant === 'admiral' && (
<>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{editingUser ? 'New Password (optional)' : 'Password'}</Label>
<Input
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder={editingUser ? 'Leave blank to keep' : 'min. 8 characters'}
/>
</div>
<div className="space-y-2">
<Label>Confirm Password</Label>
<Input
type="password"
value={formConfirmPassword}
onChange={(e) => setFormConfirmPassword(e.target.value)}
placeholder="Confirm password"
<Combobox
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'viewer', label: 'Viewer' },
...(isPaid && license?.variant === 'admiral' ? [
{ value: 'deployer', label: 'Deployer' },
{ value: 'node-admin', label: 'Node Admin' },
{ value: 'auditor', label: 'Auditor' },
] : []),
]}
value={formRole}
onValueChange={(v) => setFormRole(v as UserRole)}
placeholder="Select role..."
/>
</div>
</div>
{/* Hide password fields for SSO-provisioned users */}
{(!editingUser || editingUser.auth_provider === 'local') ? (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{editingUser ? 'New Password (optional)' : 'Password'}</Label>
<Input
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder={editingUser ? 'Leave blank to keep' : 'min. 8 characters'}
/>
</div>
<div className="space-y-2">
<Label>Confirm Password</Label>
<Input
type="password"
value={formConfirmPassword}
onChange={(e) => setFormConfirmPassword(e.target.value)}
placeholder="Confirm password"
/>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
Password is managed by the identity provider ({editingUser.auth_provider}).
</p>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 mr-1 animate-spin" />Saving...</> : (editingUser ? 'Update User' : 'Create User')}
{saving ? <><RefreshCw className="w-4 h-4 mr-1 animate-spin" strokeWidth={1.5} />Saving...</> : (editingUser ? 'Update User' : 'Create User')}
</Button>
</div>
@@ -322,7 +328,7 @@ export function UsersSection() {
on <span className="font-medium capitalize">{a.resource_type}</span>: <span className="font-mono text-xs">{a.resource_id}</span>
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => removeRoleAssignment(a.id)}>
<Trash2 className="w-3 h-3 text-destructive" />
<Trash2 className="w-3 h-3 text-destructive" strokeWidth={1.5} />
</Button>
</div>
))}
@@ -332,50 +338,46 @@ export function UsersSection() {
<div className="flex items-end gap-2">
<div className="space-y-1">
<Label className="text-xs">Role</Label>
<Select value={scopeRole} onValueChange={(v) => setScopeRole(v as UserRole)}>
<SelectTrigger className="h-8 text-xs w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Combobox
options={[
{ value: 'deployer', label: 'Deployer' },
{ value: 'node-admin', label: 'Node Admin' },
{ value: 'admin', label: 'Admin' },
]}
value={scopeRole}
onValueChange={(v) => setScopeRole(v as UserRole)}
placeholder="Role..."
className="h-8 text-xs w-[120px]"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Resource Type</Label>
<Select value={scopeResourceType} onValueChange={(v) => { setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }}>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stack">Stack</SelectItem>
<SelectItem value="node">Node</SelectItem>
</SelectContent>
</Select>
<Combobox
options={[
{ value: 'stack', label: 'Stack' },
{ value: 'node', label: 'Node' },
]}
value={scopeResourceType}
onValueChange={(v) => { setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }}
placeholder="Type..."
className="h-8 text-xs w-[100px]"
/>
</div>
<div className="space-y-1 flex-1">
<Label className="text-xs">Resource</Label>
<Select value={scopeResourceId} onValueChange={setScopeResourceId}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{scopeResourceType === 'stack' ? (
availableStacks.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))
) : (
availableNodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))
)}
</SelectContent>
</Select>
<Combobox
options={scopeResourceType === 'stack'
? availableStacks.map((s) => ({ value: s, label: s }))
: availableNodes.map((n) => ({ value: String(n.id), label: n.name }))
}
value={scopeResourceId}
onValueChange={setScopeResourceId}
placeholder="Select..."
className="h-8 text-xs"
/>
</div>
<Button size="sm" className="h-8" onClick={addRoleAssignment} disabled={addingScope || !scopeResourceId}>
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 mr-1" strokeWidth={1.5} />
Add
</Button>
</div>
@@ -423,12 +425,12 @@ export function UsersSection() {
<td className="px-4 py-2.5 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" />
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" disabled={isSelf}>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
<Trash2 className="w-3.5 h-3.5 text-destructive" strokeWidth={1.5} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>