mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
feat(rbac): make stack-scoped grants node-specific (#1727)
* feat(rbac): make stack-scoped grants node-specific Qualify stack role assignments as (nodeId, stackName), migrate legacy rows to the default node, and forward bound multi-action evidence on Proxy/Pilot hops so scoped users keep least-privilege remote access without shipping the full grant table. * fix: mirror scoped-stack-auth-evidence capability to frontend, sanitize node id in role assignment log Backend added the scoped-stack-auth-evidence capability without the matching frontend entry, failing the capability parity test. The role assignment log also interpolated the node id without sanitizeForLog, unlike the rest of the line. * fix(rbac): honor node-wide scopes and fix proxied DELETE cleanup Node-scoped grants now authorize that role's stack actions on the same node in the backend resolver, frontend can(), and remote evidence. Proxied DELETE cleanup uses the gate-stashed route because pathRewrite mutates req.path before proxyRes. Add proxy integration coverage and drop the stale scoped-permissions screenshot. * fix(rbac): preserve node-qualified grants during repair
This commit is contained in:
@@ -296,7 +296,7 @@ export default function EditorLayout() {
|
||||
hasServiceScopedUpdate: hasCapability('service-scoped-update'),
|
||||
canEditStack: (stackNameOrFilename) => {
|
||||
const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, '');
|
||||
return can('stack:edit', 'stack', stackName);
|
||||
return can('stack:edit', 'stack', stackName, activeNode?.id);
|
||||
},
|
||||
canOfferVolumeRemoval,
|
||||
onDeletedOpenStack: () => onDeletedOpenStackRef.current(),
|
||||
@@ -1059,6 +1059,7 @@ export default function EditorLayout() {
|
||||
can={can}
|
||||
selectedFile={selectedFile}
|
||||
stackName={stackName}
|
||||
activeNodeId={activeNode?.id ?? null}
|
||||
gitSourceOpen={gitSourceOpen}
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
canSelfUpdate={hasCapability('self-update')}
|
||||
|
||||
@@ -311,7 +311,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
hasUnsavedChanges,
|
||||
} = props;
|
||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
const canEditCompose = can('stack:edit', 'stack', stackName);
|
||||
const canEditCompose = can('stack:edit', 'stack', stackName, activeNode?.id);
|
||||
|
||||
// Dispose the underlying Monaco model when EditorView unmounts. The
|
||||
// @monaco-editor/react wrapper reuses a single model per editor instance
|
||||
@@ -358,7 +358,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
const safeContent = content || '';
|
||||
const safeEnvContent = envContent || '';
|
||||
const isRunning = safeContainers.some(c => c.State === 'running');
|
||||
const canRead = can('stack:read', 'stack', stackName);
|
||||
const canRead = can('stack:read', 'stack', stackName, activeNode?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'files' && !canRead) {
|
||||
@@ -469,7 +469,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
result={recoveryResult}
|
||||
activeNode={activeNode}
|
||||
backupInfo={backupInfo}
|
||||
canDeploy={can('stack:deploy', 'stack', stackName)}
|
||||
canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)}
|
||||
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
|
||||
onRestart={restartStack}
|
||||
onRollback={rollbackStack}
|
||||
@@ -663,7 +663,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{activeTab === 'files' && canRead ? (
|
||||
<StackFileExplorer
|
||||
stackName={stackName}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
canEdit={can('stack:edit', 'stack', stackName, activeNode?.id)}
|
||||
isDarkMode={isDarkMode}
|
||||
onNavigateToCompose={() => setActiveTab('compose')}
|
||||
onNavigateToEnv={() => setActiveTab('env')}
|
||||
@@ -732,7 +732,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
onOpenGitSource={() => setGitSourceOpen(true)}
|
||||
onApplyUpdate={() => { void updateStack(); }}
|
||||
applying={loadingAction === 'update'}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
canEdit={can('stack:edit', 'stack', stackName, activeNode?.id)}
|
||||
notifications={notifications}
|
||||
requestedTab={props.requestedAnatomyTab}
|
||||
/>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
const safeContainers = containers || [];
|
||||
const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1;
|
||||
const isRunning = safeContainers.some(c => c.State === 'running');
|
||||
const canEditStack = can('stack:edit', 'stack', stackName);
|
||||
const canEditStack = can('stack:edit', 'stack', stackName, activeNode?.id);
|
||||
|
||||
// The writable editor layer renders only for an editor; a stale editingCompose
|
||||
// while the user lacks stack:edit falls back to the read-only Compose segment.
|
||||
@@ -182,7 +182,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
result={recoveryResult}
|
||||
activeNode={activeNode}
|
||||
backupInfo={backupInfo}
|
||||
canDeploy={can('stack:deploy', 'stack', stackName)}
|
||||
canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)}
|
||||
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
|
||||
onRestart={restartStack}
|
||||
onRollback={rollbackStack}
|
||||
|
||||
@@ -25,9 +25,10 @@ interface ShellOverlaysProps {
|
||||
stackActions: StackActionsHook;
|
||||
isDarkMode: boolean;
|
||||
isAdmin: boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
||||
selectedFile: string | null;
|
||||
stackName: string;
|
||||
activeNodeId: number | null;
|
||||
gitSourceOpen: boolean;
|
||||
setGitSourceOpen: (open: boolean) => void;
|
||||
canSelfUpdate: boolean;
|
||||
@@ -45,6 +46,7 @@ export function ShellOverlays({
|
||||
can,
|
||||
selectedFile,
|
||||
stackName,
|
||||
activeNodeId,
|
||||
gitSourceOpen,
|
||||
setGitSourceOpen,
|
||||
canSelfUpdate,
|
||||
@@ -210,7 +212,7 @@ export function ShellOverlays({
|
||||
open={gitSourceOpen}
|
||||
onOpenChange={setGitSourceOpen}
|
||||
stackName={stackName}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
canEdit={can('stack:edit', 'stack', stackName, activeNodeId)}
|
||||
isDarkMode={isDarkMode}
|
||||
onSourceChanged={stackActions.refreshGitSourcePending}
|
||||
/>
|
||||
|
||||
@@ -203,8 +203,8 @@ export function StackIdentityHeader({
|
||||
backend permissions so a delete-only or deploy-only persona sees
|
||||
exactly what they can act on. */}
|
||||
{(() => {
|
||||
const canDeploy = can('stack:deploy', 'stack', stackName);
|
||||
const canDelete = can('stack:delete', 'stack', stackName);
|
||||
const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id);
|
||||
const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id);
|
||||
const canRollback = canDeploy && backupInfo.exists;
|
||||
const canScan = trivy.available && isAdmin;
|
||||
const canMute = stackMuteActions?.canMute ?? false;
|
||||
|
||||
@@ -31,7 +31,7 @@ interface UseSidebarContextMenuOptions {
|
||||
stackActions: StackActionsHook;
|
||||
activeNode: Node | null | undefined;
|
||||
isAdmin: boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
||||
}
|
||||
|
||||
export function useSidebarContextMenu({
|
||||
@@ -61,9 +61,9 @@ export function useSidebarContextMenu({
|
||||
canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null,
|
||||
isBusy: stackListState.isStackBusy(file),
|
||||
isAdmin,
|
||||
canDelete: can('stack:delete', 'stack', sName),
|
||||
canDeploy: can('stack:deploy', 'stack', sName),
|
||||
canEditLabels: can('stack:edit', 'stack', sName),
|
||||
canDelete: can('stack:delete', 'stack', sName, nodeId),
|
||||
canDeploy: can('stack:deploy', 'stack', sName, nodeId),
|
||||
canEditLabels: can('stack:edit', 'stack', sName, nodeId),
|
||||
// POST /api/labels (the inline "New label" entry) is guarded by the
|
||||
// unscoped requirePermission('stack:edit'); a user with only per-stack
|
||||
// scoped edit can toggle existing labels but cannot create new ones.
|
||||
|
||||
@@ -342,7 +342,7 @@ export function NetworkingView({ headerActions }: NetworkingViewProps) {
|
||||
<TableBody>
|
||||
{topFindings.map((finding) => {
|
||||
const primary = finding.recommendedActions.find((action) =>
|
||||
isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack)),
|
||||
isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack, nodeId)),
|
||||
);
|
||||
return (
|
||||
<TableRow key={finding.id}>
|
||||
|
||||
@@ -36,6 +36,7 @@ interface RoleAssignmentItem {
|
||||
role: UserRole;
|
||||
resource_type: 'stack' | 'node';
|
||||
resource_id: string;
|
||||
node_id: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
@@ -209,6 +210,11 @@ export function UsersSection() {
|
||||
setFormRole('viewer');
|
||||
setEditingUser(null);
|
||||
setShowForm(false);
|
||||
setRoleAssignments([]);
|
||||
setScopeResourceType('stack');
|
||||
setScopeNodeId('');
|
||||
setScopeResourceId('');
|
||||
setAvailableStacks([]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -313,16 +319,18 @@ export function UsersSection() {
|
||||
setFormConfirmPassword('');
|
||||
setShowForm(true);
|
||||
fetchRoleAssignments(u.id);
|
||||
fetchScopeResources();
|
||||
void fetchAvailableNodes();
|
||||
};
|
||||
|
||||
// --- Scoped Role Assignments ---
|
||||
const [roleAssignments, setRoleAssignments] = useState<RoleAssignmentItem[]>([]);
|
||||
const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack');
|
||||
const [scopeNodeId, setScopeNodeId] = useState<string>('');
|
||||
const [scopeResourceId, setScopeResourceId] = useState('');
|
||||
const [scopeRole, setScopeRole] = useState<UserRole>('deployer');
|
||||
const [availableStacks, setAvailableStacks] = useState<string[]>([]);
|
||||
const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]);
|
||||
const [loadingStacks, setLoadingStacks] = useState(false);
|
||||
const [addingScope, setAddingScope] = useState(false);
|
||||
|
||||
const fetchRoleAssignments = async (userId: number) => {
|
||||
@@ -333,16 +341,9 @@ export function UsersSection() {
|
||||
} catch { setRoleAssignments([]); }
|
||||
};
|
||||
|
||||
const fetchScopeResources = async () => {
|
||||
const fetchAvailableNodes = 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') : []);
|
||||
}
|
||||
const nodesRes = await apiFetch('/nodes', { localOnly: true });
|
||||
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 })) : []);
|
||||
@@ -350,14 +351,51 @@ export function UsersSection() {
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const fetchStacksForNode = async (nodeIdStr: string) => {
|
||||
if (!nodeIdStr) {
|
||||
setAvailableStacks([]);
|
||||
return;
|
||||
}
|
||||
const nodeId = parseInt(nodeIdStr, 10);
|
||||
if (!Number.isInteger(nodeId)) {
|
||||
setAvailableStacks([]);
|
||||
return;
|
||||
}
|
||||
setLoadingStacks(true);
|
||||
try {
|
||||
const stacksRes = await apiFetch('/stacks', { nodeId });
|
||||
if (stacksRes.ok) {
|
||||
const data = await stacksRes.json();
|
||||
setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []);
|
||||
} else {
|
||||
setAvailableStacks([]);
|
||||
toast.error('Failed to load stacks for the selected node.');
|
||||
}
|
||||
} catch {
|
||||
setAvailableStacks([]);
|
||||
toast.error('Failed to load stacks for the selected node.');
|
||||
} finally {
|
||||
setLoadingStacks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addRoleAssignment = async () => {
|
||||
if (!editingUser || !scopeResourceId) return;
|
||||
if (scopeResourceType === 'stack' && !scopeNodeId) return;
|
||||
setAddingScope(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
role: scopeRole,
|
||||
resource_type: scopeResourceType,
|
||||
resource_id: scopeResourceId,
|
||||
};
|
||||
if (scopeResourceType === 'stack') {
|
||||
body.node_id = parseInt(scopeNodeId, 10);
|
||||
}
|
||||
const res = await apiFetch(`/users/${editingUser.id}/roles`, {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
@@ -476,21 +514,29 @@ export function UsersSection() {
|
||||
|
||||
{roleAssignments.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{roleAssignments.map((a) => (
|
||||
{roleAssignments.map((a) => {
|
||||
const nodeLabel = a.resource_type === 'stack' && a.node_id != null
|
||||
? (availableNodes.find((n) => n.id === a.node_id)?.name ?? `node ${a.node_id}`)
|
||||
: null;
|
||||
return (
|
||||
<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>
|
||||
{nodeLabel != null && (
|
||||
<span className="text-muted-foreground"> @ {nodeLabel}</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" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex items-end gap-2 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Role</Label>
|
||||
<Combobox
|
||||
@@ -513,13 +559,35 @@ export function UsersSection() {
|
||||
{ value: 'node', label: 'Node' },
|
||||
]}
|
||||
value={scopeResourceType}
|
||||
onValueChange={(v) => { setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }}
|
||||
onValueChange={(v) => {
|
||||
setScopeResourceType(v as 'stack' | 'node');
|
||||
setScopeResourceId('');
|
||||
setScopeNodeId('');
|
||||
setAvailableStacks([]);
|
||||
void fetchAvailableNodes();
|
||||
}}
|
||||
placeholder="Type..."
|
||||
className="h-8 text-xs w-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label className="text-xs">Resource</Label>
|
||||
{scopeResourceType === 'stack' && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Node</Label>
|
||||
<Combobox
|
||||
options={availableNodes.map((n) => ({ value: String(n.id), label: n.name }))}
|
||||
value={scopeNodeId}
|
||||
onValueChange={(v) => {
|
||||
setScopeNodeId(v);
|
||||
setScopeResourceId('');
|
||||
void fetchStacksForNode(v);
|
||||
}}
|
||||
placeholder="Select node..."
|
||||
className="h-8 text-xs w-[140px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 flex-1 min-w-[140px]">
|
||||
<Label className="text-xs">{scopeResourceType === 'stack' ? 'Stack' : 'Node'}</Label>
|
||||
<Combobox
|
||||
options={scopeResourceType === 'stack'
|
||||
? availableStacks.map((s) => ({ value: s, label: s }))
|
||||
@@ -527,11 +595,25 @@ export function UsersSection() {
|
||||
}
|
||||
value={scopeResourceId}
|
||||
onValueChange={setScopeResourceId}
|
||||
placeholder="Select..."
|
||||
placeholder={
|
||||
scopeResourceType === 'stack'
|
||||
? (loadingStacks ? 'Loading stacks...' : (!scopeNodeId ? 'Select a node first...' : 'Select stack...'))
|
||||
: 'Select...'
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
disabled={scopeResourceType === 'stack' && (!scopeNodeId || loadingStacks)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={addRoleAssignment} disabled={addingScope || !scopeResourceId}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={addRoleAssignment}
|
||||
disabled={
|
||||
addingScope
|
||||
|| !scopeResourceId
|
||||
|| (scopeResourceType === 'stack' && !scopeNodeId)
|
||||
}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" strokeWidth={1.5} />
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function EnvironmentPanel({ stackName }: { stackName: string }) {
|
||||
|
||||
// Project env file selection
|
||||
const projectEnvCapable = hasCapability('project-env-files');
|
||||
const canEdit = can('stack:edit', 'stack', stackName);
|
||||
const canEdit = can('stack:edit', 'stack', stackName, nodeId);
|
||||
const [projectEnvFiles, setProjectEnvFiles] = useState<string[]>([]);
|
||||
const [candidates, setCandidates] = useState<string[]>([]);
|
||||
const [savingProjectEnv, setSavingProjectEnv] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user