fix(cli): keep the deployed policy authoritative over a legacy app-file one

Review follow-up. `deployedPolicyBase` spread the app file's policy over the
deployed one, and older pulls wrote the whole policy into that file — so a repo
still carrying `on_behalf_of` (a stale value, or the explicit `null` a pull used
to write) or a legacy `triggerables` map fed both back into the deploy. The
first re-permissioned the app from checked-in content, the second restored
grants for runnables the deploy had removed. `pushRawApp` compounded it by
gating `preserve_on_behalf_of` on the merged policy rather than the deployed
one, which also let a create bypass the folder default.

The run-as identity and every derived grant (`triggerables`, `s3_inputs`,
`allowed_s3_keys`) now come off the deployed policy or not at all, and the raw
path claims an identity only on update, matching the low-code path.

Also honour a file-stated `execution_mode: viewer`, which had no other way to
survive a first deploy, and warn a non-deployer that a raw-app push reassigns
ownership — `preCheckPermissionedAs` covered `app` but not `raw_app`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaJ9KHDeaWrWUdVsHeHQ8L
This commit is contained in:
Diego Imbert
2026-09-09 17:01:36 +02:00
co-authored by Claude Opus 5
parent 493d19689c
commit 3ef858807c
5 changed files with 132 additions and 25 deletions
+57
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import {
deployedPolicyBase,
executionModeFromAppFile,
generatingPolicy,
markAccessFromPolicy,
@@ -26,3 +27,59 @@ test("the access mode survives the app.yaml round trip", async () => {
expect(executionModeFromAppFile({ policy: { execution_mode: "publisher" } })).toBe("publisher");
expect(executionModeFromAppFile({})).toBe("publisher");
});
// `viewer` has no representation in the app file, so a push that read the mode
// off the file alone demoted every viewer app to publisher — running the
// publisher's identity for viewers instead of each viewer's own.
test("a deployed viewer mode is not demoted by a file that cannot express it", () => {
expect(executionModeFromAppFile({}, { execution_mode: "viewer" } as any)).toBe("viewer");
expect(executionModeFromAppFile({ policy: { execution_mode: "viewer" } })).toBe("viewer");
// Widening still has to come from the file, and dropping `public` still demotes.
expect(executionModeFromAppFile({}, { execution_mode: "anonymous" } as any)).toBe("publisher");
expect(
executionModeFromAppFile({ public: true }, { execution_mode: "viewer" } as any),
).toBe("anonymous");
});
// A push regenerates the policy, so whatever `deployedPolicyBase` does not carry
// over is dropped from the deployed app. Older pulls wrote the whole policy into
// the app file, so repos still ship `on_behalf_of` keys — letting one win would
// hand the app's execution identity to checked-in content, which is what
// `preserve_on_behalf_of` exists to prevent.
test("deployedPolicyBase: the file never sets the run-as identity", () => {
const deployed: any = {
on_behalf_of: "u/svc",
on_behalf_of_email: "svc@corp",
sandbox: true,
};
for (
const local of [
undefined,
{ on_behalf_of: null, on_behalf_of_email: null },
{ on_behalf_of: "u/stale", on_behalf_of_email: "stale@corp" },
] as any[]
) {
expect(deployedPolicyBase(deployed, local)).toMatchObject({
on_behalf_of: "u/svc",
on_behalf_of_email: "svc@corp",
sandbox: true,
});
}
});
// Grants keyed to the sources a deploy replaces must not survive it, from either
// side of the merge: the backend folds legacy `triggerables` into
// `triggerables_v2` on read and enforces S3 access against `s3_inputs`.
test("deployedPolicyBase: derived grants are never carried over", () => {
const stale: any = {
triggerables: { "gone:script/f/gone": {} },
s3_inputs: [{ allowed_resources: ["u/gone"] }],
allowed_s3_keys: [{ s3_path: "gone" }],
};
for (const [deployed, local] of [[stale, undefined], [{}, stale]] as any[]) {
const base = deployedPolicyBase(deployed, local) as any;
expect(base.triggerables).toBeUndefined();
expect(base.s3_inputs).toBeUndefined();
expect(base.allowed_s3_keys).toBeUndefined();
}
});
+30 -5
View File
@@ -812,14 +812,39 @@ syncBehavior: v1`, "utf-8");
expect(pushResult2.code).toEqual(0);
await waitForDeploymentJobs(backend);
const getResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/apps/get/p/f/test/policy_app`
);
expect(getResp.status).toEqual(200);
const policy = (await getResp.json()).policy;
async function readPolicy() {
const resp = await backend.apiRequest!(
`/api/w/${backend.workspace}/apps/get/p/f/test/policy_app`
);
expect(resp.status).toEqual(200);
return (await resp.json()).policy;
}
const policy = await readPolicy();
expect(policy.on_behalf_of).toEqual("u/svc");
expect(policy.on_behalf_of_email).toEqual("svc@windmill.dev");
expect(policy.sandbox).toEqual(true);
expect(policy.execution_mode).toEqual("viewer");
// The carry-over must not make the access mode sticky in the widening
// direction: `public: true` promotes, and dropping it demotes again.
const yamlPath = path.join(appDir, "raw_app.yaml");
const baseYaml = await readFileContent(yamlPath);
await writeFile(yamlPath, `${baseYaml}public: true\n`, "utf-8");
expect((await backend.runCLICommand(
["sync", "push", "--yes"], tempDir, "raw_app_policy_test"
)).code).toEqual(0);
await waitForDeploymentJobs(backend);
expect(await readPolicy()).toMatchObject({ execution_mode: "anonymous" });
await writeFile(yamlPath, baseYaml, "utf-8");
expect((await backend.runCLICommand(
["sync", "push", "--yes"], tempDir, "raw_app_policy_test"
)).code).toEqual(0);
await waitForDeploymentJobs(backend);
expect(await readPolicy()).toMatchObject({
execution_mode: "publisher",
sandbox: true,
on_behalf_of: "u/svc",
});
});
});