Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e198935758 | ||
|
|
f66dd297c4 |
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
|
||||
/**
|
||||
* Save-time validation for one tool's step parameters. Implementations cover the tools whose
|
||||
* endpoints reject or no-op on missing configuration, so a policy that would fail on every run (or
|
||||
* silently do nothing) is refused at save instead. Steps whose operation no validator supports pass
|
||||
* unchecked.
|
||||
*/
|
||||
public interface PolicyStepValidator {
|
||||
|
||||
/** Whether this validator covers the step's operation (endpoint path). */
|
||||
boolean supports(String operation);
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a user-presentable message when the step's parameters
|
||||
* cannot produce a successful run
|
||||
*/
|
||||
void validate(PipelineStep step);
|
||||
}
|
||||
+11
-4
@@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.proprietary.policy.input.InputSource;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.TriggerConfig;
|
||||
import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
||||
@@ -18,10 +19,10 @@ import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
|
||||
/**
|
||||
* Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean
|
||||
* that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger
|
||||
* is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} must
|
||||
* resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts.
|
||||
* Validates a policy at save time by delegating each facet (trigger, sources, output, steps) to the
|
||||
* bean that handles its type, so a misconfiguration fails fast rather than at run time. A null
|
||||
* trigger is a manual-only policy and skips trigger validation. Each referenced {@code sourceId}
|
||||
* must resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -31,6 +32,7 @@ public class PolicyValidator {
|
||||
private final List<PolicyTrigger> triggers;
|
||||
private final List<InputSource> inputSources;
|
||||
private final List<PolicyOutputSink> outputSinks;
|
||||
private final List<PolicyStepValidator> stepValidators;
|
||||
private final SourceStore sourceStore;
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,11 @@ public class PolicyValidator {
|
||||
inputSourceFor(spec).validate(spec);
|
||||
}
|
||||
validateOutput(policy.output());
|
||||
for (PipelineStep step : policy.steps()) {
|
||||
stepValidators.stream()
|
||||
.filter(validator -> validator.supports(step.operation()))
|
||||
.forEach(validator -> validator.validate(step));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package stirling.software.proprietary.policy.engine.steps;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.proprietary.policy.engine.PolicyStepValidator;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
|
||||
/**
|
||||
* An automatic redact step with nothing to redact silently no-ops — a "security" policy that
|
||||
* reports success while removing nothing — so it is refused at save. Manual-mode redaction carries
|
||||
* its regions differently and is not checked here.
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class RedactStepValidator implements PolicyStepValidator {
|
||||
|
||||
private static final String ENDPOINT = "/api/v1/security/auto-redact";
|
||||
|
||||
@Override
|
||||
public boolean supports(String operation) {
|
||||
return ENDPOINT.equals(operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(PipelineStep step) {
|
||||
Object mode = step.parameters().getOrDefault("mode", "automatic");
|
||||
if (!"automatic".equals(mode)) {
|
||||
return;
|
||||
}
|
||||
Object words = step.parameters().get("wordsToRedact");
|
||||
if (!(words instanceof List<?> list) || list.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"redact step needs at least one pattern to redact — with none it does"
|
||||
+ " nothing");
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package stirling.software.proprietary.policy.engine.steps;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.proprietary.policy.engine.PolicyStepValidator;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
|
||||
/**
|
||||
* A text watermark with no text fails the add-watermark endpoint on every document, so it is
|
||||
* refused at save. Image watermarks carry their content in {@code fileParameters} and are not
|
||||
* checked here.
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class WatermarkStepValidator implements PolicyStepValidator {
|
||||
|
||||
private static final String ENDPOINT = "/api/v1/security/add-watermark";
|
||||
|
||||
@Override
|
||||
public boolean supports(String operation) {
|
||||
return ENDPOINT.equals(operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(PipelineStep step) {
|
||||
Object type = step.parameters().get("watermarkType");
|
||||
if ("image".equals(type)) {
|
||||
return;
|
||||
}
|
||||
Object text = step.parameters().get("watermarkText");
|
||||
if (!(text instanceof String s) || s.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"watermark step needs watermark text — without it the policy fails on every"
|
||||
+ " document");
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-1
@@ -17,9 +17,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.policy.engine.steps.RedactStepValidator;
|
||||
import stirling.software.proprietary.policy.engine.steps.WatermarkStepValidator;
|
||||
import stirling.software.proprietary.policy.input.InputSource;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.TriggerConfig;
|
||||
import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
||||
@@ -43,7 +46,11 @@ class PolicyValidatorTest {
|
||||
void setUp() {
|
||||
validator =
|
||||
new PolicyValidator(
|
||||
List.of(trigger), List.of(inputSource), List.of(outputSink), sourceStore);
|
||||
List.of(trigger),
|
||||
List.of(inputSource),
|
||||
List.of(outputSink),
|
||||
List.of(new WatermarkStepValidator(), new RedactStepValidator()),
|
||||
sourceStore);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -115,6 +122,69 @@ class PolicyValidatorTest {
|
||||
assertTrue(ex.getMessage().contains("unknown trigger type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsATextWatermarkStepWithNoText() {
|
||||
when(trigger.type()).thenReturn("schedule");
|
||||
when(inputSource.supports(any())).thenReturn(true);
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
Policy policy =
|
||||
withSteps(
|
||||
new PipelineStep(
|
||||
"/api/v1/security/add-watermark",
|
||||
Map.of("watermarkType", "text", "watermarkText", " "),
|
||||
Map.of()));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> validator.validate(policy));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAutomaticRedactStepWithNoPatterns() {
|
||||
when(trigger.type()).thenReturn("schedule");
|
||||
when(inputSource.supports(any())).thenReturn(true);
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
Policy policy =
|
||||
withSteps(
|
||||
new PipelineStep(
|
||||
"/api/v1/security/auto-redact",
|
||||
Map.of("mode", "automatic", "wordsToRedact", List.of()),
|
||||
Map.of()));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> validator.validate(policy));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsConfiguredSecuritySteps() {
|
||||
when(trigger.type()).thenReturn("schedule");
|
||||
when(inputSource.supports(any())).thenReturn(true);
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
Policy policy =
|
||||
withSteps(
|
||||
new PipelineStep(
|
||||
"/api/v1/security/add-watermark",
|
||||
Map.of("watermarkType", "text", "watermarkText", "Confidential"),
|
||||
Map.of()),
|
||||
new PipelineStep(
|
||||
"/api/v1/security/auto-redact",
|
||||
Map.of("mode", "automatic", "wordsToRedact", List.of("\\d+")),
|
||||
Map.of()));
|
||||
|
||||
validator.validate(policy);
|
||||
}
|
||||
|
||||
/** A valid schedule-triggered policy carrying the given steps. */
|
||||
private Policy withSteps(PipelineStep... steps) {
|
||||
Policy base = policy("schedule");
|
||||
return new Policy(
|
||||
base.id(),
|
||||
base.name(),
|
||||
base.owner(),
|
||||
base.enabled(),
|
||||
base.trigger(),
|
||||
base.sourceIds(),
|
||||
List.of(steps),
|
||||
base.output());
|
||||
}
|
||||
|
||||
private Policy policy(String triggerType) {
|
||||
return new Policy(
|
||||
"p1",
|
||||
|
||||
@@ -7586,6 +7586,9 @@ label = "Docs enforced"
|
||||
description = "Configured but not firing"
|
||||
label = "Paused"
|
||||
|
||||
[portal.policies.wizard]
|
||||
finishConfiguring = "Finish configuring \"{{capability}}\""
|
||||
|
||||
[portal.policies.wizard.actions]
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
@@ -10720,6 +10723,7 @@ vertical = "Vertical Spacing"
|
||||
width = "Width Spacing"
|
||||
|
||||
[watermark.settings.text]
|
||||
label = "Watermark text"
|
||||
placeholder = "Enter watermark text"
|
||||
|
||||
[watermark.steps]
|
||||
|
||||
+4
@@ -25,6 +25,8 @@ interface AddWatermarkSingleStepSettingsProps {
|
||||
showFlatten?: boolean;
|
||||
/** When true, lock to text watermarks: hide the type selector and image option (e.g. in policies). */
|
||||
textOnly?: boolean;
|
||||
/** Mark the watermark text as required (policies refuse empty text at save). */
|
||||
requireText?: boolean;
|
||||
}
|
||||
|
||||
const AddWatermarkSingleStepSettings = ({
|
||||
@@ -33,6 +35,7 @@ const AddWatermarkSingleStepSettings = ({
|
||||
disabled = false,
|
||||
showFlatten = true,
|
||||
textOnly = false,
|
||||
requireText = false,
|
||||
}: AddWatermarkSingleStepSettingsProps) => {
|
||||
const isText = textOnly || parameters.watermarkType === "text";
|
||||
const isImage = !textOnly && parameters.watermarkType === "image";
|
||||
@@ -55,6 +58,7 @@ const AddWatermarkSingleStepSettings = ({
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
requireText={requireText}
|
||||
/>
|
||||
<WatermarkTextStyle
|
||||
parameters={parameters}
|
||||
|
||||
@@ -11,12 +11,16 @@ interface WatermarkWordingProps {
|
||||
value: AddWatermarkParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
/** Render a visible label with a required asterisk (e.g. in policies, where
|
||||
* empty text is refused at save). */
|
||||
requireText?: boolean;
|
||||
}
|
||||
|
||||
const WatermarkWording = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
requireText = false,
|
||||
}: WatermarkWordingProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -29,6 +33,12 @@ const WatermarkWording = ({
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={
|
||||
requireText
|
||||
? t("watermark.settings.text.label", "Watermark text")
|
||||
: undefined
|
||||
}
|
||||
withAsterisk={requireText}
|
||||
placeholder={t(
|
||||
"watermark.settings.text.placeholder",
|
||||
"Enter watermark text",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type PipelineStep,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
import { HttpError, errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
policyEndpoint,
|
||||
policyStepFromWire,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
type PolicyToolId,
|
||||
type PolicyToolStep,
|
||||
} from "@app/policies/operations";
|
||||
import { isPolicyStepConfigured } from "@app/policies/stepValidity";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
@@ -271,6 +273,23 @@ function PolicySetupWizardBody({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
|
||||
// Mirrors the backend's save-time step validation: while any enabled
|
||||
// capability is missing required config, save stays disabled.
|
||||
const misconfiguredTool = useMemo(
|
||||
() => enabledTools.find((tl) => !isPolicyStepConfigured(tl)) ?? null,
|
||||
[enabledTools],
|
||||
);
|
||||
const hasMisconfiguredTool = misconfiguredTool !== null;
|
||||
// Hover/focus hint on the gated buttons naming what still needs config —
|
||||
// a bare disabled button explains nothing.
|
||||
const gateHint = misconfiguredTool
|
||||
? t("portal.policies.wizard.finishConfiguring", {
|
||||
capability: t(
|
||||
CAPABILITY_META[misconfiguredTool.toolId].labelKey,
|
||||
CAPABILITY_META[misconfiguredTool.toolId].labelEn,
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
|
||||
setTools((prev) =>
|
||||
@@ -321,9 +340,12 @@ function PolicySetupWizardBody({
|
||||
retryDelayMinutes,
|
||||
steps,
|
||||
});
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setSubmitting(false);
|
||||
setError(t("portal.policies.wizard.errors.saveFailed"));
|
||||
// The backend validates at save (PolicyValidator) and returns a specific
|
||||
// reason — show it rather than a generic failure when we have one.
|
||||
const detail = e instanceof HttpError ? errorMessage(e) : null;
|
||||
setError(detail ?? t("portal.policies.wizard.errors.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,13 +375,15 @@ function PolicySetupWizardBody({
|
||||
{t("portal.policies.wizard.actions.cancel")}
|
||||
</Button>
|
||||
{step === "workflow" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={() => setStep("settings")}
|
||||
>
|
||||
{t("portal.policies.wizard.actions.continue")}
|
||||
</Button>
|
||||
<span title={gateHint} style={{ marginLeft: "auto" }}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setStep("settings")}
|
||||
disabled={hasMisconfiguredTool}
|
||||
>
|
||||
{t("portal.policies.wizard.actions.continue")}
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
@@ -370,11 +394,18 @@ function PolicySetupWizardBody({
|
||||
>
|
||||
{t("portal.policies.wizard.actions.back")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={submit} loading={submitting}>
|
||||
{isEdit
|
||||
? t("portal.policies.wizard.actions.saveChanges")
|
||||
: t("portal.policies.wizard.actions.enablePolicy")}
|
||||
</Button>
|
||||
<span title={gateHint}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
loading={submitting}
|
||||
disabled={hasMisconfiguredTool}
|
||||
>
|
||||
{isEdit
|
||||
? t("portal.policies.wizard.actions.saveChanges")
|
||||
: t("portal.policies.wizard.actions.enablePolicy")}
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FormField } from "@app/ui/FormField";
|
||||
import { MultiSelect } from "@app/ui/MultiSelect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PII_PRESETS } from "@app/data/policyDefinitions";
|
||||
@@ -47,20 +48,21 @@ export function PolicyPiiField({
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
inputSize="sm"
|
||||
aria-label={t("policies.pii.fieldLabel", "PII to redact")}
|
||||
placeholder={t("policies.pii.placeholder", "Select PII types")}
|
||||
data={PII_PRESETS.map((p) => ({
|
||||
value: p.value,
|
||||
// Preset labels are catalog data — keyed by preset value with the
|
||||
// English label as fallback.
|
||||
label: t(`policies.pii.${p.value}`, p.label),
|
||||
}))}
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
clearable
|
||||
/>
|
||||
<FormField label={t("policies.pii.fieldLabel", "PII to redact")} required>
|
||||
<MultiSelect
|
||||
inputSize="sm"
|
||||
placeholder={t("policies.pii.placeholder", "Select PII types")}
|
||||
data={PII_PRESETS.map((p) => ({
|
||||
value: p.value,
|
||||
// Preset labels are catalog data — keyed by preset value with the
|
||||
// English label as fallback.
|
||||
label: t(`policies.pii.${p.value}`, p.label),
|
||||
}))}
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
clearable
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export function PolicyWatermarkConfig({
|
||||
disabled={disabled}
|
||||
showFlatten={false}
|
||||
textOnly
|
||||
requireText
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PolicyToolStep } from "@app/policies/operations";
|
||||
|
||||
/**
|
||||
* Whether a policy step is configured well enough to run. Mirrors the backend's
|
||||
* save-time PolicyStepValidator rules so the wizard can disable save instead of
|
||||
* letting the request bounce: a text watermark needs its text, an automatic
|
||||
* redact needs at least one pattern. Tools with no rule are always valid.
|
||||
*/
|
||||
export function isPolicyStepConfigured(step: PolicyToolStep): boolean {
|
||||
if (step.toolId === "watermark") {
|
||||
const { watermarkType, watermarkText } = step.params;
|
||||
if (watermarkType === "image") return true;
|
||||
return typeof watermarkText === "string" && watermarkText.trim() !== "";
|
||||
}
|
||||
if (step.toolId === "redact") {
|
||||
const { wordsToRedact } = step.params;
|
||||
return Array.isArray(wordsToRedact) && wordsToRedact.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user