mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +00:00
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:
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user