feat(policies): enforce run-on-export policies on all PDF exit paths (#6788)

> **Draft / WIP** — print enforcement is still to come (see below).

## Goal

A "run on export" policy must enforce on **every** path where a PDF
leaves the editor, not just the main Download/Export button. This routes
the remaining exits through the existing export-policy gateway
(`downloadFileWithPolicy`), which runs `enforceExportPolicies` before
the file leaves and is a no-op when no export policy is active.

## Audit of exit paths

| Path | Status |
|---|---|
| Web download / export, page-editor, file-editor, thumbnails | 
already covered (gateway) |
| **Form-fill download** (`FormSaveBar`) |  fixed here — was a raw
`createObjectURL` download |
| **Desktop Ctrl+S save** (`useSaveShortcut`) |  fixed here — was raw
`downloadService` |
| **Desktop save-operation-results** (`operationResultsSaveService`) | 
fixed here — was raw `downloadService` |
| Viewer `saveAsCopy` (annotations/redactions) | n/a — in-memory version
saves, not exits |
| **Print** (`printActions.print`) |  pending — enforce-then-print
(below) |
| Web operation-results (`downloadFromUrl`) |  pending — URL-stream,
needs a fetch→enforce wrapper |
| Share link | excluded by design (enforce at share-creation, not
recipient download) |

## In this PR

All three fixes are the same pattern — route the raw download through
`downloadFileWithPolicy` instead of `URL.createObjectURL` / the raw
download service.

## Still to come (why it's a draft)

- **Print** — enforce-then-print: on print, run the same
`enforceExportPolicies`; if it changed the doc, swap the viewer to the
enforced version (new version in history) and toast *"PDF updated by
policy enforcement — review, then print again"* rather than silently
printing a different doc; if unchanged, print. Covers Ctrl+P, the
toolbar button, and embedded PDF-JS print.
- **Web operation-results** (`downloadFromUrl`) — fetch the result to a
blob, enforce, then download.

## Verification

Typecheck (core/proprietary) + prettier clean for the changes here;
desktop tsc clean for the touched files. The print UX, once added, needs
a manual run with an active export policy — there's no automated path
for it.
This commit is contained in:
Reece Browne
2026-06-29 18:01:12 +00:00
committed by GitHub
parent 82ec2acaba
commit c8af6e3b7e
10 changed files with 401 additions and 87 deletions
@@ -5914,12 +5914,40 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.enforcement]
applying = "Applying {{names}}"
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
exportFailureBody = "Security policies couldn't be applied. Files were exported as-is."
exportFailureTitle = "Exported without enforcement"
failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were exported as-is."
failureTitle = "Exported without full enforcement"
printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again."
printPolicyAppliedTitle = "Policy applied before printing"
queued = "+{{count}} queued"
successTitle = "{{names}} applied"
summaryMore = "{{first}}, {{second}} and {{more}} more"
summaryTwo = "{{first}} and {{second}}"
[policies.enforcement.triggerVerb]
convert = "Enforcing before convert"
default = "Enforcing"
export = "Enforcing before export"
input = "Enforcing on import"
print = "Enforcing before print"
[policies.fields]
selectedCount = "{{count}} selected"
[policies.pii]
account = "Account numbers (labelled)"
card = "Credit / debit cards"
email = "Email addresses"
fieldLabel = "PII to redact"
iban = "IBANs"
phone = "Phone numbers"
placeholder = "Select PII types"
routing = "US routing numbers (ABA)"
ssn = "Social Security numbers"
[policies.sidebar]
activeCount = "{{count}} active"
@@ -5942,6 +5970,11 @@ setup = "Set up"
enableAriaLabel = "Enable {{tool}}"
infoAriaLabel = "What does {{tool}} do?"
[policies.toolConfig.info]
redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document."
sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it."
watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page."
[policies.wizard]
allDocTypesDescription = "Enable the Classification policy to filter by document type."
allDocTypesTitle = "All document types"
@@ -30,6 +30,9 @@ import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { enforceExportPolicies } from "@app/services/policyExport";
import { downloadFile as downloadRaw } from "@app/services/downloadService";
import { alert as showAlert } from "@app/components/toast";
import {
WorkbenchBarButtonConfig,
WorkbenchBarRenderContext,
@@ -171,13 +174,35 @@ export default function WorkbenchBar({
const filesToExport =
selectedFiles.length > 0 ? selectedFiles : activeFiles;
for (const file of filesToExport) {
const stub = isStirlingFile(file)
const stubs = filesToExport.map((file) =>
isStirlingFile(file)
? selectors.getStirlingFileStub(file.fileId)
: undefined;
: undefined,
);
// Enforce all files in one batch so the toast shows progress across the
// whole set (e.g. "report.pdf (2 of 5)") rather than N invisible solo runs.
let enforced: File[];
try {
enforced = await enforceExportPolicies(
filesToExport as File[],
stubs.map((s) => s?.id),
);
} catch {
enforced = filesToExport as File[];
showAlert({
alertType: "warning",
title: t("policies.enforcement.exportFailureTitle"),
body: t("policies.enforcement.exportFailureBody"),
});
}
for (let idx = 0; idx < filesToExport.length; idx++) {
const file = filesToExport[idx];
const stub = stubs[idx];
try {
const result = await downloadFile({
data: file,
const result = await downloadRaw({
data: enforced[idx],
filename: file.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
fileId: stub?.id,
@@ -11,6 +11,10 @@ import React, {
import { useNavigation } from "@app/contexts/NavigationContext";
import { useFileState } from "@app/contexts/FileContext";
import { isStirlingFile } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { enforceExportPolicies } from "@app/services/policyExport";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import {
preferencesService,
type PdfRenderMode,
@@ -216,6 +220,7 @@ interface ViewerProviderProps {
}
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const { t } = useTranslation();
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] =
useState(false);
@@ -537,6 +542,45 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
triggerImmediateZoomUpdate,
});
// Printing is an exit path, so a "run on export" policy must enforce here too.
// Enforce the current file through the same path export uses: when a policy
// rewrites it, that path versions the in-editor file to the enforced output
// and marks it enforced, so a follow-up print of the unedited result prints
// it as-is instead of re-running the (non-idempotent) policy. Ask the user to
// review the updated doc before printing again, rather than printing bytes
// they haven't seen. With no active export policy this is a no-op and print
// runs straight away.
const printWithPolicy = useCallback(async () => {
const file = activeFileId
? selectors.getFiles([activeFileId as FileId])[0]
: undefined;
if (!activeFileId || !file) {
printActions.print();
return;
}
const [enforced] = await enforceExportPolicies(
[file],
[activeFileId],
"print",
);
// Original file back means no policy rewrote it (no active policy, already
// enforced, or graceful failure fallback) — nothing new to review, print it.
if (!enforced || enforced === file) {
printActions.print();
return;
}
alert({
alertType: "warning",
title: t("policies.enforcement.printPolicyAppliedTitle"),
body: t("policies.enforcement.printPolicyAppliedBody"),
});
}, [activeFileId, selectors, printActions]);
const enforcedPrintActions = useMemo<PrintActions>(
() => ({ print: printWithPolicy }),
[printWithPolicy],
);
const value: ViewerContextType = {
// UI state
isThumbnailSidebarVisible,
@@ -610,7 +654,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
exportActions,
bookmarkActions,
attachmentActions,
printActions,
printActions: enforcedPrintActions,
// Bridge registration
registerBridge,
@@ -7,6 +7,7 @@
export async function enforceExportPolicies(
files: File[],
_fileIds?: (string | undefined)[],
_trigger?: "export" | "print" | "convert" | "input",
): Promise<File[]> {
return files;
}
@@ -25,6 +25,7 @@ import DownloadIcon from "@mui/icons-material/Download";
import SaveIcon from "@mui/icons-material/Save";
import EditNoteIcon from "@mui/icons-material/EditNote";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
interface FormSaveBarProps {
/** The current file being viewed */
@@ -77,15 +78,12 @@ export function FormSaveBar({
setSaving(true);
try {
const blob = await submitForm(file, false);
// Trigger browser download
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file instanceof File ? file.name : "filled-form.pdf";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Route through the export gateway so a "run on export" policy enforces on
// the filled PDF before it leaves the app (no-op when no such policy is set).
await downloadFileWithPolicy({
data: blob,
filename: file instanceof File ? file.name : "filled-form.pdf",
});
} catch (err) {
console.error("[FormSaveBar] Download failed:", err);
} finally {
@@ -1,6 +1,8 @@
import { useEffect } from "react";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { downloadFile } from "@app/services/downloadService";
// Save through the export gateway so a "run on export" policy enforces before
// the file is written out (no-op when no such policy is active).
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
/**
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
@@ -1,10 +1,9 @@
import type { FileId } from "@app/types/fileContext";
import type { OperationSaveContext } from "@core/services/operationResultsSaveService";
import {
downloadFile,
downloadFromUrl,
DownloadResult,
} from "@app/services/downloadService";
import { downloadFromUrl, DownloadResult } from "@app/services/downloadService";
// Save through the export gateway so a "run on export" policy enforces before
// the file is written out (no-op when no such policy is active).
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
export type { OperationSaveContext };
@@ -0,0 +1,46 @@
/**
* Compact status row for the {@link enforcementQueue}, shown in the Policies
* panel whenever enforcement jobs are pending or running. The queue is serial,
* so a slow policy run would otherwise be invisible — this surfaces what's being
* enforced (before export, print, convert, …) and how many jobs are waiting.
*/
import { useTranslation } from "react-i18next";
import { Group, Text, Loader } from "@mantine/core";
import { useEnforcementQueue } from "@app/components/policies/enforcementQueue";
export function EnforcementQueueStatus() {
const { t } = useTranslation();
const jobs = useEnforcementQueue();
const active = jobs.filter(
(j) => j.status === "pending" || j.status === "running",
);
if (active.length === 0) return null;
// The running job leads the row; everything else is still queued behind it.
const lead = active.find((j) => j.status === "running") ?? active[0];
const queued = active.length - 1;
return (
<Group
gap="xs"
wrap="nowrap"
px="sm"
py={6}
role="status"
aria-live="polite"
>
<Loader size="xs" />
<Text size="xs" c="dimmed" truncate>
{t(`policies.enforcement.triggerVerb.${lead.trigger}`, {
defaultValue: t("policies.enforcement.triggerVerb.default"),
})}
: {lead.label}
{queued > 0
? ` · ${t("policies.enforcement.queued", { count: queued })}`
: "…"}
</Text>
</Group>
);
}
export default EnforcementQueueStatus;
@@ -0,0 +1,100 @@
/**
* Serial enforcement queue. Every policy enforcement — before export, before
* print, before a convert/extract, and (later) as files arrive — runs through
* here, one at a time. The backend rejects concurrent policy runs under load,
* and a single in-flight run keeps the queue the user sees honest.
*
* Jobs carry their {@link EnforcementTrigger} and a status so the UI can show
* what's pending/running. Input enforcement reuses this contract unchanged: it
* just submits jobs with `trigger: "input"`.
*/
import { useSyncExternalStore } from "react";
export type EnforcementTrigger = "export" | "print" | "convert" | "input";
export type EnforcementStatus = "pending" | "running" | "done" | "failed";
export interface EnforcementJob {
id: string;
/** Human-readable label, e.g. the policy/file name, shown in the queue UI. */
label: string;
trigger: EnforcementTrigger;
status: EnforcementStatus;
}
/** How long a finished job lingers in the list before it's dropped. */
const DONE_LINGER_MS = 2500;
type Listener = () => void;
const listeners = new Set<Listener>();
let jobs: EnforcementJob[] = [];
// The tail of the serial chain — each new job runs after this resolves.
let tail: Promise<unknown> = Promise.resolve();
let seq = 0;
function emit() {
for (const listener of listeners) listener();
}
function setStatus(id: string, status: EnforcementStatus) {
jobs = jobs.map((j) => (j.id === id ? { ...j, status } : j));
emit();
}
function scheduleRemoval(id: string) {
setTimeout(() => {
jobs = jobs.filter((j) => j.id !== id);
emit();
}, DONE_LINGER_MS);
}
/**
* Run `task` after every job queued before it has finished, tracking its status
* for the UI. The returned promise resolves/rejects with the task's result, so
* callers can `await runQueued(...)` exactly as they would the bare work.
*/
export function runQueued<T>(
meta: { label: string; trigger: EnforcementTrigger },
task: () => Promise<T>,
): Promise<T> {
const id = `enf-${++seq}`;
jobs = [
...jobs,
{ id, label: meta.label, trigger: meta.trigger, status: "pending" },
];
emit();
const run = tail.then(async () => {
setStatus(id, "running");
try {
const result = await task();
setStatus(id, "done");
return result;
} catch (error) {
setStatus(id, "failed");
throw error;
} finally {
scheduleRemoval(id);
}
});
// Keep the chain alive when a task rejects so the next job still runs; callers
// still see the rejection through `run`.
tail = run.catch(() => {});
return run;
}
export function getQueueJobs(): EnforcementJob[] {
return jobs;
}
export function subscribeQueue(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** React view of the live queue (pending + running + briefly-lingering jobs). */
export function useEnforcementQueue(): EnforcementJob[] {
return useSyncExternalStore(subscribeQueue, getQueueJobs, getQueueJobs);
}
@@ -17,10 +17,18 @@ import {
getPolicyRun,
downloadPolicyOutput,
} from "@app/services/policyApi";
import { recordRunStart } from "@app/components/policies/policyRunStore";
import {
recordRunStart,
isDispatched,
} from "@app/components/policies/policyRunStore";
import {
runQueued,
type EnforcementTrigger,
} from "@app/components/policies/enforcementQueue";
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
import { alert, updateToast, dismissToast } from "@app/components/toast";
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import i18n from "@app/i18n";
/** Poll cadence + cap for a single export run (≈2.5 min worst case). */
const POLL_MS = 2000;
@@ -98,10 +106,29 @@ async function runToCompletion(
if (view.status === "FAILED" || view.status === "CANCELLED") {
throw new Error(view.error || `policy run ${view.status.toLowerCase()}`);
}
if (view.status === "WAITING_FOR_INPUT") {
throw new Error(
"policy requires interactive input and cannot run automatically",
);
}
}
throw new Error("policy run timed out");
}
function enforcedFilesSummary(names: string[]): string {
if (names.length === 1) return names[0];
if (names.length === 2)
return i18n.t("policies.enforcement.summaryTwo", {
first: names[0],
second: names[1],
});
return i18n.t("policies.enforcement.summaryMore", {
first: names[0],
second: names[1],
more: names.length - 2,
});
}
/**
* Enforce every active export-policy on each PDF just before export, returning
* the files in order (enforced, or the original on failure). `fileIds[i]` is the
@@ -113,80 +140,119 @@ async function runToCompletion(
export async function enforceExportPolicies(
files: File[],
fileIds?: (string | undefined)[],
trigger: EnforcementTrigger = "export",
): Promise<File[]> {
const active = activeExportPolicies();
const targets = files.flatMap((f, i) => (isPdf(f) ? [i] : []));
if (!active.length || targets.length === 0) return files;
// Policies that haven't already enforced this exact file version. Enforcing
// versions the in-editor file to the policy's output and marks that output
// dispatched, so an unedited re-export skips re-running — re-applying a
// non-idempotent policy would stack watermarks/flattens. Editing produces a
// new file id that isn't dispatched, so an edited file enforces afresh.
const pendingFor = (fileId: string | undefined) =>
active.filter((p) => !(fileId && isDispatched(p.categoryId, fileId)));
if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files;
const names = active.map((p) => p.label).join(", ");
const toastId = alert({
alertType: "neutral",
title: `Applying ${names}`,
body: `Enforcing ${
targets.length === 1 ? "your file" : `${targets.length} files`
} before export…`,
isPersistentPopup: true,
expandable: false,
glowColor: active[0].accent,
});
const out = [...files];
let failures = 0;
for (const i of targets) {
const file = files[i];
const fileId = fileIds?.[i];
try {
let current = file;
// The last "new version" policy's output is what versions the editor file
// (recording every policy would double-consume the same input).
let versionRun: PolicyRunResult & { categoryId: string };
let hasVersionRun = false;
for (const policy of active) {
const result = await runToCompletion(policy.backendId, current);
current = result.file;
if (policy.outputMode === "new_version" && fileId) {
versionRun = { ...result, categoryId: policy.categoryId };
hasVersionRun = true;
// Serialise through the enforcement queue: one policy run in flight at a time
// (the backend rejects concurrent runs under load), and the user can see
// what's pending. Export, print and convert all share this queue.
return runQueued({ label: names, trigger }, async () => {
// An earlier queued job may have just enforced these same files and marked
// them dispatched, so re-check at run time before doing (or announcing) work.
if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files;
const pending = targets.filter((i) => pendingFor(fileIds?.[i]).length > 0);
const total = pending.length;
const progressTitle = (done: number) =>
total === 1
? i18n.t("policies.enforcement.applying", { names })
: i18n.t("policies.enforcement.applyingProgress", {
names,
done: done + 1,
total,
});
const progressBody = (done: number) => files[pending[done]].name;
const toastId = alert({
alertType: "neutral",
title: progressTitle(0),
body: progressBody(0),
isPersistentPopup: true,
expandable: false,
glowColor: active[0].accent,
});
const out = [...files];
let failures = 0;
let done = 0;
for (const i of pending) {
const file = files[i];
const fileId = fileIds?.[i];
const toRun = pendingFor(fileId);
try {
let current = file;
// The last "new version" policy's output is what versions the editor
// file (recording every policy would double-consume the same input).
let versionRun: (PolicyRunResult & { categoryId: string }) | undefined;
for (const policy of toRun) {
const result = await runToCompletion(policy.backendId, current);
current = result.file;
if (policy.outputMode === "new_version" && fileId) {
versionRun = { ...result, categoryId: policy.categoryId };
}
}
out[i] = current;
done += 1;
if (done < total)
updateToast(toastId, {
title: progressTitle(done),
body: progressBody(done),
});
if (versionRun && fileId) {
recordRunStart({
runId: versionRun.runId,
categoryId: versionRun.categoryId,
fileId,
fileName: file.name,
fileSize: file.size,
status: "COMPLETED",
outputs: versionRun.outputs,
error: null,
startedAt: Date.now(),
});
}
} catch {
failures += 1; // leave out[i] as the original — never hard-block.
}
out[i] = current;
if (hasVersionRun && fileId) {
recordRunStart({
runId: versionRun!.runId,
categoryId: versionRun!.categoryId,
fileId,
fileName: file.name,
fileSize: file.size,
status: "COMPLETED",
outputs: versionRun!.outputs,
error: null,
startedAt: Date.now(),
});
}
} catch {
failures += 1; // leave out[i] as the original — never hard-block.
}
}
updateToast(
toastId,
failures
? {
alertType: "warning",
title: "Exported without full enforcement",
body: `${failures} of ${targets.length} file(s) couldn't be processed and were exported as-is.`,
isPersistentPopup: false,
glowColor: undefined,
}
: {
alertType: "success",
title: `${names} applied`,
body: "Enforced before export.",
isPersistentPopup: false,
glowColor: undefined,
},
);
// update() doesn't reschedule auto-dismiss, so fade the result out explicitly.
window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS);
return out;
updateToast(
toastId,
failures
? {
alertType: "warning",
title: i18n.t("policies.enforcement.failureTitle"),
body: i18n.t("policies.enforcement.failureBody", {
failures,
total,
}),
isPersistentPopup: false,
glowColor: undefined,
}
: {
alertType: "success",
title: i18n.t("policies.enforcement.successTitle", { names }),
body: enforcedFilesSummary(pending.map((i) => files[i].name)),
isPersistentPopup: false,
glowColor: undefined,
},
);
// update() doesn't reschedule auto-dismiss, so fade the result out explicitly.
window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS);
return out;
});
}