Compare commits

...
12 changed files with 282 additions and 34 deletions
@@ -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);
}
@@ -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));
}
}
/**
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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]
@@ -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;
}