Skip to content

Commit c8af6e3

Browse files
authored
feat(policies): enforce run-on-export policies on all PDF exit paths (Stirling-Tools#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.
1 parent 82ec2ac commit c8af6e3

10 files changed

Lines changed: 401 additions & 87 deletions

File tree

frontend/editor/public/locales/en-US/translation.toml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5914,12 +5914,40 @@ statDocsEnforced = "Docs enforced"
59145914
statusActive = "Active"
59155915
statusPaused = "Paused"
59165916

5917+
[policies.enforcement]
5918+
applying = "Applying {{names}}"
5919+
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
5920+
exportFailureBody = "Security policies couldn't be applied. Files were exported as-is."
5921+
exportFailureTitle = "Exported without enforcement"
5922+
failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were exported as-is."
5923+
failureTitle = "Exported without full enforcement"
5924+
printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again."
5925+
printPolicyAppliedTitle = "Policy applied before printing"
5926+
queued = "+{{count}} queued"
5927+
successTitle = "{{names}} applied"
5928+
summaryMore = "{{first}}, {{second}} and {{more}} more"
5929+
summaryTwo = "{{first}} and {{second}}"
5930+
5931+
[policies.enforcement.triggerVerb]
5932+
convert = "Enforcing before convert"
5933+
default = "Enforcing"
5934+
export = "Enforcing before export"
5935+
input = "Enforcing on import"
5936+
print = "Enforcing before print"
5937+
59175938
[policies.fields]
59185939
selectedCount = "{{count}} selected"
59195940

59205941
[policies.pii]
5942+
account = "Account numbers (labelled)"
5943+
card = "Credit / debit cards"
5944+
email = "Email addresses"
59215945
fieldLabel = "PII to redact"
5946+
iban = "IBANs"
5947+
phone = "Phone numbers"
59225948
placeholder = "Select PII types"
5949+
routing = "US routing numbers (ABA)"
5950+
ssn = "Social Security numbers"
59235951

59245952
[policies.sidebar]
59255953
activeCount = "{{count}} active"
@@ -5942,6 +5970,11 @@ setup = "Set up"
59425970
enableAriaLabel = "Enable {{tool}}"
59435971
infoAriaLabel = "What does {{tool}} do?"
59445972

5973+
[policies.toolConfig.info]
5974+
redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document."
5975+
sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it."
5976+
watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page."
5977+
59455978
[policies.wizard]
59465979
allDocTypesDescription = "Enable the Classification policy to filter by document type."
59475980
allDocTypesTitle = "All document types"

frontend/editor/src/core/components/shared/WorkbenchBar.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
3030
import { Tooltip } from "@app/components/shared/Tooltip";
3131
import LocalIcon from "@app/components/shared/LocalIcon";
3232
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
33+
import { enforceExportPolicies } from "@app/services/policyExport";
34+
import { downloadFile as downloadRaw } from "@app/services/downloadService";
35+
import { alert as showAlert } from "@app/components/toast";
3336
import {
3437
WorkbenchBarButtonConfig,
3538
WorkbenchBarRenderContext,
@@ -171,13 +174,35 @@ export default function WorkbenchBar({
171174

172175
const filesToExport =
173176
selectedFiles.length > 0 ? selectedFiles : activeFiles;
174-
for (const file of filesToExport) {
175-
const stub = isStirlingFile(file)
177+
const stubs = filesToExport.map((file) =>
178+
isStirlingFile(file)
176179
? selectors.getStirlingFileStub(file.fileId)
177-
: undefined;
180+
: undefined,
181+
);
182+
183+
// Enforce all files in one batch so the toast shows progress across the
184+
// whole set (e.g. "report.pdf (2 of 5)") rather than N invisible solo runs.
185+
let enforced: File[];
186+
try {
187+
enforced = await enforceExportPolicies(
188+
filesToExport as File[],
189+
stubs.map((s) => s?.id),
190+
);
191+
} catch {
192+
enforced = filesToExport as File[];
193+
showAlert({
194+
alertType: "warning",
195+
title: t("policies.enforcement.exportFailureTitle"),
196+
body: t("policies.enforcement.exportFailureBody"),
197+
});
198+
}
199+
200+
for (let idx = 0; idx < filesToExport.length; idx++) {
201+
const file = filesToExport[idx];
202+
const stub = stubs[idx];
178203
try {
179-
const result = await downloadFile({
180-
data: file,
204+
const result = await downloadRaw({
205+
data: enforced[idx],
181206
filename: file.name,
182207
localPath: forceNewFile ? undefined : stub?.localFilePath,
183208
fileId: stub?.id,

frontend/editor/src/core/contexts/ViewerContext.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import React, {
1111
import { useNavigation } from "@app/contexts/NavigationContext";
1212
import { useFileState } from "@app/contexts/FileContext";
1313
import { isStirlingFile } from "@app/types/fileContext";
14+
import type { FileId } from "@app/types/file";
15+
import { enforceExportPolicies } from "@app/services/policyExport";
16+
import { useTranslation } from "react-i18next";
17+
import { alert } from "@app/components/toast";
1418
import {
1519
preferencesService,
1620
type PdfRenderMode,
@@ -216,6 +220,7 @@ interface ViewerProviderProps {
216220
}
217221

218222
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
223+
const { t } = useTranslation();
219224
// UI state - only state directly managed by this context
220225
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] =
221226
useState(false);
@@ -537,6 +542,45 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
537542
triggerImmediateZoomUpdate,
538543
});
539544

545+
// Printing is an exit path, so a "run on export" policy must enforce here too.
546+
// Enforce the current file through the same path export uses: when a policy
547+
// rewrites it, that path versions the in-editor file to the enforced output
548+
// and marks it enforced, so a follow-up print of the unedited result prints
549+
// it as-is instead of re-running the (non-idempotent) policy. Ask the user to
550+
// review the updated doc before printing again, rather than printing bytes
551+
// they haven't seen. With no active export policy this is a no-op and print
552+
// runs straight away.
553+
const printWithPolicy = useCallback(async () => {
554+
const file = activeFileId
555+
? selectors.getFiles([activeFileId as FileId])[0]
556+
: undefined;
557+
if (!activeFileId || !file) {
558+
printActions.print();
559+
return;
560+
}
561+
const [enforced] = await enforceExportPolicies(
562+
[file],
563+
[activeFileId],
564+
"print",
565+
);
566+
// Original file back means no policy rewrote it (no active policy, already
567+
// enforced, or graceful failure fallback) — nothing new to review, print it.
568+
if (!enforced || enforced === file) {
569+
printActions.print();
570+
return;
571+
}
572+
alert({
573+
alertType: "warning",
574+
title: t("policies.enforcement.printPolicyAppliedTitle"),
575+
body: t("policies.enforcement.printPolicyAppliedBody"),
576+
});
577+
}, [activeFileId, selectors, printActions]);
578+
579+
const enforcedPrintActions = useMemo<PrintActions>(
580+
() => ({ print: printWithPolicy }),
581+
[printWithPolicy],
582+
);
583+
540584
const value: ViewerContextType = {
541585
// UI state
542586
isThumbnailSidebarVisible,
@@ -610,7 +654,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
610654
exportActions,
611655
bookmarkActions,
612656
attachmentActions,
613-
printActions,
657+
printActions: enforcedPrintActions,
614658

615659
// Bridge registration
616660
registerBridge,

frontend/editor/src/core/services/policyExport.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
export async function enforceExportPolicies(
88
files: File[],
99
_fileIds?: (string | undefined)[],
10+
_trigger?: "export" | "print" | "convert" | "input",
1011
): Promise<File[]> {
1112
return files;
1213
}

frontend/editor/src/core/tools/formFill/FormSaveBar.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import DownloadIcon from "@mui/icons-material/Download";
2525
import SaveIcon from "@mui/icons-material/Save";
2626
import EditNoteIcon from "@mui/icons-material/EditNote";
2727
import { useFormFill } from "@app/tools/formFill/FormFillContext";
28+
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
2829

2930
interface FormSaveBarProps {
3031
/** The current file being viewed */
@@ -77,15 +78,12 @@ export function FormSaveBar({
7778
setSaving(true);
7879
try {
7980
const blob = await submitForm(file, false);
80-
// Trigger browser download
81-
const url = URL.createObjectURL(blob);
82-
const a = document.createElement("a");
83-
a.href = url;
84-
a.download = file instanceof File ? file.name : "filled-form.pdf";
85-
document.body.appendChild(a);
86-
a.click();
87-
document.body.removeChild(a);
88-
URL.revokeObjectURL(url);
81+
// Route through the export gateway so a "run on export" policy enforces on
82+
// the filled PDF before it leaves the app (no-op when no such policy is set).
83+
await downloadFileWithPolicy({
84+
data: blob,
85+
filename: file instanceof File ? file.name : "filled-form.pdf",
86+
});
8987
} catch (err) {
9088
console.error("[FormSaveBar] Download failed:", err);
9189
} finally {

frontend/editor/src/desktop/hooks/useSaveShortcut.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useEffect } from "react";
22
import { useFileState, useFileActions } from "@app/contexts/FileContext";
3-
import { downloadFile } from "@app/services/downloadService";
3+
// Save through the export gateway so a "run on export" policy enforces before
4+
// the file is written out (no-op when no such policy is active).
5+
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
46

57
/**
68
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files

frontend/editor/src/desktop/services/operationResultsSaveService.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import type { FileId } from "@app/types/fileContext";
22
import type { OperationSaveContext } from "@core/services/operationResultsSaveService";
3-
import {
4-
downloadFile,
5-
downloadFromUrl,
6-
DownloadResult,
7-
} from "@app/services/downloadService";
3+
import { downloadFromUrl, DownloadResult } from "@app/services/downloadService";
4+
// Save through the export gateway so a "run on export" policy enforces before
5+
// the file is written out (no-op when no such policy is active).
6+
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
87

98
export type { OperationSaveContext };
109

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Compact status row for the {@link enforcementQueue}, shown in the Policies
3+
* panel whenever enforcement jobs are pending or running. The queue is serial,
4+
* so a slow policy run would otherwise be invisible — this surfaces what's being
5+
* enforced (before export, print, convert, …) and how many jobs are waiting.
6+
*/
7+
import { useTranslation } from "react-i18next";
8+
import { Group, Text, Loader } from "@mantine/core";
9+
import { useEnforcementQueue } from "@app/components/policies/enforcementQueue";
10+
11+
export function EnforcementQueueStatus() {
12+
const { t } = useTranslation();
13+
const jobs = useEnforcementQueue();
14+
const active = jobs.filter(
15+
(j) => j.status === "pending" || j.status === "running",
16+
);
17+
if (active.length === 0) return null;
18+
19+
// The running job leads the row; everything else is still queued behind it.
20+
const lead = active.find((j) => j.status === "running") ?? active[0];
21+
const queued = active.length - 1;
22+
23+
return (
24+
<Group
25+
gap="xs"
26+
wrap="nowrap"
27+
px="sm"
28+
py={6}
29+
role="status"
30+
aria-live="polite"
31+
>
32+
<Loader size="xs" />
33+
<Text size="xs" c="dimmed" truncate>
34+
{t(`policies.enforcement.triggerVerb.${lead.trigger}`, {
35+
defaultValue: t("policies.enforcement.triggerVerb.default"),
36+
})}
37+
: {lead.label}
38+
{queued > 0
39+
? ` · ${t("policies.enforcement.queued", { count: queued })}`
40+
: "…"}
41+
</Text>
42+
</Group>
43+
);
44+
}
45+
46+
export default EnforcementQueueStatus;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Serial enforcement queue. Every policy enforcement — before export, before
3+
* print, before a convert/extract, and (later) as files arrive — runs through
4+
* here, one at a time. The backend rejects concurrent policy runs under load,
5+
* and a single in-flight run keeps the queue the user sees honest.
6+
*
7+
* Jobs carry their {@link EnforcementTrigger} and a status so the UI can show
8+
* what's pending/running. Input enforcement reuses this contract unchanged: it
9+
* just submits jobs with `trigger: "input"`.
10+
*/
11+
import { useSyncExternalStore } from "react";
12+
13+
export type EnforcementTrigger = "export" | "print" | "convert" | "input";
14+
export type EnforcementStatus = "pending" | "running" | "done" | "failed";
15+
16+
export interface EnforcementJob {
17+
id: string;
18+
/** Human-readable label, e.g. the policy/file name, shown in the queue UI. */
19+
label: string;
20+
trigger: EnforcementTrigger;
21+
status: EnforcementStatus;
22+
}
23+
24+
/** How long a finished job lingers in the list before it's dropped. */
25+
const DONE_LINGER_MS = 2500;
26+
27+
type Listener = () => void;
28+
const listeners = new Set<Listener>();
29+
let jobs: EnforcementJob[] = [];
30+
// The tail of the serial chain — each new job runs after this resolves.
31+
let tail: Promise<unknown> = Promise.resolve();
32+
let seq = 0;
33+
34+
function emit() {
35+
for (const listener of listeners) listener();
36+
}
37+
38+
function setStatus(id: string, status: EnforcementStatus) {
39+
jobs = jobs.map((j) => (j.id === id ? { ...j, status } : j));
40+
emit();
41+
}
42+
43+
function scheduleRemoval(id: string) {
44+
setTimeout(() => {
45+
jobs = jobs.filter((j) => j.id !== id);
46+
emit();
47+
}, DONE_LINGER_MS);
48+
}
49+
50+
/**
51+
* Run `task` after every job queued before it has finished, tracking its status
52+
* for the UI. The returned promise resolves/rejects with the task's result, so
53+
* callers can `await runQueued(...)` exactly as they would the bare work.
54+
*/
55+
export function runQueued<T>(
56+
meta: { label: string; trigger: EnforcementTrigger },
57+
task: () => Promise<T>,
58+
): Promise<T> {
59+
const id = `enf-${++seq}`;
60+
jobs = [
61+
...jobs,
62+
{ id, label: meta.label, trigger: meta.trigger, status: "pending" },
63+
];
64+
emit();
65+
66+
const run = tail.then(async () => {
67+
setStatus(id, "running");
68+
try {
69+
const result = await task();
70+
setStatus(id, "done");
71+
return result;
72+
} catch (error) {
73+
setStatus(id, "failed");
74+
throw error;
75+
} finally {
76+
scheduleRemoval(id);
77+
}
78+
});
79+
80+
// Keep the chain alive when a task rejects so the next job still runs; callers
81+
// still see the rejection through `run`.
82+
tail = run.catch(() => {});
83+
return run;
84+
}
85+
86+
export function getQueueJobs(): EnforcementJob[] {
87+
return jobs;
88+
}
89+
90+
export function subscribeQueue(listener: Listener): () => void {
91+
listeners.add(listener);
92+
return () => {
93+
listeners.delete(listener);
94+
};
95+
}
96+
97+
/** React view of the live queue (pending + running + briefly-lingering jobs). */
98+
export function useEnforcementQueue(): EnforcementJob[] {
99+
return useSyncExternalStore(subscribeQueue, getQueueJobs, getQueueJobs);
100+
}

0 commit comments

Comments
 (0)