mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
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:
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user