feat(rbac): add Deployer & Node Admin roles with scoped permissions (Team Pro) (#253)

* feat(rbac): add Deployer & Node Admin roles with scoped permissions (Team Pro)

Add intermediate RBAC roles gated to Team Pro tier:
- Deployer: can deploy/restart/stop/start stacks but cannot edit compose files, delete stacks, or access system settings
- Node Admin: full stack and node management within scope, no system settings access
- Scoped permissions: assign roles per-stack or per-node for fine-grained access control
- Permission engine with checkPermission/requirePermission guards replacing requireAdmin on stack/node routes
- Frontend can() function with /api/permissions/me endpoint for client-side permission checks
- User management UI updated with 4-role selector and scoped permission editor
- Documentation updated with permission matrix, scoped permission docs, and screenshots

* fix(rbac): remove unused RoleAssignment import to fix lint error
This commit is contained in:
Anso
2026-03-29 17:02:56 -04:00
committed by GitHub
parent 37701d5281
commit 8380fbad4b
10 changed files with 636 additions and 88 deletions
+14
View File
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
* **rbac:** introduce Deployer and Node Admin intermediate roles with scoped permissions (Team Pro)
- Deployer role: can deploy, restart, stop, and start stacks but cannot edit compose files, delete stacks, or access system settings
- Node Admin role: full stack and node management but no system settings, user management, or license access
- Scoped permissions: assign roles per stack or per node for fine-grained access control
- Permission matrix engine with `checkPermission()` for consistent backend enforcement
- Frontend `can()` function in AuthContext for consistent UI guards
- New API endpoints: `GET/POST/DELETE /api/users/:id/roles` for managing scoped assignments
- New API endpoint: `GET /api/permissions/me` for fetching effective permissions
- Gated to Team Pro tier — Community and Personal Pro retain binary Admin/Viewer roles
## [0.17.0](https://github.com/AnsoCode/Sencho/compare/v0.16.0...v0.17.0) (2026-03-29)
+248 -36
View File
@@ -18,7 +18,7 @@ import httpProxy from 'http-proxy';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'path';
import { HostTerminalService } from './services/HostTerminalService';
import { DatabaseService, Node, AuthProvider, ScheduledTask } from './services/DatabaseService';
import { DatabaseService, Node, AuthProvider, ScheduledTask, UserRole, ResourceType } from './services/DatabaseService';
import { NotificationService } from './services/NotificationService';
import { MonitorService } from './services/MonitorService';
import { ImageUpdateService } from './services/ImageUpdateService';
@@ -204,7 +204,7 @@ app.use(nodeContextMiddleware);
declare global {
namespace Express {
interface Request {
user?: { username: string; role: 'admin' | 'viewer' };
user?: { username: string; role: UserRole; userId: number };
nodeId: number;
apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin';
}
@@ -254,19 +254,20 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
}
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
const creator = DatabaseService.getInstance().getUserById(apiToken.user_id);
const roleMap: Record<string, 'admin' | 'viewer'> = {
const roleMap: Record<string, UserRole> = {
'read-only': 'viewer',
'deploy-only': 'admin',
'full-admin': 'admin',
};
req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer' };
req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer', userId: apiToken.user_id };
req.apiTokenScope = apiToken.scope as 'read-only' | 'deploy-only' | 'full-admin';
next();
return;
}
// Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens.
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as 'admin' | 'viewer') || 'admin' };
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 };
next();
} catch (err) {
console.error('[Auth] Token validation failed:', (err as Error).message);
@@ -806,6 +807,80 @@ const requireAdmin = (req: Request, res: Response): boolean => {
return true;
};
// --- Scoped RBAC Permission Engine (Team Pro) ---
type PermissionAction =
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
| 'node:read' | 'node:manage'
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
admin: [
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
'node:read', 'node:manage',
'system:settings', 'system:users', 'system:license', 'system:webhooks',
'system:tokens', 'system:console', 'system:audit', 'system:registries',
],
'node-admin': [
'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete',
'node:read', 'node:manage',
],
deployer: [
'stack:read', 'stack:deploy',
],
viewer: [
'stack:read', 'node:read',
],
};
/**
* Core permission resolver. Checks if the current user can perform `action` on an optional resource.
* 1. Admin → always true (backward compat)
* 2. Check global role permissions
* 3. If resource specified AND Team Pro → check scoped role_assignments
*/
function checkPermission(
req: Request,
action: PermissionAction,
resourceType?: ResourceType,
resourceId?: string,
): boolean {
if (!req.user) return false;
const globalRole = req.user.role;
// Admins always have full access
if (globalRole === 'admin') return true;
// Check if the user's global role grants this action
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
// Scoped assignments only apply when a resource is specified and license is Team Pro
if (!resourceType || !resourceId) return false;
if (LicenseService.getInstance().getVariant() !== 'team') return false;
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
for (const assignment of assignments) {
if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true;
}
return false;
}
/** Generic permission guard — sends 403 if denied. */
function requirePermission(
req: Request,
res: Response,
action: PermissionAction,
resourceType?: ResourceType,
resourceId?: string,
): boolean {
if (checkPermission(req, action, resourceType, resourceId)) return true;
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
return false;
}
// Scope enforcement for API tokens - restricts which endpoints a token can reach.
const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
/^\/api\/stacks\/[^/]+\/deploy$/,
@@ -1690,10 +1765,12 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
res.status(400).json({ error: 'Password must be at least 6 characters' });
return;
}
if (role !== 'admin' && role !== 'viewer') {
res.status(400).json({ error: 'Role must be "admin" or "viewer"' });
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' });
return;
}
if ((role === 'deployer' || role === 'node-admin') && !requireTeamPro(req, res)) return;
const db = DatabaseService.getInstance();
const existing = db.getUserByUsername(username);
@@ -1708,7 +1785,7 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Team Pro for unlimited accounts.` });
return;
}
if (role === 'viewer' && seatLimits.maxViewers !== null && db.getViewerCount() >= seatLimits.maxViewers) {
if (role !== 'admin' && seatLimits.maxViewers !== null && db.getNonAdminCount() >= seatLimits.maxViewers) {
res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxViewers} viewer account${seatLimits.maxViewers === 1 ? '' : 's'}. Upgrade to Team Pro for unlimited accounts.` });
return;
}
@@ -1755,17 +1832,19 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P
}
if (role !== undefined) {
if (role !== 'admin' && role !== 'viewer') {
res.status(400).json({ error: 'Role must be "admin" or "viewer"' });
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' });
return;
}
if ((role === 'deployer' || role === 'node-admin') && !requireTeamPro(req, res)) return;
// Prevent demoting yourself
if (user.username === req.user!.username && role !== user.role) {
res.status(400).json({ error: 'Cannot change your own role' });
return;
}
// Prevent removing the last admin
if (user.role === 'admin' && role === 'viewer' && db.getAdminCount() <= 1) {
if (user.role === 'admin' && role !== 'admin' && db.getAdminCount() <= 1) {
res.status(400).json({ error: 'Cannot demote the only admin user' });
return;
}
@@ -1823,6 +1902,137 @@ app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response)
}
});
// --- Scoped Role Assignments (Team Pro) ---
app.get('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
const db = DatabaseService.getInstance();
if (!db.getUser(userId)) {
res.status(404).json({ error: 'User not found' });
return;
}
const assignments = db.getAllRoleAssignments(userId);
res.json(assignments);
} catch (error) {
console.error('[Roles] List error:', error);
res.status(500).json({ error: 'Failed to fetch role assignments' });
}
});
app.post('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
const { role, resource_type, resource_id } = req.body;
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
if (!validRoles.includes(role)) {
res.status(400).json({ error: 'Invalid role' });
return;
}
const validResourceTypes: ResourceType[] = ['stack', 'node'];
if (!validResourceTypes.includes(resource_type)) {
res.status(400).json({ error: 'Invalid resource type' });
return;
}
if (!resource_id || typeof resource_id !== 'string') {
res.status(400).json({ error: 'resource_id is required' });
return;
}
const db = DatabaseService.getInstance();
if (!db.getUser(userId)) {
res.status(404).json({ error: 'User not found' });
return;
}
try {
const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id });
res.status(201).json({ id, user_id: userId, role, resource_type, resource_id });
} catch (err: unknown) {
if ((err as Error).message?.includes('UNIQUE constraint')) {
res.status(409).json({ error: 'This role assignment already exists' });
return;
}
throw err;
}
} catch (error) {
console.error('[Roles] Create error:', error);
res.status(500).json({ error: 'Failed to add role assignment' });
}
});
app.delete('/api/users/:id/roles/:assignId', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const userId = parseInt(req.params.id as string, 10);
const assignId = parseInt(req.params.assignId as string, 10);
const db = DatabaseService.getInstance();
const assignment = db.getRoleAssignmentById(assignId);
if (!assignment || assignment.user_id !== userId) {
res.status(404).json({ error: 'Role assignment not found' });
return;
}
db.deleteRoleAssignment(assignId);
res.json({ success: true });
} catch (error) {
console.error('[Roles] Delete error:', error);
res.status(500).json({ error: 'Failed to delete role assignment' });
}
});
// Return the current user's effective permissions (any authenticated user)
app.get('/api/permissions/me', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
const db = DatabaseService.getInstance();
const globalRole = req.user.role;
const globalPermissions = ROLE_PERMISSIONS[globalRole] || [];
const assignments = db.getAllRoleAssignments(req.user.userId);
const scopedPermissions: Record<string, PermissionAction[]> = {};
for (const a of assignments) {
const key = `${a.resource_type}:${a.resource_id}`;
const perms = ROLE_PERMISSIONS[a.role] || [];
const existing = scopedPermissions[key] || [];
scopedPermissions[key] = [...new Set([...existing, ...perms])];
}
res.json({
globalRole,
globalPermissions,
scopedPermissions,
isTeamPro: LicenseService.getInstance().getVariant() === 'team',
});
} catch (error) {
console.error('[Permissions] Error:', error);
res.status(500).json({ error: 'Failed to fetch permissions' });
}
});
// Remote Node HTTP Proxy - single global instance.
// Previously, createProxyMiddleware was called inside the request handler on every API
// call, spawning a new proxy instance (and http-proxy server) each time. This caused:
@@ -2231,9 +2441,9 @@ app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
});
app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
const stackName = req.params.stackName as string;
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
return res.status(400).json({ error: 'Invalid stack name' });
}
@@ -2377,9 +2587,9 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
});
app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
try {
const stackName = req.params.stackName as string;
if (stackName.includes('..') || stackName.includes('/') || stackName.includes('\\')) {
return res.status(400).json({ error: 'Invalid stack name' });
}
@@ -2411,7 +2621,7 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
});
app.post('/api/stacks', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'stack:create')) return;
try {
const { stackName } = req.body;
if (!stackName || typeof stackName !== 'string') {
@@ -2432,8 +2642,8 @@ app.post('/api/stacks', async (req: Request, res: Response) => {
});
app.delete('/api/stacks/:name', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.name as string;
if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return;
try {
// Stage 1: Tell Docker to clean up ghost networks/containers
try {
@@ -2511,9 +2721,9 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
// End of legacy container routes
app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
try {
const stackName = req.params.stackName as string;
const atomic = LicenseService.getInstance().getTier() === 'pro';
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
res.json({ message: 'Deployed successfully' });
@@ -2525,9 +2735,9 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) =>
});
app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
try {
const stackName = req.params.stackName as string;
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined);
res.json({ status: 'Command started' });
} catch (error) {
@@ -2536,9 +2746,9 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
});
app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
@@ -2555,9 +2765,9 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) =
});
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
@@ -2574,9 +2784,9 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
});
app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
try {
const stackName = req.params.stackName as string;
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getContainersByStack(stackName);
@@ -2594,9 +2804,9 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) =>
// Update stack: pull images and recreate containers
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
try {
const stackName = req.params.stackName as string;
const atomic = LicenseService.getInstance().getTier() === 'pro';
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
res.json({ status: 'Update completed' });
@@ -2608,10 +2818,10 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
// Manual rollback endpoint (Pro + Admin)
app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => {
if (!requireAdmin(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
if (!requirePro(req, res)) return;
try {
const stackName = req.params.stackName as string;
const fsSvc = FileSystemService.getInstance(req.nodeId);
const backupInfo = await fsSvc.getBackupInfo(stackName);
if (!backupInfo.exists) {
@@ -4098,7 +4308,7 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requirePermission(req, res, 'node:manage')) return;
try {
const { name, type, compose_dir, is_default, api_url, api_token } = req.body;
@@ -4143,9 +4353,10 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
const nodeId = req.params.id as string;
if (!requirePermission(req, res, 'node:manage', 'node', nodeId)) return;
try {
const id = parseInt(req.params.id as string);
const id = parseInt(nodeId);
const updates = req.body;
if (updates.api_url !== undefined && updates.api_url !== '') {
@@ -4173,9 +4384,10 @@ app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
const nodeIdParam = req.params.id as string;
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
try {
const id = parseInt(req.params.id as string);
const id = parseInt(nodeIdParam);
DatabaseService.getInstance().deleteNode(id);
NodeRegistry.getInstance().evictConnection(id);
res.json({ success: true });
+72 -2
View File
@@ -62,11 +62,14 @@ export interface WebhookExecution {
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin';
export type ResourceType = 'stack' | 'node';
export interface User {
id: number;
username: string;
password_hash: string;
role: 'admin' | 'viewer';
role: UserRole;
auth_provider: AuthProvider;
provider_id: string | null;
email: string | null;
@@ -74,6 +77,15 @@ export interface User {
updated_at: number;
}
export interface RoleAssignment {
id: number;
user_id: number;
role: UserRole;
resource_type: ResourceType;
resource_id: string;
created_at: number;
}
export interface SSOConfig {
id: number;
provider: string;
@@ -201,6 +213,7 @@ export class DatabaseService {
this.migrateEncryptNodeTokens();
this.migrateSSOColumns();
this.migrateRegistries();
this.migrateRoleAssignments();
}
public static getInstance(): DatabaseService {
@@ -529,6 +542,25 @@ export class DatabaseService {
`);
}
private migrateRoleAssignments(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS role_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
`);
try {
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)');
} catch { /* already exists */ }
}
// --- Agents ---
public getAgents(): Agent[] {
@@ -902,7 +934,7 @@ export class DatabaseService {
return this.db.prepare('SELECT * FROM users WHERE auth_provider = ? AND provider_id = ?').get(authProvider, providerId) as User | undefined;
}
public addUser(user: { username: string; password_hash: string; role: 'admin' | 'viewer'; auth_provider?: AuthProvider; provider_id?: string | null; email?: string | null }): number {
public addUser(user: { username: string; password_hash: string; role: UserRole; auth_provider?: AuthProvider; provider_id?: string | null; email?: string | null }): number {
const now = Date.now();
const result = this.db.prepare(
'INSERT INTO users (username, password_hash, role, auth_provider, provider_id, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
@@ -943,6 +975,44 @@ export class DatabaseService {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'viewer'").get() as { count: number })?.count || 0;
}
public getNonAdminCount(): number {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role != 'admin'").get() as { count: number })?.count || 0;
}
// --- Role Assignments ---
public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] {
return this.db.prepare(
'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ?'
).all(userId, resourceType, resourceId) as RoleAssignment[];
}
public getAllRoleAssignments(userId: number): RoleAssignment[] {
return this.db.prepare(
'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id'
).all(userId) as RoleAssignment[];
}
public addRoleAssignment(assignment: { user_id: number; role: UserRole; resource_type: ResourceType; resource_id: string }): number {
const now = Date.now();
const result = this.db.prepare(
'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, created_at) VALUES (?, ?, ?, ?, ?)'
).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, now);
return result.lastInsertRowid as number;
}
public getRoleAssignmentById(id: number): RoleAssignment | undefined {
return this.db.prepare('SELECT * FROM role_assignments WHERE id = ?').get(id) as RoleAssignment | undefined;
}
public deleteRoleAssignment(id: number): void {
this.db.prepare('DELETE FROM role_assignments WHERE id = ?').run(id);
}
public deleteRoleAssignmentsByUser(userId: number): void {
this.db.prepare('DELETE FROM role_assignments WHERE user_id = ?').run(userId);
}
// --- SSO Config ---
public getSSOConfigs(): SSOConfig[] {
+64 -33
View File
@@ -1,70 +1,101 @@
---
title: RBAC & User Management
description: Role-based access control for Sencho Pro - create admin and viewer accounts to control who can modify your stacks.
description: Role-based access control for Sencho - manage admin, viewer, deployer, and node admin accounts with scoped permissions.
---
<Note>
RBAC requires a Sencho Pro license. Community Edition supports a single admin account only.
Multi-user support requires a Sencho Pro license. Community Edition supports a single admin account only. Intermediate roles (Deployer, Node Admin) and scoped permissions require **Team Pro**.
</Note>
Sencho Pro introduces role-based access control with two distinct roles: **Admin** and **Viewer**. This lets you give team members read-only access to your infrastructure without risking accidental changes.
Sencho supports role-based access control with four distinct roles. The classic **Admin** and **Viewer** roles are available on all Pro tiers, while **Deployer** and **Node Admin** are exclusive to Team Pro and support scoped permissions per stack or node.
## Roles
| Role | Description |
|------|-------------|
| **Admin** | Full access to all features - deploy, edit, manage users, configure nodes, and more |
| **Viewer** | Read-only access to dashboards, logs, stats, and file contents |
| Role | Description | Tier |
|------|-------------|------|
| **Admin** | Full access to all features deploy, edit, manage users, configure nodes, and more | Pro |
| **Viewer** | Read-only access to dashboards, logs, stats, and file contents | Pro |
| **Deployer** | Can deploy, restart, stop, and start stacks — but cannot edit compose files, delete stacks, or access system settings | Team Pro |
| **Node Admin** | Full stack and node management within their scope — cannot access system settings, users, or license management | Team Pro |
### What viewers can see
### Permission matrix
- Dashboard and home metrics
- Stacks list and stack detail view
- Compose and `.env` file contents (read-only)
- Per-container stats and logs
- Fleet view
- Resources hub (images, volumes, networks - read-only)
- Global logs
- Notifications
| Action | Admin | Node Admin | Deployer | Viewer |
|--------|-------|------------|----------|--------|
| View stacks, logs, stats | ✅ | ✅ | ✅ | ✅ |
| Deploy / restart / stop / start stacks | ✅ | ✅ | ✅ | ❌ |
| Edit compose and `.env` files | ✅ | ✅ | ❌ | ❌ |
| Create and delete stacks | ✅ | ✅ | ❌ | ❌ |
| View nodes | ✅ | ✅ | ✅ | ✅ |
| Add / edit / delete nodes | ✅ | ✅ | ❌ | ❌ |
| System settings | ✅ | ❌ | ❌ | ❌ |
| User management | ✅ | ❌ | ❌ | ❌ |
| License management | ✅ | ❌ | ❌ | ❌ |
| Webhooks | ✅ | ❌ | ❌ | ❌ |
| API tokens | ✅ | ❌ | ❌ | ❌ |
| Host console | ✅ | ❌ | ❌ | ❌ |
| Audit log | ✅ | ❌ | ❌ | ❌ |
### What viewers cannot do
## Scoped permissions
- Edit compose or environment files
- Deploy, restart, stop, or start stacks
- Create or delete stacks
- Manage users, nodes, webhooks, or alerts
- Access the host console
- Prune resources
- Change settings
<Note>
Scoped permissions require a **Team Pro** license.
</Note>
Roles can be scoped to specific stacks or nodes. This means you can grant a user the **Deployer** role globally, but also give them **Node Admin** access on a specific node — or limit a viewer to deploy access on only certain stacks.
Scoped permissions **add to** the user's global role. They never reduce it. A user with a global Viewer role plus a scoped Deployer assignment on "my-app" stack can deploy "my-app" but has read-only access to everything else.
### Example scenarios
- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack can deploy, restart, and stop only that stack.
- A **Deployer** with a scoped **Node Admin** assignment on node "staging-server" can manage stacks and nodes on that server, plus deploy globally.
- A **Node Admin** without any scoped assignments can manage all stacks and nodes but has no access to system settings.
## Managing users
<Frame>
<img src="/images/rbac/users-settings.png" alt="Users management panel showing user list with roles" />
<img src="/images/rbac/role-selector.png" alt="User Management with role-based access control" />
</Frame>
Admins can manage accounts in **Settings → Users**. From there you can:
- **Create** a new user with a username, password, and role (Admin or Viewer)
- **Create** a new user with a username, password, and role
- **Edit** an existing user's password or role
- **Delete** a user account
When creating or editing a user on Team Pro, you'll see all four role options in the role selector. On Personal Pro, only Admin and Viewer are available.
<Frame>
<img src="/images/rbac/role-selector-dropdown.png" alt="Role selector showing all four roles on Team Pro" />
</Frame>
## Managing scoped permissions
When editing a user on Team Pro, a **Scoped Permissions** section appears below the user form. Here you can:
1. **View** the user's current scoped assignments
2. **Add** a new scope by selecting a role, resource type (stack or node), and specific resource
3. **Remove** an existing scope
Each scoped assignment grants the specified role's permissions on the specified resource only.
## SSO auto-provisioning
With a Team Pro license, users can also be created automatically when they log in via SSO (LDAP, Google, GitHub, or Okta). SSO users appear in the Users list alongside local accounts. They are assigned a role based on identity provider group membership or claim mapping.
SSO users cannot log in with a password - they must always authenticate through their identity provider.
SSO users cannot log in with a password they must always authenticate through their identity provider. After SSO provisioning, an admin can add scoped permissions to SSO users just like local accounts.
To set up identity provider authentication, see [SSO Authentication →](/features/sso).
## Migration from single-admin setup
When you upgrade to Sencho Pro, your existing single-admin credentials are automatically migrated to the new users table. No manual action is required - your login continues to work as before, and your account is assigned the Admin role.
When you upgrade to Sencho Pro, your existing single-admin credentials are automatically migrated to the new users table. No manual action is required your login continues to work as before, and your account is assigned the Admin role.
## License tiers
| Tier | Admin accounts | Viewer accounts |
|------|---------------|-----------------|
| **Community** | 1 | 0 |
| **Personal Pro** | 1 | 3 |
| **Team Pro** | Unlimited | Unlimited |
| Tier | Admin accounts | Non-admin accounts | Intermediate roles | Scoped permissions |
|------|---------------|-------------------|-------------------|-------------------|
| **Community** | 1 | 0 | ❌ | ❌ |
| **Personal Pro** | 1 | 3 | ❌ | ❌ |
| **Team Pro** | Unlimited | Unlimited | ✅ | ✅ |
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+3 -3
View File
@@ -45,7 +45,7 @@ interface AppStoreViewProps {
}
export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
const { isAdmin } = useAuth();
const { can } = useAuth();
const { activeNode } = useNodes();
const [templates, setTemplates] = useState<Template[]>([]);
const [searchQuery, setSearchQuery] = useState('');
@@ -473,10 +473,10 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
<div className="flex flex-col w-full gap-2">
<Button
onClick={handleDeploy}
disabled={isDeploying || !stackName.trim() || !isAdmin}
disabled={isDeploying || !stackName.trim() || !can('stack:create')}
className="w-full"
size="lg"
title={!isAdmin ? 'Admin access required to deploy' : undefined}
title={!can('stack:create') ? 'Permission required to deploy' : undefined}
>
{isDeploying ? (
<>
+7 -7
View File
@@ -78,7 +78,7 @@ const formatBytes = (bytes: number) => {
};
export default function EditorLayout() {
const { isAdmin } = useAuth();
const { isAdmin, can } = useAuth();
const { isPro, license } = useLicense();
const { nodes, activeNode, setActiveNode } = useNodes();
// Stable ref so notification callbacks always read the latest nodes list
@@ -1128,7 +1128,7 @@ export default function EditorLayout() {
)}
{/* Create Stack Button */}
{isAdmin && <div className="p-4">
{can('stack:create') && <div className="p-4">
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full rounded-lg">
@@ -1234,7 +1234,7 @@ export default function EditorLayout() {
<Download className="h-4 w-4 mr-2" />
Update
</DropdownMenuItem>
{isAdmin && (
{can('stack:delete', 'stack', file.replace(/\.(yml|yaml)$/, '')) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -1282,7 +1282,7 @@ export default function EditorLayout() {
<Download className="h-4 w-4 mr-2" />
Update
</ContextMenuItem>
{isAdmin && (
{can('stack:delete', 'stack', file.replace(/\.(yml|yaml)$/, '')) && (
<>
<ContextMenuSeparator />
<ContextMenuItem
@@ -1490,7 +1490,7 @@ export default function EditorLayout() {
{/* Stack Name */}
<CardTitle className="text-2xl font-bold">{stackName}</CardTitle>
{/* Action Bar */}
{isAdmin && (
{can('stack:deploy', 'stack', stackName) && (
<div className="flex items-center gap-2 flex-wrap">
{isRunning ? (
<>
@@ -1712,7 +1712,7 @@ export default function EditorLayout() {
</Select>
)}
</div>
{isAdmin && (
{can('stack:edit', 'stack', stackName) && (
<div className="flex gap-2">
{!isEditing ? (
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
@@ -1767,7 +1767,7 @@ export default function EditorLayout() {
fontSize: 14,
padding: { top: 10 },
scrollBeyondLastLine: false,
readOnly: !isEditing || !isAdmin,
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
}}
/>
)}
+174 -5
View File
@@ -23,7 +23,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from './TierBadge';
import { ProGate } from './ProGate';
@@ -380,12 +380,22 @@ function WebhooksSection({ isPro }: { isPro: boolean }) {
interface UserItem {
id: number;
username: string;
role: 'admin' | 'viewer';
role: UserRole;
created_at: number;
}
interface RoleAssignmentItem {
id: number;
user_id: number;
role: UserRole;
resource_type: 'stack' | 'node';
resource_id: string;
created_at: number;
}
function UsersSection() {
const { user: currentUser } = useAuth();
const { isPro, license } = useLicense();
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
@@ -396,7 +406,7 @@ function UsersSection() {
const [formUsername, setFormUsername] = useState('');
const [formPassword, setFormPassword] = useState('');
const [formConfirmPassword, setFormConfirmPassword] = useState('');
const [formRole, setFormRole] = useState<'admin' | 'viewer'>('viewer');
const [formRole, setFormRole] = useState<UserRole>('viewer');
const fetchUsers = async () => {
try {
@@ -501,6 +511,82 @@ function UsersSection() {
setFormPassword('');
setFormConfirmPassword('');
setShowForm(true);
fetchRoleAssignments(u.id);
fetchScopeResources();
};
// --- Scoped Role Assignments ---
const [roleAssignments, setRoleAssignments] = useState<RoleAssignmentItem[]>([]);
const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack');
const [scopeResourceId, setScopeResourceId] = useState('');
const [scopeRole, setScopeRole] = useState<UserRole>('deployer');
const [availableStacks, setAvailableStacks] = useState<string[]>([]);
const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]);
const [addingScope, setAddingScope] = useState(false);
const fetchRoleAssignments = async (userId: number) => {
try {
const res = await apiFetch(`/users/${userId}/roles`, { localOnly: true });
if (res.ok) setRoleAssignments(await res.json());
else setRoleAssignments([]);
} catch { setRoleAssignments([]); }
};
const fetchScopeResources = async () => {
try {
const [stacksRes, nodesRes] = await Promise.all([
apiFetch('/stacks', { localOnly: true }),
apiFetch('/nodes', { localOnly: true }),
]);
if (stacksRes.ok) {
const data = await stacksRes.json();
setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []);
}
if (nodesRes.ok) {
const data = await nodesRes.json();
setAvailableNodes(Array.isArray(data) ? data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })) : []);
}
} catch { /* ignore */ }
};
const addRoleAssignment = async () => {
if (!editingUser || !scopeResourceId) return;
setAddingScope(true);
try {
const res = await apiFetch(`/users/${editingUser.id}/roles`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }),
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to add scope.');
return;
}
toast.success('Scope added.');
setScopeResourceId('');
fetchRoleAssignments(editingUser.id);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
} finally { setAddingScope(false); }
};
const removeRoleAssignment = async (assignId: number) => {
if (!editingUser) return;
try {
const res = await apiFetch(`/users/${editingUser.id}/roles/${assignId}`, { method: 'DELETE', localOnly: true });
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to remove scope.');
return;
}
toast.success('Scope removed.');
fetchRoleAssignments(editingUser.id);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
return (
@@ -533,13 +619,19 @@ function UsersSection() {
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={formRole} onValueChange={(v) => setFormRole(v as 'admin' | 'viewer')}>
<Select value={formRole} onValueChange={(v) => setFormRole(v as UserRole)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
{isPro && license?.variant === 'team' && (
<>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
@@ -570,6 +662,83 @@ function UsersSection() {
{saving ? <><RefreshCw className="w-4 h-4 mr-1 animate-spin" />Saving...</> : (editingUser ? 'Update User' : 'Create User')}
</Button>
</div>
{/* Scoped Permissions (Team Pro, editing only) */}
{editingUser && isPro && license?.variant === 'team' && (
<div className="border rounded-lg p-4 space-y-3 mt-4">
<h4 className="text-sm font-medium">Scoped Permissions</h4>
<p className="text-xs text-muted-foreground">
Grant additional permissions on specific stacks or nodes. These supplement the user's global role.
</p>
{roleAssignments.length > 0 && (
<div className="space-y-1">
{roleAssignments.map((a) => (
<div key={a.id} className="flex items-center justify-between text-sm bg-muted/50 rounded px-3 py-1.5">
<span>
<Badge variant="outline" className="text-xs mr-2 capitalize">{a.role}</Badge>
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" />
</Button>
</div>
))}
</div>
)}
<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>
</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>
</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>
</div>
<Button size="sm" className="h-8" onClick={addRoleAssignment} disabled={addingScope || !scopeResourceId}>
<Plus className="w-3 h-3 mr-1" />
Add
</Button>
</div>
</div>
)}
</div>
)}
@@ -602,7 +771,7 @@ function UsersSection() {
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</td>
<td className="px-4 py-2.5">
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'} className="text-xs capitalize">
<Badge variant={u.role === 'admin' ? 'default' : u.role === 'viewer' ? 'secondary' : 'outline'} className="text-xs capitalize">
{u.role}
</Badge>
</td>
+54 -2
View File
@@ -1,10 +1,25 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'authenticated';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin';
export type PermissionAction =
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
| 'node:read' | 'node:manage'
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
interface UserInfo {
username: string;
role: 'admin' | 'viewer';
role: UserRole;
}
interface PermissionsData {
globalRole: UserRole;
globalPermissions: PermissionAction[];
scopedPermissions: Record<string, PermissionAction[]>;
isTeamPro: boolean;
}
interface AuthContextType {
@@ -13,6 +28,8 @@ interface AuthContextType {
needsSetup: boolean;
user: UserInfo | null;
isAdmin: boolean;
permissions: PermissionsData | null;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
logout: () => Promise<void>;
@@ -25,6 +42,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [appStatus, setAppStatus] = useState<AppStatus>('loading');
const [user, setUser] = useState<UserInfo | null>(null);
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
const checkAuth = async () => {
try {
@@ -37,6 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (statusData.needsSetup) {
setAppStatus('needsSetup');
setUser(null);
setPermissions(null);
return;
}
@@ -49,12 +68,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await authResponse.json();
setUser(data.user ?? null);
setAppStatus('authenticated');
// Fetch effective permissions
try {
const permsRes = await fetch('/api/permissions/me', { credentials: 'include' });
if (permsRes.ok) {
setPermissions(await permsRes.json());
}
} catch {
// Permissions fetch is non-critical — fallback to global role only
}
} else {
setUser(null);
setPermissions(null);
setAppStatus('notAuthenticated');
}
} catch {
setUser(null);
setPermissions(null);
setAppStatus('notAuthenticated');
}
};
@@ -66,6 +97,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized);
}, []);
const can = useCallback((action: PermissionAction, resourceType?: string, resourceId?: string): boolean => {
if (!permissions) return false;
// Admins always have full access
if (permissions.globalRole === 'admin') return true;
// Check global role permissions
if (permissions.globalPermissions.includes(action)) return true;
// Check scoped permissions
if (resourceType && resourceId) {
const key = `${resourceType}:${resourceId}`;
return permissions.scopedPermissions[key]?.includes(action) ?? false;
}
return false;
}, [permissions]);
const login = async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
try {
const response = await fetch('/api/auth/login', {
@@ -125,6 +174,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error('Logout error:', error);
} finally {
setUser(null);
setPermissions(null);
setAppStatus('notAuthenticated');
}
};
@@ -141,6 +191,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
needsSetup: appStatus === 'needsSetup',
user,
isAdmin: user?.role === 'admin',
permissions,
can,
login,
ssoLdapLogin,
logout,