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
+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,