fix: allow chmod on protected stack files (#1772)

* fix: allow chmod on protected stack files

Identity protection still blocks delete, rename, and copy-onto-reserved-name for compose and .env at the stack root. Permission changes are ordinary edits and must succeed from the explorer.

* fix: gate chmod on compose files during stack ops

Chmod on compose filenames and .blueprint.json now follows the same
stack-op lock as content writes and uploads. Document allowed blueprint
chmod and that content saves reset mode bits.
This commit is contained in:
Anso
2026-08-05 09:22:36 -04:00
committed by GitHub
parent 9b481212f6
commit a826cd398d
5 changed files with 169 additions and 12 deletions
@@ -819,6 +819,50 @@ describe('FileSystemService stack methods', () => {
expect(await fs.readFile(externalFile, 'utf-8')).toBe('external');
});
});
describe('chmod on protected stack files', () => {
it('chmodStackPath succeeds on .env', async () => {
const envPath = path.join(stackDir, '.env');
await fs.writeFile(envPath, 'KEY=val\n');
await fs.chmod(envPath, 0o644);
const service = FileSystemService.getInstance();
await service.chmodStackPath(STACK, '.env', 0o600);
if (!isWindows) {
const stat = await fs.stat(envPath);
expect(stat.mode & 0o777).toBe(0o600);
}
});
it('chmodStackPath succeeds on compose.yaml', async () => {
const composePath = path.join(stackDir, 'compose.yaml');
await fs.writeFile(composePath, 'services: {}\n');
await fs.chmod(composePath, 0o644);
const service = FileSystemService.getInstance();
await service.chmodStackPath(STACK, 'compose.yaml', 0o600);
if (!isWindows) {
const stat = await fs.stat(composePath);
expect(stat.mode & 0o777).toBe(0o600);
}
});
it('chmodStackPath succeeds on .blueprint.json', async () => {
const markerPath = path.join(stackDir, '.blueprint.json');
await fs.writeFile(markerPath, '{"blueprintId":1,"revision":1}\n');
await fs.chmod(markerPath, 0o644);
const service = FileSystemService.getInstance();
await service.chmodStackPath(STACK, '.blueprint.json', 0o600);
if (!isWindows) {
const stat = await fs.stat(markerPath);
expect(stat.mode & 0o777).toBe(0o600);
}
});
});
});
// Root-scoped (bind-mount) behaviour: the file methods accept an arbitrary
@@ -1128,6 +1128,14 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
expect(composeRes.status).toBe(409);
expect(composeRes.body.code).toBe('stack_op_in_progress');
const trailingRes = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'compose.yaml/' })
.set('Cookie', adminCookie)
.send({ content: 'services:\n app:\n image: nginx\n' });
expect(trailingRes.status).toBe(409);
expect(trailingRes.body.code).toBe('stack_op_in_progress');
const markerRes = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: '.blueprint.json' })
@@ -1739,6 +1747,63 @@ describe('PUT /api/stacks/:stackName/files/permissions', () => {
.send({ mode: 0o600 });
expect(res.status).toBe(204);
});
it('blocks root trust file chmod while a stack op lock is held', async () => {
await fs.writeFile(path.join(stacksDir, STACK, '.blueprint.json'), '{"blueprintId":1,"revision":1}\n');
await fs.mkdir(path.join(stacksDir, STACK, 'config'), { recursive: true });
await fs.writeFile(path.join(stacksDir, STACK, 'config', 'app.conf'), 'ok\n');
await fs.writeFile(path.join(stacksDir, STACK, 'config', 'compose.yaml'), 'services: {}\n');
const { StackOpLockService } = await import('../services/StackOpLockService');
StackOpLockService.getInstance().tryAcquire(1, STACK, 'deploy', 'admin');
try {
const composeRes = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie)
.send({ mode: 0o600 });
expect(composeRes.status).toBe(409);
expect(composeRes.body.code).toBe('stack_op_in_progress');
const trailingRes = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: 'compose.yaml/' })
.set('Cookie', adminCookie)
.send({ mode: 0o600 });
expect(trailingRes.status).toBe(409);
expect(trailingRes.body.code).toBe('stack_op_in_progress');
const markerRes = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: '.blueprint.json' })
.set('Cookie', adminCookie)
.send({ mode: 0o600 });
expect(markerRes.status).toBe(409);
expect(markerRes.body.code).toBe('stack_op_in_progress');
const envRes = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: '.env' })
.set('Cookie', adminCookie)
.send({ mode: 0o600 });
expect(envRes.status).toBe(204);
const nestedRes = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: 'config/app.conf' })
.set('Cookie', adminCookie)
.send({ mode: 0o600 });
expect(nestedRes.status).toBe(204);
const nestedComposeRes = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: 'config/compose.yaml' })
.set('Cookie', adminCookie)
.send({ mode: 0o600 });
expect(nestedComposeRes.status).toBe(204);
} finally {
StackOpLockService.getInstance().release(1, STACK);
}
});
});
// ── DELETE /:stackName/files ──────────────────────────────────────────────────
@@ -1992,14 +2057,46 @@ describe('protected stack files', () => {
await fs.unlink(path.join(stacksDir, STACK, 'pretend.yaml'));
});
it('PUT /files/permissions refuses .env with 409 PROTECTED_FILE', async () => {
it('PUT /files/permissions succeeds on .env', async () => {
const mode = 0o600;
const res = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: '.env' })
.set('Cookie', adminCookie)
.send({ mode: 0o644 });
expect(res.status).toBe(409);
expect(res.body.code).toBe('PROTECTED_FILE');
.send({ mode });
expect(res.status).toBe(204);
// Windows Node only distinguishes writable vs read-only; exact Unix bits are Linux/macOS.
if (!isWindows) {
expect((await fs.stat(path.join(stacksDir, STACK, '.env'))).mode & 0o777).toBe(mode);
}
});
it('PUT /files/permissions succeeds on compose.yaml', async () => {
const mode = 0o600;
const res = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie)
.send({ mode });
expect(res.status).toBe(204);
if (!isWindows) {
expect((await fs.stat(path.join(stacksDir, STACK, 'compose.yaml'))).mode & 0o777).toBe(mode);
}
});
it('PUT /files/permissions succeeds on .blueprint.json', async () => {
const markerPath = path.join(stacksDir, STACK, '.blueprint.json');
await fs.writeFile(markerPath, '{"blueprintId":1,"revision":1}\n');
const mode = 0o600;
const res = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: '.blueprint.json' })
.set('Cookie', adminCookie)
.send({ mode });
expect(res.status).toBe(204);
if (!isWindows) {
expect((await fs.stat(markerPath)).mode & 0o777).toBe(mode);
}
});
it('DELETE /files still succeeds on a non-protected file', async () => {
+5 -3
View File
@@ -145,7 +145,7 @@ function releaseStackOpLock(req: Request, stackName: string): void {
StackOpLockService.getInstance().release(req.nodeId, stackName);
}
/** Root compose + blueprint marker on the stack-source root must not change while a lifecycle op holds the stack lock. */
/** Root compose + blueprint marker on the stack-source root must not change (content or mode) while a lifecycle op holds the stack lock. */
const STACK_OP_LOCKED_ROOT_TRUST_FILES = new Set([
'compose.yaml',
'compose.yml',
@@ -162,8 +162,9 @@ function rejectIfStackOpBlocksRootTrustFileWrite(
root: StackFileRoot,
): boolean {
if (root.kind !== 'stack-source') return false;
if (relPath.includes('/')) return false;
const base = relPath.toLowerCase();
const normalized = relPath.endsWith('/') ? relPath.slice(0, -1) : relPath;
if (normalized.includes('/')) return false;
const base = normalized.toLowerCase();
if (!STACK_OP_LOCKED_ROOT_TRUST_FILES.has(base)) return false;
const existing = StackOpLockService.getInstance().get(req.nodeId, stackName);
if (!existing) return false;
@@ -3662,6 +3663,7 @@ stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Resp
}
const root = await resolveRootForOp(req, res, stackName, 'write');
if (!root) return;
if (rejectIfStackOpBlocksRootTrustFileWrite(req, res, stackName, relPath, root)) return;
const startedAt = Date.now();
logFileDiag('chmod start', { stackName, relPath, nodeId: req.nodeId, mode, rootKind: root.kind });
try {
@@ -2068,7 +2068,6 @@ export class FileSystemService {
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
// chmod on a symlink is rejected. Following the link would silently