mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
fix(rbac): make complete built-in RBAC available on Community (#1793)
Open all five built-in global roles and stack/node scoped assignments on Community. Remove paid fences from user role create/update, scoped assignment CRUD, permission evaluation, and the Users settings UI. Admiral continues to own extended audit governance, LDAP directory integration, and other organizational assurance features. Built-in scoped RBAC is no longer marketed or enforced as paid-only.
This commit is contained in:
@@ -97,6 +97,30 @@ describe('scopedActionsForStack', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermission on Community tier with scoped grants', () => {
|
||||
it('honors stack scoped deployer grants when proxyTier is community', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addRoleAssignment({
|
||||
user_id: viewerId,
|
||||
role: 'deployer',
|
||||
resource_type: 'stack',
|
||||
resource_id: 'community-deploy-me',
|
||||
node_id: defaultNodeId,
|
||||
});
|
||||
|
||||
const req = mockReq({
|
||||
userId: viewerId,
|
||||
role: 'viewer',
|
||||
proxyTier: 'community',
|
||||
});
|
||||
expect(checkPermission(req, 'stack:deploy', 'stack', 'community-deploy-me')).toBe(true);
|
||||
expect(checkPermission(req, 'stack:edit', 'stack', 'community-deploy-me')).toBe(false);
|
||||
expect(checkPermission(req, 'stack:deploy', 'stack', 'other-stack')).toBe(false);
|
||||
|
||||
db.deleteRoleAssignmentsByStack(defaultNodeId, 'community-deploy-me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermission with node-scoped stack grants', () => {
|
||||
it('viewer + scoped deploy grant succeeds for stack:deploy when req.nodeId matches', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
@@ -136,28 +136,36 @@ describe('POST /api/users', () => {
|
||||
expect(res.body.code).toBe('SCOPE_DENIED');
|
||||
});
|
||||
|
||||
it('creates an advanced-role user on the paid tier (201)', async () => {
|
||||
it('creates a deployer user (201)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'paid-deployer', password: 'password123', role: 'deployer' });
|
||||
.send({ username: 'role-deployer', password: 'password123', role: 'deployer' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.role).toBe('deployer');
|
||||
DatabaseService.getInstance().deleteUser(res.body.id);
|
||||
});
|
||||
|
||||
it('blocks an advanced-role user on the Community tier (403 PAID_REQUIRED)', async () => {
|
||||
it('creates deployer, node-admin, and auditor users on the Community tier (201)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const svc = LicenseService.getInstance();
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('community');
|
||||
const createdIds: number[] = [];
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'community-deployer', password: 'password123', role: 'deployer' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
for (const role of ['deployer', 'node-admin', 'auditor'] as const) {
|
||||
const res = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: `community-${role}`, password: 'password123', role });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.role).toBe(role);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
createdIds.push(res.body.id);
|
||||
}
|
||||
} finally {
|
||||
for (const id of createdIds) {
|
||||
DatabaseService.getInstance().deleteUser(id);
|
||||
}
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
@@ -506,17 +514,31 @@ describe('Scoped Role Assignments', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/users/:id/roles is blocked on the Community tier (PAID_REQUIRED)', async () => {
|
||||
it('POST /api/users/:id/roles succeeds on the Community tier (201)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const svc = LicenseService.getInstance();
|
||||
const nodeId = defaultNodeId();
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${targetUserId}/roles`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: defaultNodeId() });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: nodeId });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.role).toBe('deployer');
|
||||
expect(res.body.resource_id).toBe('community-stack');
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
|
||||
const listed = await request(app)
|
||||
.get(`/api/users/${targetUserId}/roles`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.some((a: { id: number }) => a.id === res.body.id)).toBe(true);
|
||||
|
||||
const deleted = await request(app)
|
||||
.delete(`/api/users/${targetUserId}/roles/${res.body.id}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(deleted.status).toBe(200);
|
||||
} finally {
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('paid');
|
||||
}
|
||||
@@ -563,30 +585,30 @@ describe('GET /api/permissions/me', () => {
|
||||
db.deleteUser(id);
|
||||
});
|
||||
|
||||
it('omits scoped permissions on the Community tier even when assignments exist', async () => {
|
||||
it('includes scoped permissions on the Community tier when assignments exist', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
const svc = LicenseService.getInstance();
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
const nodeId = defaultNodeId();
|
||||
const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' });
|
||||
db.addRoleAssignment({
|
||||
user_id: id,
|
||||
role: 'deployer',
|
||||
resource_type: 'stack',
|
||||
resource_id: 'my-stack',
|
||||
node_id: defaultNodeId(),
|
||||
node_id: nodeId,
|
||||
});
|
||||
const user = db.getUserById(id)!;
|
||||
const token = authToken('permcheck-community', 'viewer', user.token_version);
|
||||
|
||||
// Scoped grants only take effect on paid; a downgraded instance must not
|
||||
// advertise per-resource permissions the API will then 403.
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('community');
|
||||
const res = await request(app)
|
||||
.get('/api/permissions/me')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scopedPermissions).toEqual({});
|
||||
expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toBeDefined();
|
||||
expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toContain('stack:deploy');
|
||||
|
||||
// Cleanup
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('paid');
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService';
|
||||
import type { LicenseTier } from '../services/license-types';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { effectiveTier } from './tierGates';
|
||||
|
||||
// --- Scoped RBAC Permission Engine (paid) ---
|
||||
// --- Scoped RBAC Permission Engine ---
|
||||
|
||||
/** Permission subject decoupled from Express Request; used by in-process callers like the scheduler. */
|
||||
export interface PermissionSubject { username: string; role: UserRole; userId: number; }
|
||||
@@ -84,14 +82,13 @@ export function scopedActionsForStack(
|
||||
|
||||
/**
|
||||
* Core permission resolver without a Request dependency. Admin bypasses
|
||||
* all checks; scoped assignments only apply on the paid tier. Used by
|
||||
* in-process callers (e.g. the scheduler) that have a subject + tier but
|
||||
* no HTTP context. Does NOT handle scopedStackEvidence (machine-auth hop
|
||||
* elevation) — that path requires a Request.
|
||||
* all checks; scoped assignments are evaluated on every tier. Used by
|
||||
* in-process callers (e.g. the scheduler) that have a subject but no HTTP
|
||||
* context. Does NOT handle scopedStackEvidence (machine-auth hop elevation);
|
||||
* that path requires a Request.
|
||||
*/
|
||||
export function checkPermissionForSubject(
|
||||
subject: PermissionSubject,
|
||||
tier: LicenseTier,
|
||||
action: PermissionAction,
|
||||
resourceType?: ResourceType,
|
||||
resourceId?: string,
|
||||
@@ -104,8 +101,6 @@ export function checkPermissionForSubject(
|
||||
|
||||
if (!resourceType || !resourceId) return false;
|
||||
|
||||
if (tier !== 'paid') return false;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = resourceType === 'stack' ? (resourceNodeId ?? undefined) : null;
|
||||
const assignments = db.getRoleAssignments(
|
||||
@@ -135,7 +130,7 @@ export function checkPermissionForSubject(
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on the paid tier. */
|
||||
/** Core permission resolver. Admin bypasses all checks; scoped assignments apply on every tier. */
|
||||
export function checkPermission(
|
||||
req: Request,
|
||||
action: PermissionAction,
|
||||
@@ -168,12 +163,6 @@ export function checkPermission(
|
||||
return true;
|
||||
}
|
||||
|
||||
const tier = effectiveTier(req);
|
||||
if (tier !== 'paid') {
|
||||
console.warn('[RBAC] Scoped assignment check blocked: effective tier is', sanitizeForLog(tier), 'license_status:', sanitizeForLog(DatabaseService.getInstance().getSystemState('license_status') ?? ''));
|
||||
return false;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = resourceType === 'stack'
|
||||
? (resourceNodeId === undefined ? req.nodeId : resourceNodeId)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
||||
import { effectiveTier } from '../middleware/tierGates';
|
||||
|
||||
export const permissionsRouter = Router();
|
||||
|
||||
@@ -17,20 +16,15 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void
|
||||
const globalRole = req.user.role;
|
||||
const globalPermissions = ROLE_PERMISSIONS[globalRole] || [];
|
||||
|
||||
// Scoped role assignments only take effect on the paid tier (mirrors
|
||||
// checkPermission in middleware/permissions.ts). Returning them to a
|
||||
// Community client would render per-resource affordances the API then 403s,
|
||||
// for example on an instance that held assignments before a downgrade.
|
||||
// Scoped assignments apply on every tier (mirrors checkPermission).
|
||||
const scopedPermissions: Record<string, PermissionAction[]> = {};
|
||||
if (effectiveTier(req) === 'paid') {
|
||||
for (const a of db.getAllRoleAssignments(req.user.userId)) {
|
||||
const key = a.resource_type === 'stack'
|
||||
? `stack:${a.node_id}:${a.resource_id}`
|
||||
: `node:${a.resource_id}`;
|
||||
const perms = ROLE_PERMISSIONS[a.role] || [];
|
||||
const existing = scopedPermissions[key] || [];
|
||||
scopedPermissions[key] = [...new Set([...existing, ...perms])];
|
||||
}
|
||||
for (const a of db.getAllRoleAssignments(req.user.userId)) {
|
||||
const key = a.resource_type === 'stack'
|
||||
? `stack:${a.node_id}:${a.resource_id}`
|
||||
: `node:${a.resource_id}`;
|
||||
const perms = ROLE_PERMISSIONS[a.role] || [];
|
||||
const existing = scopedPermissions[key] || [];
|
||||
scopedPermissions[key] = [...new Set([...existing, ...perms])];
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Router, type Request, type Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid } from '../middleware/tierGates';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { BCRYPT_SALT_ROUNDS, MIN_PASSWORD_LENGTH } from '../helpers/constants';
|
||||
@@ -19,13 +18,6 @@ const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin
|
||||
const VALID_ASSIGNMENT_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
|
||||
const VALID_RESOURCE_TYPES: ResourceType[] = ['stack', 'node'];
|
||||
|
||||
// Roles that require a paid license. Viewer and admin are available on the
|
||||
// free tier; the advanced roles unlock per-resource scoping that is only
|
||||
// meaningful on paid.
|
||||
function roleRequiresPaid(role: UserRole): boolean {
|
||||
return role === 'deployer' || role === 'node-admin' || role === 'auditor';
|
||||
}
|
||||
|
||||
export const usersRouter = Router();
|
||||
|
||||
usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -69,7 +61,6 @@ usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
|
||||
return;
|
||||
}
|
||||
if (roleRequiresPaid(role) && !requirePaid(req, res)) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getUserByUsername(username);
|
||||
@@ -88,8 +79,6 @@ usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
}
|
||||
});
|
||||
|
||||
// PUT/DELETE intentionally do NOT enforce requirePaid. Admins must be able
|
||||
// to manage existing users even if their license lapses.
|
||||
usersRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requirePermission(req, res, 'system:users')) return;
|
||||
@@ -124,7 +113,6 @@ usersRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pro
|
||||
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
|
||||
return;
|
||||
}
|
||||
if (roleRequiresPaid(role) && !requirePaid(req, res)) return;
|
||||
if (user.username === req.user!.username && role !== user.role) {
|
||||
res.status(400).json({ error: 'Cannot change your own role' });
|
||||
return;
|
||||
@@ -229,12 +217,11 @@ usersRouter.post('/:id/mfa/reset', authMiddleware, (req: Request, res: Response)
|
||||
}
|
||||
});
|
||||
|
||||
// --- Scoped Role Assignments (paid) ---
|
||||
// --- Scoped Role Assignments ---
|
||||
|
||||
usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requirePermission(req, res, 'system:users')) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
@@ -253,7 +240,6 @@ usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): voi
|
||||
usersRouter.post('/:id/roles', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requirePermission(req, res, 'system:users')) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const { role, resource_type, resource_id, node_id: rawNodeId } = req.body;
|
||||
@@ -359,7 +345,6 @@ usersRouter.post('/:id/roles', authMiddleware, async (req: Request, res: Respons
|
||||
usersRouter.delete('/:id/roles/:assignId', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requirePermission(req, res, 'system:users')) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const assignId = parseInt(req.params.assignId as string, 10);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CronExpressionParser } from 'cron-parser';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import type { ScheduledTask } from './DatabaseService';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import type { LicenseTier } from './license-types';
|
||||
import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers';
|
||||
import DockerController from './DockerController';
|
||||
import { ComposeService } from './ComposeService';
|
||||
@@ -333,10 +332,8 @@ export class SchedulerService {
|
||||
task.node_id,
|
||||
task.selector_type,
|
||||
);
|
||||
const tier: LicenseTier = LicenseService.getInstance().getTier();
|
||||
if (!checkPermissionForSubject(
|
||||
{ username: creator.username, role: creator.role, userId: creator.id },
|
||||
tier,
|
||||
scope.action,
|
||||
scope.resourceType,
|
||||
scope.resourceId,
|
||||
|
||||
Reference in New Issue
Block a user