diff --git a/backend/src/__tests__/users-rbac.test.ts b/backend/src/__tests__/users-rbac.test.ts new file mode 100644 index 00000000..f268f7d0 --- /dev/null +++ b/backend/src/__tests__/users-rbac.test.ts @@ -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 = { 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'); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index cf330112..f9012272 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 = { '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 => { 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}`); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 669cc31f..636559cb 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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[] { diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index d23b54ef..47e458b5 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -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. diff --git a/docs/images/rbac/role-selector-dropdown.png b/docs/images/rbac/role-selector-dropdown.png index 3e79b496..ccf49ebd 100644 Binary files a/docs/images/rbac/role-selector-dropdown.png and b/docs/images/rbac/role-selector-dropdown.png differ diff --git a/docs/images/rbac/user-create-form.png b/docs/images/rbac/user-create-form.png index 6d32cfc2..6f543f9b 100644 Binary files a/docs/images/rbac/user-create-form.png and b/docs/images/rbac/user-create-form.png differ diff --git a/docs/images/rbac/users-list.png b/docs/images/rbac/users-list.png index c2d7ad0c..11f7ec6c 100644 Binary files a/docs/images/rbac/users-list.png and b/docs/images/rbac/users-list.png differ diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 4a4a96b8..04d03709 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -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() { {!showForm && ( )} @@ -260,48 +261,53 @@ export function UsersSection() {
- -
- -
-
- - setFormPassword(e.target.value)} - placeholder={editingUser ? 'Leave blank to keep' : 'min. 8 characters'} - /> -
-
- - setFormConfirmPassword(e.target.value)} - placeholder="Confirm password" + setFormRole(v as UserRole)} + placeholder="Select role..." />
+ {/* Hide password fields for SSO-provisioned users */} + {(!editingUser || editingUser.auth_provider === 'local') ? ( +
+
+ + setFormPassword(e.target.value)} + placeholder={editingUser ? 'Leave blank to keep' : 'min. 8 characters'} + /> +
+
+ + setFormConfirmPassword(e.target.value)} + placeholder="Confirm password" + /> +
+
+ ) : ( +

+ Password is managed by the identity provider ({editingUser.auth_provider}). +

+ )}
@@ -322,7 +328,7 @@ export function UsersSection() { on {a.resource_type}: {a.resource_id} ))} @@ -332,50 +338,46 @@ export function UsersSection() {
- + setScopeRole(v as UserRole)} + placeholder="Role..." + className="h-8 text-xs w-[120px]" + />
- + { setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }} + placeholder="Type..." + className="h-8 text-xs w-[100px]" + />
- + ({ 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" + />
@@ -423,12 +425,12 @@ export function UsersSection() {