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:
Anso
2026-05-24 22:58:28 -04:00
committed by GitHub
parent c67478b50d
commit 4c28b37a59
3 changed files with 56 additions and 8 deletions
@@ -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');
});
});
+4
View File
@@ -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)) {