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:
Anso
2026-08-07 23:50:53 -04:00
committed by GitHub
parent e084ad424c
commit 5c52ae26eb
15 changed files with 126 additions and 126 deletions
@@ -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();
+40 -18
View File
@@ -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');
+6 -17
View File
@@ -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)
+8 -14
View File
@@ -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({
+1 -16
View File
@@ -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);
-3
View File
@@ -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,