Policies: enforce on upload or export (#6614)

Follow-up to #6604 (merged). Builds the Security policy out so it
actually enforces, driven from the editor.

## What it does
- **Run on upload or export** — a single choice in the wizard: enforce
when a file is uploaded, or just before it's exported.
- **Output** — enforced result is a **new version** of the file
(default) or a **new file**, with optional filename
prefix/suffix/auto-number ("Output filename" subsection; auto-number
only for new files).
- **Export enforcement** — exporting an export-mode file runs the policy
first and downloads the enforced result; never hard-blocks (on failure
the original downloads). For new-version policies the in-editor file is
versioned too. Covers every export path incl. multi-file ZIP. A toast
(glowing in the policy's accent while it runs) reports progress and
fades after ~10s.
- **Affordances** — a freshly enforced file briefly glows its policy
accent and carries a shield badge.
- **Config tidy-up** — removed the unwired Security setting fields + the
wizard's review step; "Upgrade to enterprise" on locked categories;
category accent in the detail/wizard headers.

## Notes
- Builds on the manual-only (client-driven) policy model from #6587
(`trigger: null`, metadata in `output.options`), adding the `runOn`
field + export-time enforcement.
- The page-editor merge-export (no single source file) enforces +
downloads but doesn't version in place.

## Verification
typecheck (core + proprietary), eslint, prettier; proprietary suite
(105) green; flows checked in-app.
This commit is contained in:
Reece Browne
2026-06-11 18:12:01 +01:00
committed by GitHub
parent 5fa5e12c64
commit 9ee0bc4b32
31 changed files with 529 additions and 123 deletions
@@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard";
import FilePickerModal from "@app/components/shared/FilePickerModal";
import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface FileEditorProps {
@@ -256,6 +256,7 @@ const FileEditor = ({
data: file,
filename: file.name,
localPath: record.localFilePath,
fileId,
});
console.log("[FileEditor] Download complete, checking dirty state:", {
localFilePath: record.localFilePath,
@@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain";
import HoverActionMenu, {
HoverAction,
} from "@app/components/shared/HoverActionMenu";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
@@ -248,6 +248,7 @@ const FileEditorThumbnail = ({
data: fileToSave,
filename: file.name,
localPath: file.localFilePath,
fileId: file.id,
});
if (!result.cancelled && result.savedPath) {
fileActions.updateStirlingFileStub(file.id, {
@@ -23,7 +23,7 @@ import { FileId } from "@app/types/file";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
interface FileItem {
id: FileId;
@@ -98,6 +98,7 @@ const FileThumbnail = ({
void downloadFile({
data: maybeFile,
filename: maybeFile.name || file.name || "download",
fileId: file.id,
});
return;
}
@@ -36,6 +36,32 @@
background-color: rgba(59, 130, 246, 0.06);
}
/* A policy-enforced file glows in that policy's accent colour so it's obvious
the file has had a policy applied: it pulses a few times to catch the eye and
then fades out (no lingering glow). */
.file-sidebar-file-item.policy-enforced {
animation: policy-glow-fade 4.5s ease-in-out forwards;
}
@keyframes policy-glow-fade {
0%,
24%,
48% {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent),
0 0 6px -2px var(--policy-glow);
}
12%,
36%,
60% {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent),
0 0 16px 0 var(--policy-glow);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
.file-sidebar-file-item.selected {
background-color: rgba(59, 130, 246, 0.12);
}
@@ -132,6 +132,9 @@ export interface FileItemPolicyRef {
name: string;
/** CSS colour for the badge (matches the policy's accent). */
accentColor: string;
/** True only just after the policy was applied — drives the one-off glow, so
* it doesn't replay on every reload of an already-enforced file. */
recent: boolean;
}
export interface FileItemProps {
@@ -201,6 +204,9 @@ export function FileItem({
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
// A just-applied policy (recent run) drives the one-off row glow.
const recentPolicy = policies.find((p) => p.recent);
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
const thumbPos =
hoverRect && resolvedThumbnail
@@ -214,7 +220,14 @@ export function FileItem({
<>
<div
ref={itemRef}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
style={
recentPolicy
? ({
"--policy-glow": recentPolicy.accentColor,
} as React.CSSProperties)
: undefined
}
onClick={() => onClick(fileId)}
draggable={draggable}
onDragStart={
@@ -29,7 +29,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import {
WorkbenchBarButtonConfig,
WorkbenchBarRenderContext,
@@ -150,6 +150,7 @@ export default function WorkbenchBar({
data: new Blob([buffer], { type: "application/pdf" }),
filename: fileToExport.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
fileId: stub?.id,
});
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
@@ -179,6 +180,7 @@ export default function WorkbenchBar({
data: file,
filename: file.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
fileId: stub?.id,
});
if (result.cancelled) continue;
if (!forceNewFile && stub && result.savedPath) {
@@ -51,6 +51,27 @@
pointer-events: auto;
}
/* A toast tied to a policy pulses a glow in that policy's accent (var set
inline as --toast-glow), so it's obvious which policy is being applied. */
.toast-item--glow {
animation: toast-glow-pulse 1.8s ease-in-out infinite;
}
@keyframes toast-glow-pulse {
0%,
100% {
box-shadow:
var(--shadow-lg),
0 0 0 1px color-mix(in srgb, var(--toast-glow) 35%, transparent),
0 0 10px -2px var(--toast-glow);
}
50% {
box-shadow:
var(--shadow-lg),
0 0 0 1px color-mix(in srgb, var(--toast-glow) 80%, transparent),
0 0 20px 0 var(--toast-glow);
}
}
/* Toast Alert Type Colors */
.toast-item--success {
background: var(--color-green-100);
@@ -1,3 +1,4 @@
import type { CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@app/components/toast/ToastContext";
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
@@ -64,7 +65,16 @@ export default function ToastRenderer() {
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
{grouped[loc].map((t) => {
return (
<div key={t.id} role="status" className={getToastItemClass(t)}>
<div
key={t.id}
role="status"
className={`${getToastItemClass(t)}${t.glowColor ? " toast-item--glow" : ""}`}
style={
t.glowColor
? ({ "--toast-glow": t.glowColor } as CSSProperties)
: undefined
}
>
{/* Top row: Icon + Title + Controls */}
<div className="toast-header">
{/* Icon */}
@@ -25,6 +25,10 @@ export interface ToastOptions {
id?: string;
/** If true, show chevron and collapse/expand animation. Defaults to true. */
expandable?: boolean;
/** Optional accent colour (any CSS colour, e.g. a `var(--color-…)`). When set,
* the toast pulses a glow in this colour — used to tie a toast to a policy's
* accent. */
glowColor?: string;
}
export interface ToastInstance extends Omit<
@@ -2,6 +2,9 @@ export interface DownloadRequest {
data: Blob | File;
filename: string;
localPath?: string;
/** Workspace fileId of the file being exported, when known. Lets export-time
* policy enforcement version the in-editor file (not just the download). */
fileId?: string;
}
export interface DownloadResult {
@@ -0,0 +1,29 @@
/**
* Export entry points call {@link downloadFileWithPolicy} instead of
* {@link downloadFile} so any "export"-triggered policy enforces on the file
* before it's downloaded. The enforcement itself is proprietary (a no-op in the
* core build via the `@app/services/policyExport` stub), and never hard-blocks:
* on failure the original file is downloaded.
*/
import {
downloadFile,
type DownloadRequest,
type DownloadResult,
} from "@app/services/downloadService";
import { enforceExportPolicies } from "@app/services/policyExport";
export async function downloadFileWithPolicy(
request: DownloadRequest,
): Promise<DownloadResult> {
// enforceExportPolicies only touches PDFs and is a no-op without an active
// export policy, so non-PDF / non-policy downloads pass straight through.
const input =
request.data instanceof File
? request.data
: new File([request.data], request.filename, {
type: request.data.type,
});
const [enforced] = await enforceExportPolicies([input], [request.fileId]);
return downloadFile({ ...request, data: enforced ?? request.data });
}
@@ -7,7 +7,7 @@ import {
setPageRotation,
addNewPage,
} from "@app/services/pdfiumService";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
// A4 dimensions in PDF points (72 dpi)
@@ -259,10 +259,10 @@ export class PDFExportService {
}
/**
* Download a single file
* Download a single file, applying any export-triggered policy first.
*/
downloadFile(blob: Blob, filename: string): void {
void downloadFile({ data: blob, filename });
void downloadFileWithPolicy({ data: blob, filename });
}
/**
@@ -0,0 +1,12 @@
/**
* Open-source stub for export-time policy enforcement. Policies are a proprietary
* feature, so in the core build there's nothing to enforce — this returns the
* files unchanged. The proprietary build shadows this module via the `@app/*`
* alias with the real implementation.
*/
export async function enforceExportPolicies(
files: File[],
_fileIds?: (string | undefined)[],
): Promise<File[]> {
return files;
}
@@ -2,6 +2,8 @@ import { StirlingFileStub } from "@app/types/fileContext";
import { fileStorage } from "@app/services/fileStorage";
import { zipFileService } from "@app/services/zipFileService";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
import { enforceExportPolicies } from "@app/services/policyExport";
/**
* Downloads a blob as a file using browser download API
@@ -27,10 +29,11 @@ export async function downloadFileFromStorage(
throw new Error(`File "${file.name}" not found in storage`);
}
await downloadFile({
await downloadFileWithPolicy({
data: stirlingFile,
filename: stirlingFile.name,
localPath: file.localFilePath,
fileId: file.id,
});
}
@@ -59,15 +62,15 @@ export async function downloadFilesAsZip(
throw new Error("No files provided for ZIP download");
}
// Convert stored files to File objects
// Convert stored files to File objects (tracking ids so export policies can
// version the in-editor file).
const filesToZip: File[] = [];
const fileIds: (string | undefined)[] = [];
for (const fileWithUrl of files) {
const lookupKey = fileWithUrl.id;
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
const stirlingFile = await fileStorage.getStirlingFile(fileWithUrl.id);
if (stirlingFile) {
// StirlingFile is already a File object!
filesToZip.push(stirlingFile);
fileIds.push(fileWithUrl.id);
}
}
@@ -75,6 +78,9 @@ export async function downloadFilesAsZip(
throw new Error("No valid files found in storage for ZIP download");
}
// Enforce any export-triggered policy on each PDF before they're zipped.
const enforced = await enforceExportPolicies(filesToZip, fileIds);
// Generate default filename if not provided
const finalZipFilename =
zipFilename ||
@@ -82,7 +88,7 @@ export async function downloadFilesAsZip(
// Create and download ZIP
const { zipFile } = await zipFileService.createZipFromFiles(
filesToZip,
enforced,
finalZipFilename,
);
await downloadFile({ data: zipFile, filename: finalZipFilename });
@@ -125,7 +131,7 @@ export async function downloadFiles(
* @param filename - Optional custom filename
*/
export function downloadFileObject(file: File, filename?: string): void {
void downloadFile({ data: file, filename: filename || file.name });
void downloadFileWithPolicy({ data: file, filename: filename || file.name });
}
/**