mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
fix(stacks): require stack:read on file explorer GET routes (#1200)
The four file-explorer GET endpoints (list, content, download, permissions) previously relied on auth alone. Their write-side siblings required stack:edit, so the read path was the only file-explorer surface without an explicit capability check. The shipped roles all carry stack:read globally so behaviour is unchanged today, but adding the guard prevents a future role definition from silently inheriting unrestricted file reads, and it brings the file-explorer reads in line with gitSources and stackActivity which already gate on stack:read. The frontend Files tab trigger, panel, and the anatomy-panel "Open Files" affordance now render only when the user holds stack:read for the active stack. An effect canonicalises activeTab back to 'compose' if the user lands on 'files' without permission, so a denied user cannot end up staring at an empty panel.
This commit is contained in:
@@ -595,4 +595,39 @@ describe('permission gating', () => {
|
||||
.send({ mode: 0o644 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('viewer with global stack:read can GET /files', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files`)
|
||||
.set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('viewer with global stack:read can GET /files/content', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.content).toBe('string');
|
||||
});
|
||||
|
||||
it('viewer with global stack:read can GET /files/download', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/download`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toMatch(/attachment/);
|
||||
});
|
||||
|
||||
it('viewer with global stack:read can GET /files/permissions', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.octal).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1370,6 +1370,7 @@ function isSafeUploadFilename(rawName: string): boolean {
|
||||
|
||||
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
|
||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||
@@ -1388,6 +1389,7 @@ stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
|
||||
|
||||
stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
|
||||
if (!isValidRelativeStackPath(relPath)) {
|
||||
@@ -1415,6 +1417,7 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response
|
||||
|
||||
stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
|
||||
if (!isValidRelativeStackPath(relPath)) {
|
||||
@@ -1583,6 +1586,7 @@ stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Respons
|
||||
|
||||
stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
|
||||
if (!isValidRelativeStackPath(relPath)) {
|
||||
|
||||
@@ -319,6 +319,13 @@ export function EditorView({
|
||||
const safeContent = content || '';
|
||||
const safeEnvContent = envContent || '';
|
||||
const isRunning = safeContainers.some(c => c.State === 'running');
|
||||
const canRead = can('stack:read', 'stack', stackName);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'files' && !canRead) {
|
||||
setActiveTab('compose');
|
||||
}
|
||||
}, [activeTab, canRead, setActiveTab]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -712,12 +719,14 @@ export function EditorView({
|
||||
<TabsHighlightItem value="env">
|
||||
<TabsTrigger value="env" disabled={!envExists}>.env</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="files">
|
||||
<TabsTrigger value="files">
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Files
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{canRead && (
|
||||
<TabsHighlightItem value="files">
|
||||
<TabsTrigger value="files">
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Files
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -801,7 +810,7 @@ export function EditorView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{activeTab === 'files' ? (
|
||||
{activeTab === 'files' && canRead ? (
|
||||
<StackFileExplorer
|
||||
stackName={stackName}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
@@ -864,7 +873,7 @@ export function EditorView({
|
||||
selectedEnvFile={selectedEnvFile}
|
||||
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
|
||||
onEditCompose={() => { setEditingCompose(true); setActiveTab('compose'); }}
|
||||
onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }}
|
||||
onOpenFiles={canRead ? () => { setEditingCompose(true); setActiveTab('files'); } : undefined}
|
||||
onOpenGitSource={() => setGitSourceOpen(true)}
|
||||
onApplyUpdate={() => { void updateStack(); }}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
|
||||
Reference in New Issue
Block a user