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
@@ -222,6 +222,34 @@
margin: 0 0 var(--space-2);
}
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
directly below it carries data-first so the borders don't double up. */
.pol-subhead {
padding: 0.55rem 0.875rem 0.4rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-4);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
/* Position dropdown + custom-text input sitting on one row under the
"Output filename" subhead — the dropdown sizes to content, text fills. */
.pol-name-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.pol-name-row > :first-child {
flex: 0 0 auto;
}
.pol-name-row > :last-child {
flex: 1 1 auto;
min-width: 0;
}
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
.pol-field {
padding: 0.7rem 0.875rem;
@@ -112,14 +112,14 @@ describe("Policies right-sidebar surface", () => {
}
});
it("shows Security as active and the unbuilt categories as Coming soon", () => {
it("shows Security as active and the unbuilt categories as upgrade-gated", () => {
renderHost();
expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
// Ingestion, Compliance, Routing, Retention are locked for this release.
expect(screen.getAllByText("Coming soon")).toHaveLength(4);
expect(screen.getAllByText("Upgrade to enterprise")).toHaveLength(4);
});
it("does not open a Coming soon policy when its row is clicked", () => {
it("does not open an upgrade-gated policy when its row is clicked", () => {
renderHost();
fireEvent.click(screen.getByText("Ingestion"));
// The locked row isn't a button — we stay on the list, nothing opens.
@@ -151,7 +151,9 @@ export function PoliciesSection({
</IconBadge>
<span className="pol-row-label">{cat.label}</span>
<span className="pol-row-trail">
<span className="pol-row-soon">Coming soon</span>
<span className="pol-row-soon">
Upgrade to enterprise
</span>
</span>
</div>
);
@@ -80,8 +80,7 @@ interface PolicySetupWizardProps {
* The shared policy wizard, used for both setup and edit. Two steps: Workflow
* (the tool pipeline, reusing the Watch Folders builder) → Settings (the policy
* fields + output/retry config). The workflow builder is kept mounted across
* steps so the final action can trigger its save. (A Sources step exists in
* code, gated off by SOURCES_IN_FLOW, for when non-editor sources return.)
* steps so the final action can trigger its save.
*/
export function PolicySetupWizard({
category,
@@ -113,8 +112,8 @@ export function PolicySetupWizard({
);
const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0);
const [scopeTypes, setScopeTypes] = useState<string[]>(initial.scopeTypes);
// Reviewer is no longer configured in the flow (there's no human-review step),
// but the field is kept in the saved policy, defaulted to the signed-in user.
// Reviewer isn't shown in the flow; the field is still saved on the policy,
// defaulted to the signed-in user.
const reviewerEmail = initial.reviewerEmail || user?.email || "";
// Output + retry settings — the real, working folder settings (the engine
// applies them). Pre-filled from the backing folder in edit mode.
@@ -129,6 +128,10 @@ export function PolicySetupWizard({
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
initialFolder?.retryDelayMinutes ?? 5,
);
// The editor event this policy runs on: input on upload, or output on export.
const [runOn, setRunOn] = useState<"upload" | "export">(
initial.runOn ?? "upload",
);
const workflowSave = useRef<(() => void) | null>(null);
const [submitting, setSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -197,6 +200,7 @@ export function PolicySetupWizard({
scopeTypes: scopeNarrow ? scopeTypes : [],
reviewerEmail,
folder: {
runOn,
outputMode,
outputName: outputName.trim(),
outputNamePosition,
@@ -229,6 +233,7 @@ export function PolicySetupWizard({
scopeTypes: scopeNarrow ? scopeTypes : [],
reviewerEmail,
folder: {
runOn,
outputMode,
outputName: outputName.trim(),
outputNamePosition,
@@ -329,35 +334,69 @@ export function PolicySetupWizard({
{step === 2 && (
<>
<p className="pol-desc">{category.desc}</p>
<Card padding="none">
{config.fields.map((f, i) => (
<PolicyFieldRow
key={f.key}
field={f}
value={fieldValues[f.key]}
first={i === 0}
onChange={(v) =>
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
}
/>
))}
</Card>
{config.fields.length > 0 && (
<Card padding="none">
{config.fields.map((f, i) => (
<PolicyFieldRow
key={f.key}
field={f}
value={fieldValues[f.key]}
first={i === 0}
onChange={(v) =>
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
}
/>
))}
</Card>
)}
{/* Real, working output + retry settings (applied by the engine). */}
<p className="pol-section-label">Output &amp; retries</p>
<Card padding="none">
{/* The editor event the policy runs on: input on upload, or
output on export (enforced before the file is exported). */}
<div className="pol-subhead">Run on</div>
<div className="pol-field" data-first>
<SettingsRow
label="Output"
label="Run on"
control={
<Select
inputSize="sm"
value={runOn}
onChange={(e) =>
setRunOn(e.target.value as "upload" | "export")
}
aria-label="Run on"
options={[
{ value: "upload", label: "Upload" },
{ value: "export", label: "Export" },
]}
/>
}
/>
</div>
<div className="pol-subhead">Output</div>
<div className="pol-field" data-first>
<SettingsRow
label="Output as"
control={
<Select
inputSize="sm"
value={outputMode}
onChange={(e) =>
setOutputMode(
e.target.value as "new_file" | "new_version",
)
}
onChange={(e) => {
const mode = e.target.value as
| "new_file"
| "new_version";
setOutputMode(mode);
// Auto-number only applies to new files; a new version
// replaces the file in place, so fall back to suffix.
if (
mode === "new_version" &&
outputNamePosition === "auto-number"
) {
setOutputNamePosition("suffix");
}
}}
aria-label="Output mode"
options={[
{ value: "new_file", label: "New file" },
@@ -367,41 +406,40 @@ export function PolicySetupWizard({
}
/>
</div>
<div className="pol-field">
<SettingsRow
label="Add to filename"
control={
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(e) =>
setOutputNamePosition(
e.target.value as "prefix" | "suffix" | "auto-number",
)
}
aria-label="Add to filename"
options={[
{ value: "prefix", label: "Prefix" },
{ value: "suffix", label: "Suffix" },
{ value: "auto-number", label: "Auto-number" },
]}
/>
}
/>
</div>
<div className="pol-field">
<SettingsRow
label="Text to add"
control={
{/* Output filename: position + custom text together as one row. */}
<div className="pol-subhead">Output filename</div>
<div className="pol-field" data-first>
<div className="pol-name-row">
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(e) =>
setOutputNamePosition(
e.target.value as "prefix" | "suffix" | "auto-number",
)
}
aria-label="Filename position"
options={[
{ value: "prefix", label: "Prefix" },
{ value: "suffix", label: "Suffix" },
// Auto-number only makes sense for separate new files.
...(outputMode === "new_file"
? [{ value: "auto-number", label: "Auto-number" }]
: []),
]}
/>
{/* Auto-number names the file itself, so there's no custom
text to add — only show the input for prefix/suffix. */}
{outputNamePosition !== "auto-number" && (
<Input
inputSize="sm"
value={outputName}
onChange={(e) => setOutputName(e.target.value)}
placeholder="optional"
aria-label="Text to add"
placeholder="Text to add (optional)"
aria-label="Filename text"
/>
}
/>
)}
</div>
</div>
<div className="pol-field">
<SettingsRow
@@ -96,7 +96,6 @@ export function PolicyToolConfig({
</div>
{tool.enabled &&
(tool.operation === "redact" ? (
// Redact config is reduced to just the PII type picker.
<div className="pol-tool-body">
<PolicyRedactConfig
parameters={tool.parameters}
@@ -109,8 +108,8 @@ export function PolicyToolConfig({
// are fixed in the policy preset), so no settings are shown.
<></>
) : tool.operation === "watermark" ? (
// Watermark: full settings minus the "Flatten PDF pages to
// images" toggle (hidden), with flatten forced on.
// Watermark settings with flatten hidden + forced on (see
// PolicyWatermarkConfig).
<div className="pol-tool-body">
<PolicyWatermarkConfig
parameters={tool.parameters}
@@ -73,14 +73,19 @@ export function usePolicyAutoRun(): void {
useEffect(() => {
if (!POLICIES_ENABLED) return;
const active = Object.entries(policies).filter(
([, s]) => s.configured && s.status === "active" && s.backendId,
([, s]) =>
s.configured &&
s.status === "active" &&
s.backendId &&
// Only auto-run on upload when the policy is set to run on upload
// (export-triggered policies enforce at export time instead).
(s.runOn ?? "upload") === "upload",
);
for (const [categoryId, s] of active) {
for (const stub of fileStubs) {
const key = dispatchKey(categoryId, stub.id);
// Skip if already run (persisted) or a dispatch is mid-flight (this
// session) — runPolicyOnFile now waits for the file's bytes to commit,
// so the in-memory guard, not an eager mark, prevents double-firing.
// Skip if already run (persisted) or a dispatch is in flight — the
// in-memory guard prevents double-firing during the async wait.
if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) {
continue;
}