From 5c52ae26eb602cc581f08bdbc5d35a12c21def7d Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 7 Aug 2026 23:50:53 -0400 Subject: [PATCH] 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. --- README.md | 4 +- .../__tests__/permissions-stack-rbac.test.ts | 24 ++++++++ backend/src/__tests__/users-rbac.test.ts | 58 +++++++++++++------ backend/src/middleware/permissions.ts | 23 ++------ backend/src/routes/permissions.ts | 22 +++---- backend/src/routes/users.ts | 17 +----- backend/src/services/SchedulerService.ts | 3 - docs/features/audit-log.mdx | 12 ++-- docs/features/licensing.mdx | 6 +- docs/features/overview.mdx | 2 +- docs/features/rbac.mdx | 46 +++++++-------- docs/reference/security.mdx | 12 ++-- docs/reference/settings.mdx | 8 +-- .../src/components/settings/UsersSection.tsx | 14 ++--- .../settings/__tests__/UsersSection.test.tsx | 1 - 15 files changed, 126 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 88bbf980..2229f77e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Sencho is free, open-source software under AGPLv3. Everything below is included ### Security - [SSO](https://docs.sencho.io/features/sso): custom OIDC and presets for Google, GitHub, and Okta - [Two-factor authentication](https://docs.sencho.io/features/two-factor-authentication) with TOTP and backup codes -- [RBAC](https://docs.sencho.io/features/rbac) with admin (full control) and viewer (read-only) roles +- [RBAC](https://docs.sencho.io/features/rbac) with five built-in roles and stack or node scoped assignments - [Security overview](https://docs.sencho.io/features/security) with a chart-led scan summary, sortable images, and searchable scan history - [Vulnerability scanning](https://docs.sencho.io/features/vulnerability-scanning) via Trivy, with on-demand node-wide scans, VEX-based suppression, SARIF export, and SBOM upload - [Compose network inspector](https://docs.sencho.io/features/compose-networking) with an exposure-intent guard for unintended published ports @@ -194,7 +194,7 @@ Sencho does not emit telemetry, analytics, or crash reports, and makes no outbou ## Admiral -**Admiral** is Studio Saelix's paid business assurance plan on top of everything in Community: Hardened Build, Recovery Vault (managed off-site snapshots), priority support, and governance depth (advanced RBAC roles, LDAP / Active Directory, full audit log export and anomaly detection). Fleet Sync policy replication and AWS ECR registry credentials currently require Admiral as well; those access rules are temporary availability, not the reason Admiral exists. See [sencho.io/pricing](https://sencho.io/pricing) for current plan details. +**Admiral** is Studio Saelix's paid business assurance plan on top of everything in Community: Hardened Build, Recovery Vault (managed off-site snapshots), priority support, and governance depth (LDAP / Active Directory, full audit log export and anomaly detection, and related organizational controls). Built-in RBAC (five roles and scoped assignments) is included on Community. AWS ECR registry credentials currently require Admiral as well; that access rule is temporary availability, not the reason Admiral exists. See [sencho.io/pricing](https://sencho.io/pricing) for current plan details. --- diff --git a/backend/src/__tests__/permissions-stack-rbac.test.ts b/backend/src/__tests__/permissions-stack-rbac.test.ts index dc27241d..cea1911f 100644 --- a/backend/src/__tests__/permissions-stack-rbac.test.ts +++ b/backend/src/__tests__/permissions-stack-rbac.test.ts @@ -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(); diff --git a/backend/src/__tests__/users-rbac.test.ts b/backend/src/__tests__/users-rbac.test.ts index d2777e71..a7cfd480 100644 --- a/backend/src/__tests__/users-rbac.test.ts +++ b/backend/src/__tests__/users-rbac.test.ts @@ -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'); diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts index a8c3bd41..e6b1da5b 100644 --- a/backend/src/middleware/permissions.ts +++ b/backend/src/middleware/permissions.ts @@ -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) diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts index d229cb01..ff9f065a 100644 --- a/backend/src/routes/permissions.ts +++ b/backend/src/routes/permissions.ts @@ -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 = {}; - 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({ diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 0856edb1..89232e6e 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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 => { @@ -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 => { 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 => { 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); diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 5b62f417..19393abf 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -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, diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index b3efc312..db787552 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -4,7 +4,7 @@ description: Track every mutating action on your Sencho instance with a searchab --- - Audit requires the `system:audit` permission (Admin, or Auditor on Admiral). Community shows the rolling 14-day recent-activity window. Admiral adds the 24h signal rail, CSV/JSON export, anomaly detection, and configurable retention beyond that window. + Audit requires the `system:audit` permission (Admin or Auditor). Community shows the rolling 14-day recent-activity window. Admiral adds the 24h signal rail, CSV/JSON export, anomaly detection, and configurable retention beyond that window. @@ -66,7 +66,7 @@ Expanding a row in the Table view reveals additional detail: ## Viewing the audit log -The **Audit** tab appears for any signed-in user whose role grants the `system:audit` permission (by default **Admin**, and **Auditor** when that role is available). Community shows the last 14 days of activity. Admiral shows the full retained history; retention defaults to 90 days and is configurable up to 365. +The **Audit** tab appears for any signed-in user whose role grants the `system:audit` permission (by default **Admin** or **Auditor**). Community shows the last 14 days of activity. Admiral shows the full retained history; retention defaults to 90 days and is configurable up to 365. Navigate to the **Audit** tab in the top navigation when it is available for your role. @@ -152,14 +152,14 @@ Exports are capped at 10,000 entries per download. To export a larger window, na ## Auditor role - Assigning the **Auditor** role requires a Sencho **Admiral** license; Community can assign **Admin** or **Viewer** only. + The **Auditor** role is available on every plan. Assign it under **Settings · Users**. The **Auditor** role provides read-only access to the audit log without granting any administrative privileges. Auditors can: -- View the full audit log +- View the audit log (Community: last 14 days; Admiral: full retained history) - Search and filter entries (Table view) -- Export audit data as CSV or JSON +- Export as CSV or JSON (Admiral) - View stacks and nodes (read-only) Auditors **cannot** modify settings, manage users, deploy stacks, or perform any other administrative action. The role is ideal for compliance officers, security reviewers, or team leads who need visibility into system activity without operational access. @@ -194,7 +194,7 @@ Sensitive database values (such as remote node API tokens) are encrypted at rest - The Audit tab appears only for users with the `system:audit` permission (by default **Admin**, or **Auditor** when that role is available). If you are signed in as a Viewer, Deployer, or Node Admin, ask an admin to grant you a role that includes audit access. On Community, Admin is the role that can open Audit; the Auditor role requires Admiral to assign. + The Audit tab appears only for users with the `system:audit` permission (by default **Admin** or **Auditor**). If you are signed in as a Viewer, Deployer, or Node Admin, ask an admin to grant you a role that includes audit access. Filters live in **Table view only**. Toggle the segmented control in the card header from **Stream** to **Table** and the search box, method dropdown, and From / To date pickers will appear above the grid. Switching back to Stream clears the filter strip but does not remember the last filter. diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 5ce45db3..fc0f82dc 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -43,7 +43,7 @@ Admiral is arranged directly with Studio Saelix rather than a self-serve checkou - A 14-day recent-activity audit log with Stream and Table views and filtering - Alert rules with Discord, Slack, Apprise, and webhook targets - API tokens for CI/CD pipelines and scripts (admin role required) -- Unlimited accounts with the Admin and Viewer roles +- Unlimited accounts with the full built-in RBAC system (Admin, Viewer, Deployer, Node Admin, Auditor) and stack or node scoped assignments - Two-factor authentication (TOTP plus backup codes) - Single sign-on with Custom OIDC (Authelia, Keycloak, Authentik, Zitadel, Pocket ID, or any spec-compliant OIDC provider) and preset providers for Google, GitHub, and Okta @@ -52,9 +52,9 @@ Admiral is arranged directly with Studio Saelix rather than a self-serve checkou - **Hardened Build:** an Admiral image channel with published supply-chain assurance artifacts, switched from **Settings → Admiral Account**; see [Switching to Hardened Build](#switching-to-hardened-build) below - **Managed continuity:** Recovery Vault (a managed, off-site snapshot allowance) - **Assurance and support:** priority email support and Studio Saelix-backed continuity for production fleets -- **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and audit log export (CSV, JSON), anomaly detection, and configurable retention beyond the recent window +- **Governance:** extended audit (export as CSV or JSON, anomaly detection, configurable retention beyond the recent window) and planned organizational controls such as advanced identity mapping and approval workflows - **Directory integration:** LDAP / Active Directory authentication -- **Current plan availability:** some product surfaces (including AWS ECR credentials and Fleet Sync policy replication) still require an Admiral plan today. That access rule is temporary availability, not the reason Admiral exists. Other operator surfaces may be limited-availability on a given instance and are documented on their own feature pages when enabled. +- **Current plan availability:** some product surfaces (including AWS ECR credentials) still require an Admiral plan today. That access rule is temporary availability, not the reason Admiral exists. Other operator surfaces may be limited-availability on a given instance and are documented on their own feature pages when enabled. **Planned assurance services** (not available today; no delivery date committed): Release Safety Channel, Production Assurance Reports, and Fleet Beacon. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 874e8809..eecaf286 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -240,7 +240,7 @@ Protect your account with a time-based one-time password from an authenticator a ### RBAC and user management -Create unlimited accounts with read-only Viewer access to dashboards, logs, and file contents, while keeping deploy and edit locked to admins. Community includes the Admin and Viewer roles; Admiral adds more granular team roles and scoped permissions. [Learn more →](/features/rbac) +Create unlimited accounts with the full built-in RBAC system: Admin, Viewer, Deployer, Node Admin, and Auditor, plus stack or node scoped assignments for least-privilege access. [Learn more →](/features/rbac) ### SSO and LDAP authentication diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index ed88e09c..f194dee5 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -5,14 +5,14 @@ description: Role-based access control for Sencho. Manage admin, viewer, deploye --- - Community supports unlimited accounts with the **Admin** and **Viewer** roles. The **Deployer**, **Node Admin**, and **Auditor** roles, plus scoped permissions, require **Admiral**. + Community includes complete built-in RBAC: unlimited accounts, all five global roles, and stack or node scoped assignments. Admiral adds organizational identity governance, extended audit depth, and related assurance workflows. The Users panel is hub-only. It does not appear under Settings on a remote node, since accounts and roles are managed from the gateway that proxies the fleet. -Sencho ships with five built-in roles that map to the permissions most operators reach for: full operator access, read-only observation, day-to-day deploys, scoped fleet management, and audit-only compliance. Admiral adds **scoped permissions** so you can grant a viewer the right to deploy one specific stack without elevating them anywhere else. +Sencho ships with five built-in roles that map to the permissions most operators reach for: full operator access, read-only observation, day-to-day deploys, scoped fleet management, and audit-only compliance. **Scoped permissions** let you grant a viewer the right to deploy one specific stack without elevating them anywhere else. Built-in roles and scopes are available on every plan. ## Roles @@ -20,9 +20,9 @@ Sencho ships with five built-in roles that map to the permissions most operators |------|----------------|------| | **Admin** | Full operator access: deploy, edit compose, manage users, configure nodes, view audit log, every system setting | Community | | **Viewer** | Read-only access to stacks, logs, stats, file contents, and node listings | Community | -| **Deployer** | Deploy, restart, stop, and start stacks, and check individual stacks for image updates. Cannot edit compose files, create or delete stacks, view nodes, or manage alert and auto-heal rules | Admiral | -| **Node Admin** | Full stack and node management across the fleet, including node-scoped operational Settings. No access to users, licensing, credentials, or system-only Settings | Admiral | -| **Auditor** | Read-only access to stacks, nodes, and the audit log. No write access anywhere | Admiral | +| **Deployer** | Deploy, restart, stop, and start stacks, and check individual stacks for image updates. Cannot edit compose files, create or delete stacks, view nodes, or manage alert and auto-heal rules | Community | +| **Node Admin** | Full stack and node management across the fleet, including node-scoped operational Settings. No access to users, licensing, credentials, or system-only Settings | Community | +| **Auditor** | Read-only access to stacks, nodes, and the audit log. No write access anywhere | Community | ### Permission matrix @@ -46,16 +46,16 @@ Each row is one of the permission keys the backend checks. The matrix below is t | Host console (`system:console`) | Yes | No | No | No | No | | Container registries (`system:registries`) | Yes | No | No | No | No | -On Admiral, a user with a lower global role can still hold extra permissions on specific stacks or nodes through scoped assignments (covered below). Scoped permissions are additive: they grant more, never less. +A user with a lower global role can still hold extra permissions on specific stacks or nodes through scoped assignments (covered below). Scoped permissions are additive: they grant more, never less. ## Account limits by tier -| Tier | Admin accounts | Non-admin accounts | Intermediate roles | Scoped permissions | -|------|---------------|--------------------|--------------------|--------------------| -| **Community** | Unlimited | Unlimited | No | No | -| **Admiral** | Unlimited | Unlimited | Yes | Yes | +| Tier | Admin accounts | Non-admin accounts | Built-in roles | Scoped permissions | +|------|---------------|--------------------|----------------|--------------------| +| **Community** | Unlimited | Unlimited | All five | Yes | +| **Admiral** | Unlimited | Unlimited | All five | Yes | -Community accounts use the **Admin** and **Viewer** roles. Admiral adds the intermediate roles (Deployer, Node Admin, Auditor) and scoped permissions. +Both plans use the same five built-in roles and the same additive stack and node scopes. Admiral extends identity and audit governance beyond this foundation. ## Managing users @@ -84,21 +84,21 @@ Click **Add user**. The form opens inline below the button (it is not a modal), | Field | Rules | |-------|-------| | **Username** | At least 3 characters. Letters, numbers, underscores, and hyphens only. Submitting with `.` or whitespace returns `Username can only contain letters, numbers, underscores, and hyphens.` | -| **Role** | Combobox. On Admiral you see all five roles; on Community you see Admin and Viewer only. | +| **Role** | Combobox listing Admin, Viewer, Deployer, Node Admin, and Auditor. | | **Password** | At least 8 characters. The placeholder reads `min. 8 characters`. | | **Confirm Password** | Must match the password field, validated on submit. | -The role combobox on Admiral exposes the full set: +The role combobox exposes the full built-in set: - Role combobox open inside the New User form on an Admiral instance, listing Admin, Viewer (checkmark), Deployer, Node Admin, and Auditor as selectable options. + Role combobox open inside the New User form, listing Admin, Viewer (checkmark), Deployer, Node Admin, and Auditor as selectable options. Click **Create user** to submit. The form clears, the table refreshes, and an audit-log entry is written with the actor, target username, and assigned role. ### Editing a user -Click the pencil icon on a row to switch the form into **Edit User** mode. The same four fields render with the existing values pre-filled, plus a separate **Scoped Permissions** box below for Admiral instances (see [Scoped permissions](#scoped-permissions)). +Click the pencil icon on a row to switch the form into **Edit User** mode. The same four fields render with the existing values pre-filled, plus a separate **Scoped Permissions** box below (see [Scoped permissions](#scoped-permissions)). The password fields change subtly in edit mode: @@ -109,17 +109,13 @@ Click **Update user** to save. Changing the role takes effect on the next API re ## Scoped permissions - - Scoped permissions require **Admiral**. - - -Scoped permissions let you grant a user a higher role on a specific stack or node without elevating them globally. A Viewer can be granted Deployer on one stack; a Deployer can be granted Node Admin on one server. +Scoped permissions let you grant a user a higher role on a specific stack or node without elevating them globally. A Viewer can be granted Deployer on one stack; a Deployer can be granted Node Admin on one server. Built-in stack and node scopes are available on Community and Admiral. **Stack scopes are node-specific.** The same stack name on two different nodes is two independent grants. Assigning a stack scope means choosing the node first, then picking a stack that exists on that node. Display form conceptually: stack name @ node name (for example `frontend @ prod`). **Node scopes are node-wide.** The resource is the node itself. There is no separate node qualifier on a node assignment row. Granting Node Admin (or Deployer, or Admin) on `staging-server` authorizes that role's stack and node operations for every stack on that node, without a separate per-stack grant. -The box appears below the user form whenever you are editing a user on Admiral. +The box appears below the user form whenever you are editing a user. The add-scope form has these controls and an **Add** button: @@ -193,7 +189,7 @@ Admins can turn this renewal off from **Settings > Users > Session policy** (**K ## SSO auto-provisioning -With SSO configured on Admiral, users authenticate through an identity provider (LDAP, Custom OIDC, Google, GitHub, Okta). On their first successful sign-in, Sencho auto-creates a user record. SSO accounts appear in the Users list alongside local accounts and can be edited the same way; only the password and (optionally) the role differ. +With SSO configured, users authenticate through an identity provider (Custom OIDC and preset providers on every plan; LDAP on Admiral). On their first successful sign-in, Sencho auto-creates a user record. SSO accounts appear in the Users list alongside local accounts and can be edited the same way; only the password and (optionally) the role differ. Two SSO-specific behaviors to keep in mind: @@ -231,8 +227,8 @@ Entries include the acting user, IP address, HTTP method and path, response stat The Users entry is hidden in two cases. **One,** you are signed in as a non-admin (Viewer, Deployer, Auditor): the entry is admin-only. **Two,** you have a remote node selected: the panel is hub-only and is hidden in the sidebar when any remote node is active. Switch back to the local node via the node switcher in the masthead. - - The combobox only shows roles available on your tier. On Community, the combobox lists Admin and Viewer only. **Deployer**, **Node Admin**, and **Auditor** are Admiral roles. Upgrade to Admiral to use them, or the scoped-permission equivalents. + + The combobox lists the five built-in roles: Admin, Viewer, Deployer, Node Admin, and Auditor. Custom roles are not available. If a role is missing after a UI refresh, sign out and back in, or confirm you are editing from the hub (local node) under **Settings · Users**. Token-version bumps invalidate sessions. Two events do this: an admin changed the user's password, or an admin reset their 2FA. Both rotate the user's token version, so every JWT issued before the rotation is rejected on the next request. The user can sign in again with their (possibly new) password. Role changes do **not** sign the user out; they take effect on the next request without rotating the token version. @@ -241,7 +237,7 @@ Entries include the acting user, IP address, HTTP method and path, response stat Check whether **Session policy > Keep active sessions alive** was turned off in **Settings > Users**. With it off, every session hits a strict, fixed 24-hour (or 30-day, with **Stay signed in**) ceiling regardless of activity. Turn it back on so an active session renews itself instead of hard-expiring, or have the user check **Stay signed in** at their next sign-in for a longer session between visits. - Three causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or stack name on the assignment does not match the request's resource (names are case-sensitive). **Three,** the stack grant is tied to a different node than the one the user is acting on: the same stack name on another node is a separate grant. Re-open the user in the edit form and confirm the existing-scope row shows the expected stack name at the expected node. + Two common causes. **One,** the resource type or stack name on the assignment does not match the request's resource (names are case-sensitive). **Two,** the stack grant is tied to a different node than the one the user is acting on: the same stack name on another node is a separate grant. Re-open the user in the edit form and confirm the existing-scope row shows the expected stack name at the expected node. Built-in scopes remain active on Community and Admiral. The icon only appears for users with a finished TOTP enrollment. If the user started enrollment but never confirmed their first code, the enrollment is incomplete and the icon stays hidden. Ask the user to finish enrollment from their account settings, or, if they cannot, leave the row alone: there is nothing to reset. diff --git a/docs/reference/security.mdx b/docs/reference/security.mdx index a064df88..7e8c6037 100644 --- a/docs/reference/security.mdx +++ b/docs/reference/security.mdx @@ -72,7 +72,7 @@ Every self-hosted instance includes the full security stack. Some advanced gover ## Tier availability -Every Sencho instance includes the foundational security stack. Advanced access-control and compliance features are available on paid tiers. +Every Sencho instance includes the foundational security stack. Organizational identity and extended audit governance features are available on paid tiers. | Feature | Community | Admiral | |---------|:---------:|:-------:| @@ -86,15 +86,13 @@ Every Sencho instance includes the foundational security stack. Advanced access- | Rate limiting (auth + API) | ✓ | ✓ | | Node-to-node authentication | ✓ | ✓ | | Vulnerability scanning (on-demand + post-deploy + scheduled) | ✓ | ✓ | -| Multi-user with RBAC (Admin, Viewer) | ✓ | ✓ | +| Built-in RBAC (five roles + scoped assignments) | ✓ | ✓ | | Webhook signatures (HMAC-SHA256) | ✓ | ✓ | | API tokens (scoped, expiring) | ✓ | ✓ | | SBOM generation (SPDX, CycloneDX) | ✓ | ✓ | | Recent-activity audit log (14-day window) | ✓ | ✓ | | Scan policies (`block_on_deploy`) | ✓ | ✓ | | SARIF export (admin) | ✓ | ✓ | -| Advanced RBAC (Deployer, Node Admin, Auditor) | | ✓ | -| Scoped permissions (per-stack, per-node) | | ✓ | | Audit log export, anomaly detection, and extended retention | | ✓ | ## Password authentication @@ -182,7 +180,7 @@ Webhooks are available on every tier. For setup and verification recipes, see [W ## Role-based access control -Sencho defines five roles with increasing levels of access. Admin and Viewer are available on every tier; Deployer, Node Admin, and Auditor require Admiral. +Sencho defines five built-in roles with increasing levels of access. All five roles and stack or node scoped assignments are available on every plan. ### Permission matrix @@ -202,9 +200,9 @@ Sencho defines five roles with increasing levels of access. Admin and Viewer are | API tokens | ✓ | | | | | | Host console | ✓ | | | | | -On Admiral, you can create **scoped assignments** that grant a user elevated permissions on specific stacks or nodes without giving them broad access. For example, a Viewer can be promoted to Deployer on a single production stack. +You can create **scoped assignments** that grant a user elevated permissions on specific stacks or nodes without giving them broad access. For example, a Viewer can be promoted to Deployer on a single production stack. Scoped assignments are available on Community and Admiral. -Both tiers support unlimited accounts. Community uses the Admin and Viewer roles; Admiral adds the Deployer, Node Admin, and Auditor roles plus scoped permissions. +Both tiers support unlimited accounts and the same five built-in roles with additive stack and node scopes. For user management and scoped permissions, see [RBAC & User Management](/features/rbac). diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index a3eadcaf..39c174ab 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -186,7 +186,7 @@ See [Licensing & Billing](/features/licensing) for the full walkthrough includin ## Users - User management requires an admin role. Community supports unlimited accounts with the Admin and Viewer roles. + User management requires an admin role. Community supports unlimited accounts with the full built-in RBAC system (five roles and scoped assignments). **Scope:** Global @@ -206,9 +206,9 @@ Create and manage user accounts with role-based access. The masthead publishes a |------|------|-------------| | **Admin** | Community | Full access to all features | | **Viewer** | Community | Read-only access to stacks and nodes | -| **Deployer** | Admiral | Can view stacks and trigger deployments | -| **Node Admin** | Admiral | Full stack and node management, including node-scoped operational Settings | -| **Auditor** | Admiral | Read-only plus audit log access | +| **Deployer** | Community | Can view stacks and trigger deployments | +| **Node Admin** | Community | Full stack and node management, including node-scoped operational Settings | +| **Auditor** | Community | Read-only plus audit log access | See [RBAC & User Management](/features/rbac) for details on what each role can access. diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index d7597f38..ea20e932 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -11,7 +11,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { TogglePill } from '@/components/ui/toggle-pill'; import { apiFetch } from '@/lib/api'; import { useAuth, type UserRole } from '@/context/AuthContext'; -import { useLicense } from '@/context/LicenseContext'; import { CapabilityGate } from '@/components/CapabilityGate'; import { RefreshCw, Trash2, Plus, Pencil, ShieldOff, AlertTriangle } from 'lucide-react'; import { SettingsCallout } from './SettingsCallout'; @@ -169,7 +168,6 @@ function SessionPolicySection() { export function UsersSection() { const { user: currentUser } = useAuth(); - const { isPaid } = useLicense(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); @@ -458,11 +456,9 @@ export function UsersSection() { options={[ { value: 'admin', label: 'Admin' }, { value: 'viewer', label: 'Viewer' }, - ...(isPaid ? [ - { value: 'deployer', label: 'Deployer' }, - { value: 'node-admin', label: 'Node Admin' }, - { value: 'auditor', label: 'Auditor' }, - ] : []), + { value: 'deployer', label: 'Deployer' }, + { value: 'node-admin', label: 'Node Admin' }, + { value: 'auditor', label: 'Auditor' }, ]} value={formRole} onValueChange={(v) => setFormRole(v as UserRole)} @@ -504,8 +500,8 @@ export function UsersSection() { - {/* Scoped Permissions (paid, editing only) */} - {editingUser && isPaid && ( + {/* Scoped Permissions (editing only) */} + {editingUser && (

Scoped Permissions

diff --git a/frontend/src/components/settings/__tests__/UsersSection.test.tsx b/frontend/src/components/settings/__tests__/UsersSection.test.tsx index 61233f13..20d5e6ff 100644 --- a/frontend/src/components/settings/__tests__/UsersSection.test.tsx +++ b/frontend/src/components/settings/__tests__/UsersSection.test.tsx @@ -12,7 +12,6 @@ vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, })); vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, user: { username: 'admin' } }) })); -vi.mock('@/context/LicenseContext', () => ({ useLicense: () => ({ isPaid: true }) })); vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} })); vi.mock('@/components/CapabilityGate', () => ({ CapabilityGate: ({ children }: { children: React.ReactNode }) => children }));