Redesign policy running (#6609)

# Description of Changes
Redesign policy running so the server is in charge of policy IDs and
running, to make it impossible to have the frontend miss the results.
This solves a minor bug that we currently have in policies, where if you
load a file and then refresh while the policy is running, you'll never
receive the outputted file.
This commit is contained in:
James Brunton
2026-06-17 16:18:50 +00:00
committed by GitHub
parent 3750111ffc
commit 13af10a6d1
18 changed files with 469 additions and 72 deletions
@@ -36,6 +36,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
@@ -73,6 +74,7 @@ public class PolicyController {
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@@ -143,6 +145,35 @@ public class PolicyController {
return ResponseEntity.ok(PolicyRunView.of(run));
}
@GetMapping("/runs")
@Operation(
summary = "List the caller's stored-policy runs",
description =
"Returns the caller's in-flight and recently-finished stored-policy runs (within"
+ " the run-retention window). The frontend reconciles these on load so a"
+ " run started before a refresh/crash is rediscovered and its outputs"
+ " collected, rather than orphaned on the backend. Ad-hoc runs (no"
+ " policy id) are excluded.")
public List<PolicyRunView> listRuns() {
return runRegistry.all().stream()
.filter(run -> run.getPolicyId() != null)
.filter(run -> ownedByCurrentUser(run.getRunId()))
.map(PolicyRunView::of)
.toList();
}
/**
* Whether the run is owned by the current user, derived purely from the existing scoping
* methods: stripping then re-applying the scope reproduces the run's key only when its owner
* prefix matches the caller's. No auth (single-user) owns everything. Avoids duplicating the
* scoped-key format here.
*/
private boolean ownedByCurrentUser(String runId) {
return jobOwnershipService
.createScopedJobKey(jobOwnershipService.extractJobId(runId))
.equals(runId);
}
// --- Policy management ---
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -81,13 +81,27 @@ public class PolicyEngine {
*/
public PolicyRunHandle submit(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
return submit(definition, inputs, listener, null);
}
/**
* As {@link #submit(PipelineDefinition, PolicyInputs, PolicyProgressListener)}, recording the
* originating stored policy's id on the run ({@code null} for ad-hoc pipelines). The id lets a
* client attribute a run it rediscovers via {@code GET /policies/runs} after losing local state
* (e.g. a refresh before it recorded the run), so a finished run is never orphaned server-side.
*/
public PolicyRunHandle submit(
PipelineDefinition definition,
PolicyInputs inputs,
PolicyProgressListener listener,
String policyId) {
// Ad-hoc run (no stored policy): bill whoever kicked it off and own the outputs as them
// too.
// Capture the principal on this (request) thread — it does not survive the hop onto the
// async
// worker.
String principal = currentActingPrincipal();
return submitForPrincipal(principal, principal, definition, inputs, listener);
return submitForPrincipal(principal, principal, policyId, definition, inputs, listener);
}
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
@@ -103,12 +117,13 @@ public class PolicyEngine {
String triggeringUser = currentActingPrincipal();
String fileOwner = triggeringUser != null ? triggeringUser : policy.owner();
return submitForPrincipal(
policy.owner(), fileOwner, policy.toDefinition(), inputs, listener);
policy.owner(), fileOwner, policy.id(), policy.toDefinition(), inputs, listener);
}
private PolicyRunHandle submitForPrincipal(
String billingPrincipal,
String fileOwner,
String policyId,
PipelineDefinition definition,
PolicyInputs inputs,
PolicyProgressListener listener) {
@@ -116,7 +131,7 @@ public class PolicyEngine {
// ownership check passes. No-op when security is off.
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
taskManager.createTask(runId);
PolicyRun run = new PolicyRun(runId, definition);
PolicyRun run = new PolicyRun(runId, policyId, definition);
registry.register(run);
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
PolicyProgressListener tracking = trackingListener(runId, run, listener);
@@ -17,6 +17,10 @@ import stirling.software.common.model.job.ResultFile;
public class PolicyRun {
private final String runId;
/** ID of the stored policy that produced this run; null for ad-hoc pipelines. */
private final String policyId;
private final PipelineDefinition definition;
private final Instant createdAt = Instant.now();
@@ -45,8 +49,9 @@ public class PolicyRun {
private volatile List<ResultFile> outputs = List.of();
private volatile Instant updatedAt = Instant.now();
public PolicyRun(String runId, PipelineDefinition definition) {
public PolicyRun(String runId, String policyId, PipelineDefinition definition) {
this.runId = runId;
this.policyId = policyId;
this.definition = definition;
}
@@ -10,23 +10,28 @@ import stirling.software.common.model.job.ResultFile;
*/
public record PolicyRunView(
String runId,
String policyId,
PolicyRunStatus status,
int currentStep,
int stepCount,
String error,
String errorCode,
Boolean errorSubscribed,
List<ResultFile> outputs) {
List<ResultFile> outputs,
/** When the run was created, epoch millis, so a rediscovered run shows its real age. */
long createdAt) {
public static PolicyRunView of(PolicyRun run) {
return new PolicyRunView(
run.getRunId(),
run.getPolicyId(),
run.getStatus(),
run.getCurrentStep(),
run.stepCount(),
run.getError(),
run.getErrorCode(),
run.getErrorSubscribed(),
run.getOutputs());
run.getOutputs(),
run.getCreatedAt().toEpochMilli());
}
}
@@ -95,7 +95,7 @@ class PolicyRunRegistryTest {
}
private PolicyRun register(String runId) {
PolicyRun run = new PolicyRun(runId, new PipelineDefinition(runId, List.of(), null));
PolicyRun run = new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), null));
registry.register(run);
return run;
}