From 08b08aa8a1a82d7f7ff2dc14f30559b96b954b80 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:27:20 +0000 Subject: [PATCH] Let everyone read the failures they caused (Review Flow PR 3) (#7477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review Flow PR 3. Stacked on #7296. A recorded failure becomes readable by the person who caused it. ## What changes Before this, reading or triaging a failure required leader permissions: `FileRunEventController.requireFailureReviewAllowed()` returned 403 to anyone who could not edit policies. #7296 lets any user report a failure, so they could file into a queue they could never read. That gate is removed from the endpoints and the decision moves into `FileRunEventService`: | Caller | Reads and closes | |---|---| | Team leader or admin | the whole team's failures (unchanged) | | Anyone else | only failures where `actor` is them | | Team unresolvable | nothing | | Name unresolvable | nothing | `GET /kinds` is also opened. It returns static enum metadata, and a member needs it to render failures they can already see. ## Additions - An `actor` predicate on both list queries in `FileRunEventRepository`, threaded through `FileRunEventStore.list`. - `ReadScope` (permitted, teamId, actor) replacing `TeamScope`, with `wholeTeam` / `mine` / `denied` factories. - An actor filter on `dispatch`, so acting on another person's row answers **404, not 403** — the same response as an id that does not exist. ## Fixes - **`report()` filed rows under the wrong team.** It took the team from the read scope, which returns null for a caller who cannot be named, so such a report landed unteamed in the bucket every team shares. It now uses a dedicated `currentTeamId()`. - **`forgetFiles` narrows to the caller even for a leader.** File ids are minted by each client, so scoping on team alone would let one caller close a colleague's incidents by naming ids. - The controller no longer injects `PolicyManagementAuthority` or `ApplicationProperties`; with the gate gone it decides nothing. ## Team isolation Unchanged and covered by database-backed tests rather than mocks. `FileRunEventStoreDbTest` asserts that a caller with a team sees only their own team's rows and never the unteamed ones, and that the actor predicate narrows within a team without ever widening across one. Delete either clause from the JPQL and one of those tests fails. No endpoint accepts a team parameter; the team always comes from the authenticated principal. **Attribution is fixed here too, because this PR depends on it.** A failure's actor was read from the MDC audit principal, which carries the BILLING identity — for a stored policy, always its owner. Since reads are now narrowed to the rows you are the actor on, a wrong actor means the member who caused a failure and holds the document reads nothing, while the policy owner is handed incidents from runs they never triggered. The triggering user is now carried on the run, separate from the billing principal and the output owner, and is null for a trigger-fired sweep so an unattended failure stays ownerless. `PolicyFailureAttributionTest` runs the real engine, recorder, store and service together. The two sides used to assert independently — the engine's test matched the actor with `any()`, which is how this went unnoticed. ## How to test Needs a proprietary or SaaS build with login enabled and two accounts in the same team, one a leader and one not. `task dev:all` gives you the stack. 1. **As the member**, fail a tool: open a PDF and run **Remove Password** with a wrong password. 2. **Still as the member**, go to `/processor/documents` → **Failures**. Before this PR you got nothing here. Now you see your own row, and only yours. 3. **As the leader**, open the same view. You see the whole team's rows, including the member's. 4. **Member cannot reach a colleague's row.** As the leader, copy a row's id from **Show raw JSON**. As the member, `POST /api/v1/file-run-events/{thatId}/actions/DISMISS`. It answers **404**, and the row is untouched — it must not answer 403, which would confirm the row exists. 5. **Member can close their own.** Dismiss your own row as the member. It leaves the default view. 6. **Deleting a file only closes your own rows.** As the leader, delete a file in your editor. The member's incidents are untouched even if the leader's client happened to name the same ids. ## Migration None. `actor` is an existing column; this only adds predicates to existing queries. --- .../failure/FileRunEventController.java | 57 ++-- .../failure/FileRunEventRepository.java | 11 +- .../failure/FileRunEventService.java | 91 ++++-- .../failure/FileRunEventStore.java | 9 +- .../policy/engine/PolicyEngine.java | 30 +- .../proprietary/policy/model/PolicyRun.java | 20 +- .../failure/FileRunEventControllerTest.java | 112 +++++--- .../FileRunEventHttpIntegrationTest.java | 7 +- .../failure/FileRunEventServiceTest.java | 116 ++++++-- .../failure/FileRunEventStoreDbTest.java | 38 ++- .../failure/FileRunEventStoreTest.java | 12 +- .../InMemoryFileRunEventRepository.java | 23 +- .../failure/PolicyFailureAttributionTest.java | 264 ++++++++++++++++++ .../failure/PolicyFailureRecorderTest.java | 30 +- .../controller/PolicyControllerTest.java | 10 +- .../policy/engine/PolicyEngineTest.java | 152 +++++++++- .../policy/engine/PolicyRunRegistryTest.java | 1 + 17 files changed, 800 insertions(+), 183 deletions(-) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java index a14f243678..aac1c3364b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java @@ -22,15 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.model.ApplicationProperties; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - /** - * Read and triage recorded failures for the caller's team. Note the absence of a team parameter: - * the team comes from the authenticated principal, never the request. + * Read and triage recorded failures. Note the absence of a team parameter: the team comes from the + * authenticated principal, never the request. * - *

Reviewing failures is a leader-level capability, gated the same way policy editing is: see - * {@link #requireFailureReviewAllowed()}. + *

Every endpoint is open to any authenticated user and scoped in the service instead: a leader + * reads and closes the whole team's failures, everyone else their own. Nothing here decides who may + * do what, so the two cannot drift apart. */ @Slf4j @RestController @@ -46,21 +44,21 @@ public class FileRunEventController { private static final int DEFAULT_LIMIT = 50; private final FileRunEventService service; - private final PolicyManagementAuthority policyManagementAuthority; - private final ApplicationProperties applicationProperties; @GetMapping @Operation( summary = "List recorded failures", description = - "Failures recorded for the caller's team, newest first. Each row carries its" - + " available actions already resolved.") + "Failures the caller may see, newest first: their team's for a leader, their own" + + " for everyone else. Each row carries its available actions already" + + " resolved.") public FileRunEventsResponse list( // Spring's converter 400s on a value outside the enum, so no hand-rolled parse. @RequestParam(required = false) FileRunEventStatus status, @RequestParam(required = false) String kindId, @RequestParam(required = false) Integer limit) { - requireFailureReviewAllowed(); + // No role gate: the service scopes the read instead, so a member gets their own failures + // and a leader the team's. int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT); List events = @@ -82,7 +80,8 @@ public class FileRunEventController { @PathVariable String eventId, @PathVariable String actionId, @RequestBody(required = false) ActionRequest request) { - requireFailureReviewAllowed(); + // No role gate: the service decides, which lets someone close their own failure while + // still keeping a colleague's out of reach. Map inputs = request == null ? Map.of() : request.safeInputs(); try { FileRunEvent updated = service.dispatch(eventId, actionId, inputs); @@ -97,9 +96,9 @@ public class FileRunEventController { summary = "Report a failure hit in the editor", description = "For failures the server never sees, because the editor calls tools directly." - + " Open to any authenticated user, unlike the read and triage endpoints:" - + " whoever's work failed can say so, and a leader reviews it. Rejected" - + " with 400 if it names more files than one report may carry.") + + " Open to any authenticated user: whoever's work failed can say so, and" + + " reads it back scoped to themselves. Rejected with 400 if it names" + + " more files than one report may carry.") public ResponseEntity report(@RequestBody EditorFailureReport report) { if (report == null || !report.hasOperation()) { throw new ResponseStatusException( @@ -128,8 +127,8 @@ public class FileRunEventController { summary = "Close the incidents about files deleted from the editor", description = "Deleting the document leaves nothing to act on, so its incidents drop out of" - + " the queue while the rows stay for audit. Open to any authenticated" - + " user, and applies only to their own editor rows.") + + " the queue while the rows stay for audit. Applies only to the" + + " caller's own editor rows, however senior they are.") public ResponseEntity filesRemoved(@RequestBody(required = false) RemovedFiles request) { service.forgetFiles(request == null ? List.of() : request.safeFileIds()); // No body: the editor is telling the server, not asking it anything. @@ -143,29 +142,11 @@ public class FileRunEventController { "The failure registry. Lets a client describe kinds it was not built with, and" + " doubles as the probe for whether failure tracking exists at all.") public List kinds() { - requireFailureReviewAllowed(); + // The registry is copy and metadata, not anyone's data, and a member needs it to render the + // failures they can already see. return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList(); } - /** - * Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code - * PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who - * manages a team's automation: a member can trigger runs, a leader reviews them. - * - *

Login disabled means a single-user deployment with no roles to tell apart, the same - * carve-out the policy endpoints make. Team scoping is separate, and lives in the service. - */ - private void requireFailureReviewAllowed() { - if (!applicationProperties.getSecurity().isEnableLogin()) { - return; - } - if (!policyManagementAuthority.canEditPolicies()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Recorded failures may only be reviewed by a team leader"); - } - } - /** * A closed row is a conflict rather than a bad request: the request was well-formed and would * have been valid a moment earlier. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java index 1bee2398f7..1930b36e6a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java @@ -20,26 +20,33 @@ public interface FileRunEventRepository extends JpaRepository{@code actor} narrows to one person's own failures. Null means the whole team, which only + * a leader ever asks for: see {@code FileRunEventService#readScope}. */ @Query( "select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or" + " e.teamId = :teamId) and e.status in :statuses" - + " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc") + + " and (:kindId is null or e.kindId = :kindId)" + + " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc") List findByTeamAndStatusIn( @Param("teamId") Long teamId, @Param("statuses") List statuses, @Param("kindId") String kindId, + @Param("actor") String actor, Pageable pageable); /** As {@link #findByTeamAndStatusIn} but for exactly one status, for the surface's filters. */ @Query( "select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or" + " e.teamId = :teamId) and e.status = :status" - + " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc") + + " and (:kindId is null or e.kindId = :kindId)" + + " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc") List findByTeamAndStatus( @Param("teamId") Long teamId, @Param("status") FileRunEventStatus status, @Param("kindId") String kindId, + @Param("actor") String actor, Pageable pageable); /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java index 57eb4698e4..fa858b6e22 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java @@ -13,12 +13,14 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; /** - * Reads and acts on incidents for the calling user's team. + * Reads and acts on the incidents the calling user is allowed to see, which is where that decision + * is made: a leader reads and closes the whole team's failures, everyone else their own. Keeping it + * here rather than on the endpoints means the read and the triage cannot drift apart. * *

Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the * team always comes from the authenticated principal, and scoping applies only when login is * enabled so single-user deployments keep working. When the team cannot be resolved the caller - * reads nothing; see {@link #scope()}. + * reads nothing; see {@link #readScope()}. */ @Slf4j @Service @@ -41,7 +43,9 @@ public class FileRunEventService { */ public List report(EditorFailureReport report) { FailureKind kind = FailureKind.byErrorCode(report.errorCode()).orElse(FailureKind.UNKNOWN); - Long teamId = scope().teamId(); + // The caller's team, not their read scope: recording is open to everyone, and a reader who + // may see nothing still has their failure filed under the team it happened in. + Long teamId = currentTeamId(); String actor = currentActor(); String detail = detailFor(report); @@ -81,10 +85,14 @@ public class FileRunEventService { * cleared cache or another device never will. Rows left open that way are retention's problem, * not this method's. * + *

Narrowed to the caller's own rows however senior they are, which is why it passes {@link + * #currentActor()} rather than the read scope's actor: file ids are minted by each client, so a + * leader reading with a null actor would match every unattributed row in the team. + * * @return how many incidents were closed */ public int forgetFiles(List fileIds) { - TeamScope scope = scope(); + ReadScope scope = readScope(); if (!scope.permitted()) { return 0; } @@ -92,13 +100,16 @@ public class FileRunEventService { return store.markFilesRemoved(scope.teamId(), currentActor(), named); } - /** The calling user's events, newest first. Empty when their team cannot be resolved. */ + /** + * The events the caller may read, newest first: the team's for a leader, their own for everyone + * else. Empty when their team cannot be resolved. + */ public List list(FileRunEventStatus status, String kindId, int limit) { - TeamScope scope = scope(); + ReadScope scope = readScope(); if (!scope.permitted()) { return List.of(); } - return store.list(scope.teamId(), status, kindId, limit); + return store.list(scope.teamId(), status, kindId, scope.actor(), limit); } /** @@ -108,7 +119,13 @@ public class FileRunEventService { * event's kind does not declare the action, or the event is already closed */ public FileRunEvent dispatch(String eventId, String actionId, Map inputs) { - TeamScope scope = scope(); + // Whoever can see it can close it: a leader for the whole team, everyone else for the + // failures they caused. Someone who fixes their own problem should not have to ask a leader + // to clear the row. + // + // Closing the row is all this covers. Acting on the document behind it, such as supplying a + // password for a retry, would need its own permission, and no such action exists yet. + ReadScope scope = readScope(); if (!scope.permitted()) { // Reported as "no such event", the same as an id from another team, so the response // does @@ -118,6 +135,12 @@ public class FileRunEventService { } FileRunEvent event = store.find(eventId, scope.teamId()) + // Reported as "no such event" rather than a refusal, so a member cannot + // learn that a colleague's incident exists by trying to close it. + .filter( + found -> + scope.actor() == null + || scope.actor().equals(found.actor())) .orElseThrow( () -> new FailureActionException( @@ -179,31 +202,55 @@ public class FileRunEventService { } /** - * Which rows the caller may touch, since a null team id means two different things. Login - * disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's, - * as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads - * nothing, because unteamed rows there are shared by every team's ad-hoc runs. + * Which rows the caller may read. A leader reviews the whole team's, as before. Everyone else + * reads the failures they caused themselves: a member can already report one, so letting them + * see their own back is what makes telling them about it worth anything, and it exposes nothing + * of a colleague's. + * + *

A null team id means two different things. Login disabled is the self-hosted setup with no + * users or teams, where unteamed rows are everyone's, as {@code PolicyAccessGuard} also treats + * them. Login enabled with no resolvable team reads nothing, because unteamed rows there are + * shared by every team's ad-hoc runs. */ - private TeamScope scope() { + private ReadScope readScope() { if (!enforced()) { - return TeamScope.of(null); + return ReadScope.wholeTeam(null); } - Long teamId = policyManagementAuthority.currentUserTeamId(); - return teamId == null ? TeamScope.denied() : TeamScope.of(teamId); + Long teamId = currentTeamId(); + if (teamId == null) { + return ReadScope.denied(); + } + if (policyManagementAuthority.canEditPolicies()) { + return ReadScope.wholeTeam(teamId); + } + // Narrowing to "mine" needs a name to narrow by. Without one the filter would be dropped + // and a member would read the whole team, so refuse rather than widen. + String actor = currentActor(); + return actor == null ? ReadScope.denied() : ReadScope.mine(teamId, actor); } /** - * The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted. + * What the caller may read. {@code actor} is the person to narrow to, or null for the whole + * team; both are only meaningful when permitted. */ - private record TeamScope(boolean permitted, Long teamId) { + private record ReadScope(boolean permitted, Long teamId, String actor) { - static TeamScope of(Long teamId) { - return new TeamScope(true, teamId); + static ReadScope wholeTeam(Long teamId) { + return new ReadScope(true, teamId, null); } - static TeamScope denied() { - return new TeamScope(false, null); + static ReadScope mine(Long teamId, String actor) { + return new ReadScope(true, teamId, actor); } + + static ReadScope denied() { + return new ReadScope(false, null, null); + } + } + + /** The team a row belongs to, which is nobody's when there are no teams to belong to. */ + private Long currentTeamId() { + return enforced() ? policyManagementAuthority.currentUserTeamId() : null; } private String currentActor() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java index cf336ab76e..2ed86e61b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java @@ -121,16 +121,19 @@ public class FileRunEventStore { * *

Both filters live in the query, before the limit: filtering an already-limited page could * return nothing while matching rows exist. + * + *

{@code actor} narrows to one person's own failures, or reads the whole team when null. Who + * gets which is the service's decision, not this method's. */ @Transactional(readOnly = true) public List list( - Long teamId, FileRunEventStatus status, String kindId, int limit) { + Long teamId, FileRunEventStatus status, String kindId, String actor, int limit) { Pageable page = PageRequest.of(0, Math.max(1, limit)); List rows = status == null ? repository.findByTeamAndStatusIn( - teamId, FileRunEventStatus.open(), kindId, page) - : repository.findByTeamAndStatus(teamId, status, kindId, page); + teamId, FileRunEventStatus.open(), kindId, actor, page) + : repository.findByTeamAndStatus(teamId, status, kindId, actor, page); return rows.stream().map(FileRunEvent::of).toList(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 1a424176ce..0d75866a6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -130,7 +130,15 @@ public class PolicyEngine { // worker. String principal = currentActingPrincipal(); return submitForPrincipal( - principal, principal, policyId, definition, inputs, listener, null, null); + principal, + principal, + principal, + policyId, + definition, + inputs, + listener, + null, + null); } /** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */ @@ -158,6 +166,10 @@ public class PolicyEngine { // they can download their enforced file; otherwise an org-wide policy's output is owned by // the admin and the triggering user is denied it. Trigger-fired runs have no such user, so // the owner owns those outputs. + // + // The triggering user is also carried on the run, as the actor of any failure it records: + // null for a trigger-fired run, which is what makes an unattended incident ownerless rather + // than the owner's problem. Three identities, deliberately not interchangeable. String triggeringUser = currentActingPrincipal(); String fileOwner = triggeringUser != null ? triggeringUser : policy.owner(); // Stored supporting files (certificates, watermark images, ...) load here, before the @@ -172,6 +184,7 @@ public class PolicyEngine { return submitForPrincipal( policy.owner(), fileOwner, + triggeringUser, policy.id(), definition, // main's asset-resolved inputs, not the raw ones: stored certificates and watermark @@ -185,6 +198,7 @@ public class PolicyEngine { private PolicyRunHandle submitForPrincipal( String billingPrincipal, String fileOwner, + String triggeringUser, String policyId, PipelineDefinition definition, PolicyInputs inputs, @@ -199,7 +213,8 @@ public class PolicyEngine { if (policyId != null) { taskManager.putMetadata(runId, "policyId", policyId); } - PolicyRun run = new PolicyRun(runId, policyId, definition, sourceId, fileIdentity); + PolicyRun run = + new PolicyRun(runId, policyId, definition, sourceId, fileIdentity, triggeringUser); registry.register(run); CompletableFuture completion = new CompletableFuture<>(); PolicyProgressListener tracking = trackingListener(runId, run, listener); @@ -364,12 +379,14 @@ public class PolicyEngine { taskManager.setError(run.getRunId(), message); // No exception to classify here: nothing was thrown by a tool, the run simply was not // admitted. Record it explicitly so a run lost to load pressure is still accounted for. + // Attributed like any other failure: a user whose run was refused is still the person + // holding that document, and an unattended sweep's run carries no triggering user. failureRecorder.recordRunFailureAs( FailureKind.UNKNOWN, run.getRunId(), run.getPolicyId(), run.getSourceId(), - null, + run.getTriggeringUser(), message); completion.complete(run); } @@ -379,6 +396,11 @@ public class PolicyEngine { /** * Record why a run failed. Called after the run's own state transition and task-manager update, * so a recording problem cannot change the outcome the caller observes. + * + *

The actor is the run's triggering user, not the MDC audit principal: that carries the + * BILLING identity, which for a stored policy is always its owner. Reading it here filed every + * failure under the owner — hiding an attended failure from the member who caused it and holds + * the document, and leaving an unattended sweep's failure looking attended. */ private void recordFailure(PolicyRun run, String message, Throwable cause) { failureRecorder.recordRunFailure( @@ -386,7 +408,7 @@ public class PolicyEngine { run.getPolicyId(), run.getSourceId(), run.getFileIdentity(), - MDC.get(AUDIT_PRINCIPAL_MDC_KEY), + run.getTriggeringUser(), message, cause); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java index 65cfa658ca..8eb0cfcc73 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java @@ -35,6 +35,14 @@ public class PolicyRun { */ private final String fileIdentity; + /** + * The user who triggered this run, or null when nothing attended it (a trigger-fired sweep). + * Recorded as a failure's actor, so an attended failure is handed to the person holding the + * document. Deliberately not the billing principal: a shared policy is billed to its owner, who + * may never have touched the file. + */ + private final String triggeringUser; + private final Instant createdAt = Instant.now(); private volatile PolicyRunStatus status = PolicyRunStatus.PENDING; @@ -63,22 +71,24 @@ public class PolicyRun { private volatile Instant updatedAt = Instant.now(); /** - * Both references are required rather than defaulted: a run with neither is a real case (a - * user's upload, an ad-hoc pipeline), but it should be stated at the call site. Overloads that - * omitted them would make losing the attribution the frictionless option, which is how both - * fields went unpopulated in the first place. + * All three attribution references are required rather than defaulted: a run with none is a + * real case (an unattended sweep of a generator pipeline), but it should be stated at the call + * site. Overloads that omitted them would make losing the attribution the frictionless option, + * which is how {@code sourceId} and {@code fileIdentity} went unpopulated in the first place. */ public PolicyRun( String runId, String policyId, PipelineDefinition definition, String sourceId, - String fileIdentity) { + String fileIdentity, + String triggeringUser) { this.runId = runId; this.policyId = policyId; this.sourceId = sourceId; this.definition = definition; this.fileIdentity = fileIdentity; + this.triggeringUser = triggeringUser; } public int stepCount() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java index 088cffe8ec..58b8d1b408 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java @@ -51,9 +51,7 @@ class FileRunEventControllerTest { List.of(new AcknowledgeAction(store), new DismissAction(store))); controller = new FileRunEventController( - new FileRunEventService(store, registry, authority, userService, props), - authority, - props); + new FileRunEventService(store, registry, authority, userService, props)); lenient().when(authority.canEditPolicies()).thenReturn(true); lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); @@ -61,14 +59,27 @@ class FileRunEventControllerTest { } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { + return recorded("author@example.com", kind, teamId, fileId, "run-1"); + } + + /** + * As {@link #given} but naming who hit it, in its own run. A RUN-scoped kind keys on the run, + * so two rows sharing one run id are one incident, however they differ otherwise. + */ + private FileRunEvent givenHitBy(String actor, FailureKind kind, Long teamId, String fileId) { + return recorded(actor, kind, teamId, fileId, "run-" + fileId); + } + + private FileRunEvent recorded( + String actor, FailureKind kind, Long teamId, String fileId, String runId) { return store.record( new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", - "run-1", + runId, null, fileId, "the raw failure message")); @@ -281,48 +292,68 @@ class FileRunEventControllerTest { } @Nested - @DisplayName("only a team leader may review failures") + @DisplayName("a leader reviews the team's failures, everyone else their own") class Authorization { @Test - void aMemberCannotListThem() { + void aMemberSeesTheirOwnFailuresAndNobodyElses() { + // A member can report a failure, so they get to see it back. It must not widen to a + // colleague's. + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); when(authority.canEditPolicies()).thenReturn(false); - assertThatThrownBy(() -> controller.list(null, null, null)) - .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.FORBIDDEN)); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("mine"); } @Test - void aMemberCannotDispatchAnAction() { - // The read being refused is not enough on its own: an id learned any other way must - // not let a member close another user's failure. - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f-1"); - when(authority.canEditPolicies()).thenReturn(false); + void aLeaderSeesTheWholeTeams() { + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); + when(authority.canEditPolicies()).thenReturn(true); - assertThatThrownBy(() -> controller.act(event.id(), "DISMISS", null)) - .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.FORBIDDEN)); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactlyInAnyOrder("mine", "theirs"); } @Test - void theRegistryIsAlsoLeaderOnly() { + void aMemberMayCloseTheirOwn() { + // Someone who fixes their own problem should not have to ask a leader to clear the row. + FileRunEvent mine = + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); when(authority.canEditPolicies()).thenReturn(false); - assertThatThrownBy(() -> controller.kinds()) - .isInstanceOf(ResponseStatusException.class); + assertThat(controller.act(mine.id(), "DISMISS", null).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } + + @Test + void aMemberCannotCloseAColleaguesEvenKnowingTheId() { + // Refusing the read is not enough on its own: an id learned any other way must not work + // either. Answered as not-found rather than forbidden, so trying does not confirm the + // row exists. + FileRunEvent theirs = + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(statusOf(() -> controller.act(theirs.id(), "DISMISS", null))) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void theRegistryIsOpenBecauseItIsCopyNotData() { + // A member renders the failures they can see, so they need the labels for them. No role + // stub: the point is that kinds() never asks. + assertThat(controller.kinds()).isNotEmpty(); } @Test void loginDisabledTrustsTheLocalOperator() { - // A single-user deployment has no roles to distinguish, so the role gate must not lock - // the only user out of their own failures. + // A single-user deployment has no roles to distinguish, so the narrowing must not leave + // the only user reading nothing. ApplicationProperties unsecured = new ApplicationProperties(); unsecured.getSecurity().setEnableLogin(false); FileRunEventController noLogin = @@ -335,9 +366,7 @@ class FileRunEventControllerTest { new DismissAction(store))), authority, userService, - unsecured), - authority, - unsecured); + unsecured)); assertThatCode(() -> noLogin.list(null, null, null)).doesNotThrowAnyException(); // Not merely permitted: the role is never consulted at all, which is what makes the @@ -382,9 +411,7 @@ class FileRunEventControllerTest { new DismissAction(store))), authority, userService, - unsecured), - authority, - unsecured); + unsecured)); given(FailureKind.UNKNOWN, null, "unteamed"); given(FailureKind.UNKNOWN, TEAM, "teamed"); @@ -399,9 +426,9 @@ class FileRunEventControllerTest { class Reporting { @Test - void aMemberMayReportEvenThoughTheyMayNotRead() { - // The asymmetry is the point: anyone whose work failed can say so, but only a leader - // reviews the queue. + void aMemberMayReportAndThenSeeTheirOwnReport() { + // Reporting was always open to a member; reading their own back is the round trip that + // makes the report worth anything to them. when(authority.canEditPolicies()).thenReturn(false); assertThatCode( @@ -410,8 +437,9 @@ class FileRunEventControllerTest { new EditorFailureReport( "compress", "E004", List.of("f-1"), "boom"))) .doesNotThrowAnyException(); - assertThatThrownBy(() -> controller.list(null, null, null)) - .isInstanceOf(ResponseStatusException.class); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("f-1"); } @Test @@ -441,7 +469,7 @@ class FileRunEventControllerTest { new EditorFailureReport("compress", "E004", atLimit, "boom"); assertThat(controller.report(report).getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) + assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) .hasSize(EditorFailureReport.MAX_FILE_IDS); } @@ -463,7 +491,7 @@ class FileRunEventControllerTest { overLimit, "boom")))) .isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) + assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) .isEmpty(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java index b3ee4b7d15..375e46f4ad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java @@ -438,11 +438,8 @@ class FileRunEventHttpIntegrationTest { } @Bean - FileRunEventController fileRunEventController( - FileRunEventService service, - PolicyManagementAuthority authority, - ApplicationProperties props) { - return new FileRunEventController(service, authority, props); + FileRunEventController fileRunEventController(FileRunEventService service) { + return new FileRunEventController(service); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java index fbbe8e6359..4e6508bed9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java @@ -54,6 +54,9 @@ class FileRunEventServiceTest { lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR); + // A leader unless a test says otherwise: most of these are about team scoping, which is + // what a leader sees. The member narrowing has its own tests. + lenient().when(authority.canEditPolicies()).thenReturn(true); } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { @@ -291,7 +294,7 @@ class FileRunEventServiceTest { } @Nested - @DisplayName("team scoping") + @DisplayName("read scoping") class Scoping { @Test @@ -304,6 +307,68 @@ class FileRunEventServiceTest { .containsExactly("mine"); } + @Test + void aMemberReadsOnlyTheFailuresTheyCaused() { + // Reporting is open to a member, so reading their own back is what lets us tell them + // anything at all. A colleague's must not come with it. + store.record(RecordFailure.forEditor(FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom")); + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, "colleague@example.com", "theirs", "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(service.list(null, null, 50)) + .extracting(FileRunEvent::fileId) + .containsExactly("mine"); + } + + @Test + void aMemberWithNoResolvableNameReadsNothingRatherThanEverything() { + // Narrowing to "mine" needs a name to narrow by. Dropping the filter would hand the + // whole team to someone who may not have it. + given(FailureKind.UNKNOWN, TEAM, "mine"); + when(authority.canEditPolicies()).thenReturn(false); + when(userService.getCurrentUsername()).thenReturn(null); + + assertThat(service.list(null, null, 50)).isEmpty(); + } + + @Test + void aMemberCannotActOnAColleaguesRowEvenKnowingItsId() { + // Refusing the read is not enough on its own: an id learned any other way must not work + // either. Reported as not-found, so trying does not confirm the row exists. + FileRunEvent theirs = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, + TEAM, + "colleague@example.com", + "theirs", + "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> service.dispatch(theirs.id(), "DISMISS", Map.of())) + .isInstanceOf(FailureActionException.class) + .extracting(e -> ((FailureActionException) e).getReason()) + .isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND); + + assertThat(store.find(theirs.id(), TEAM).orElseThrow().status()) + .isEqualTo(FileRunEventStatus.NEW); + } + + @Test + void aMemberMayCloseTheirOwn() { + // Someone who fixes their own problem should not have to ask a leader to clear the row. + FileRunEvent mine = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(service.dispatch(mine.id(), "DISMISS", Map.of()).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } + @Test void aCallerWhoseTeamCannotBeResolvedReadsNothing() { // A run with no stored policy is recorded unteamed, and those rows are shared by every @@ -399,7 +464,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED); assertThat(event.origin()).isEqualTo(FailureOrigin.TOOL); assertThat(event.fileId()).isEqualTo("f-1"); @@ -412,16 +477,32 @@ class FileRunEventServiceTest { // session. service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.teamId()).isEqualTo(TEAM); assertThat(event.actor()).isEqualTo(ACTOR); } + @Test + void stillFilesTheRowUnderTheTeamWhenTheReporterCannotBeNamed() { + // Recording is open to everyone and takes the caller's team, not their read scope: a + // reporter who cannot be named reads nothing back, but the row is still the team's + // rather than dropping into the unteamed bucket every team shares. No role stub either, + // since recording never asks. + when(userService.getCurrentUsername()).thenReturn(null); + + service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); + + assertThat(store.list(TEAM, null, null, null, 10)) + .singleElement() + .extracting(FileRunEvent::teamId) + .isEqualTo(TEAM); + } + @Test void recordsAnUnrecognisedCodeAsUnknownRatherThanDroppingIt() { service.report(new EditorFailureReport("ocr", "E999", List.of("f-1"), "no idea")); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } @@ -429,7 +510,7 @@ class FileRunEventServiceTest { void recordsAnAbsentCodeAsUnknown() { service.report(new EditorFailureReport("ocr", null, List.of("f-1"), "network died")); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } @@ -439,7 +520,7 @@ class FileRunEventServiceTest { new EditorFailureReport( "compress", "E004", List.of("f-1", "f-2", "f-3"), "boom")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .hasSize(3) .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("f-1", "f-2", "f-3"); @@ -451,7 +532,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("compress", "E004", List.of("f-1"), "boom again")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -461,7 +542,7 @@ class FileRunEventServiceTest { void recordsOneUnattributedIncidentWhenNoFileWasNamed() { service.report(new EditorFailureReport("compress", "E004", List.of(), "boom")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::fileId) .isNull(); @@ -476,7 +557,7 @@ class FileRunEventServiceTest { service.report(new EditorFailureReport("compress", "E004", many, "boom")); - assertThat(store.list(TEAM, null, null, 200)).hasSize(60); + assertThat(store.list(TEAM, null, null, null, 200)).hasSize(60); } @Test @@ -486,7 +567,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.detail()).contains("remove-password"); assertThat(event.fileId()).isEqualTo("f-1"); } @@ -499,7 +580,7 @@ class FileRunEventServiceTest { new EditorFailureReport( "compress", "E004", List.of("f-1"), "Failed on Q4 report.pdf")); - assertThat(store.list(TEAM, null, null, 10).getFirst().detail()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().detail()) .isEqualTo("compress: Failed on Q4 report.pdf"); } @@ -540,7 +621,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("bob@example.com", "b-1"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::actor) .containsExactlyInAnyOrder("alice@example.com", "bob@example.com"); } @@ -550,7 +631,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("alice@example.com", "a-2"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("a-1", "a-2"); } @@ -560,7 +641,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("alice@example.com", "a-1"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -612,8 +693,11 @@ class FileRunEventServiceTest { } @Test - void aColleaguesIncidentIsUntouched() { - // File ids come from the client, so naming one must not close someone else's row. + void aColleaguesIncidentIsUntouchedEvenForALeader() { + // File ids come from the client, so naming one must not close someone else's row. The + // caller here is a leader, who reads the whole team: this path narrows to their own + // rows + // regardless, since a null actor would otherwise match every unattributed row. store.record( RecordFailure.forEditor( FailureKind.UNKNOWN, TEAM, "employee@example.com", "f-1", "theirs")); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java index 949904dfb9..982d804354 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java @@ -55,11 +55,16 @@ class FileRunEventStoreDbTest { } private RecordFailure failure(FailureKind kind, Long teamId, String fileId) { + return failure(kind, teamId, "author@example.com", fileId); + } + + /** As {@link #failure} but naming the actor, which is what the read scope narrows by. */ + private RecordFailure failure(FailureKind kind, Long teamId, String actor, String fileId) { return new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", "run-1", null, @@ -74,16 +79,37 @@ class FileRunEventStoreDbTest { store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs")); store.record(failure(FailureKind.UNKNOWN, null, "unteamed")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactly("ours"); // A plain `e.teamId = :teamId` would return nothing here: SQL equality against NULL is // never true, which is what the explicit null branch in the JPQL exists for. - assertThat(store.list(null, null, null, 10)) + assertThat(store.list(null, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactly("unteamed"); } + @Test + @DisplayName("actor narrowing is enforced by the query, within the team") + void actorNarrowingIsEnforcedBySql() { + // The clause that makes a member read only their own rows. Exercised here rather than only + // against the in-memory repository, which reimplements the filter in Java and would agree + // with a query that had lost it. + store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "mine@example.com", "f1")); + store.record( + failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "theirs@example.com", "f2")); + store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null, "f3")); + + assertThat(store.list(TEAM, null, null, "mine@example.com", 10)) + .extracting(FileRunEvent::fileId) + .containsExactly("f1"); + // A null actor is "no filter", which is what a leader reads with: the whole team, including + // the rows nobody is named on. + assertThat(store.list(TEAM, null, null, null, 10)) + .extracting(FileRunEvent::fileId) + .containsExactlyInAnyOrder("f1", "f2", "f3"); + } + @Test @DisplayName("a fold lands on the row's current state, not the caller's snapshot") void foldTargetsTheCurrentRowNotACallersSnapshot() { @@ -170,7 +196,7 @@ class FileRunEventStoreDbTest { assertThat(replacement.id()).isNotEqualTo(first.id()); assertThat(replacement.occurrences()).isEqualTo(1); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -188,7 +214,7 @@ class FileRunEventStoreDbTest { FileRunEvent folded = store.record(secondSweep); assertThat(folded.occurrences()).isEqualTo(2); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .as("one incident per document, however many runs it failed in") .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("file-hash-a", "file-hash-b"); @@ -267,7 +293,7 @@ class FileRunEventStoreDbTest { store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i)); } - assertThat(store.list(TEAM, null, "UNKNOWN", 1)) + assertThat(store.list(TEAM, null, "UNKNOWN", null, 1)) .extracting(FileRunEvent::fileId) .containsExactly("old-unknown"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java index 714d65c29c..97de1952e9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java @@ -281,7 +281,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.UNKNOWN, TEAM, "mine", "a")); store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs", "b")); - assertThat(store.list(TEAM, null, null, 50)) + assertThat(store.list(TEAM, null, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("mine"); } @@ -293,7 +293,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.UNKNOWN, null, "unteamed", "a")); store.record(failure(FailureKind.UNKNOWN, TEAM, "teamed", "b")); - assertThat(store.list(null, null, null, 50)) + assertThat(store.list(null, null, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("unteamed"); } @@ -305,8 +305,8 @@ class FileRunEventStoreTest { FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom")); store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer"); - assertThat(store.list(TEAM, null, null, 10)).isEmpty(); - assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).isEmpty(); + assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, null, 10)).hasSize(1); } @Test @@ -314,7 +314,7 @@ class FileRunEventStoreTest { FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom")); store.applyStatus(event.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "reviewer"); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -323,7 +323,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "closed", "b")); store.applyStatus(open.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "me"); - assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, 50)) + assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("open"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java index 1873f6265b..6dbb16322b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java @@ -54,9 +54,18 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { return kindId == null || kindId.equals(entity.getKindId()); } + /** Null means the whole team, matching the JPQL's {@code :actor is null} branch. */ + private static boolean sameActor(FileRunEventEntity entity, String actor) { + return actor == null || actor.equals(entity.getActor()); + } + @Override public List findByTeamAndStatus( - Long teamId, FileRunEventStatus status, String kindId, Pageable pageable) { + Long teamId, + FileRunEventStatus status, + String kindId, + String actor, + Pageable pageable) { return page( newestFirst( rows.values().stream() @@ -64,7 +73,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { e -> sameTeam(e, teamId) && e.getStatus() == status - && sameKind(e, kindId)) + && sameKind(e, kindId) + && sameActor(e, actor)) .toList()), pageable); } @@ -142,7 +152,11 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { @Override public List findByTeamAndStatusIn( - Long teamId, List statuses, String kindId, Pageable pageable) { + Long teamId, + List statuses, + String kindId, + String actor, + Pageable pageable) { return page( newestFirst( rows.values().stream() @@ -150,7 +164,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { e -> sameTeam(e, teamId) && statuses.contains(e.getStatus()) - && sameKind(e, kindId)) + && sameKind(e, kindId) + && sameActor(e, actor)) .toList()), pageable); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java new file mode 100644 index 0000000000..2a71c236a7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java @@ -0,0 +1,264 @@ +package stirling.software.proprietary.failure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.core.io.ByteArrayResource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.service.JobQueue; +import stirling.software.common.service.ResourceMonitor; +import stirling.software.common.service.TaskManager; +import stirling.software.common.service.ToolMetadataService; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.engine.PolicyEngine; +import stirling.software.proprietary.policy.engine.PolicyExecutor; +import stirling.software.proprietary.policy.engine.PolicyRunRegistry; +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.PolicyInputs; +import stirling.software.proprietary.policy.output.InlineOutputSink; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; +import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +import tools.jackson.databind.json.JsonMapper; + +/** + * Pins what the engine records as a failure's actor against what a reader gets back, because the + * two sides used to assert independently: the engine's test passed {@code any()} for the actor, and + * the service's fixtures assumed an actor the engine never actually produced. Every collaborator + * between the failing tool call and the read is real here, so a regression in either one fails. + * + *

This is what makes this PR's promise hold. Reads are narrowed to the rows the caller is the + * actor on, so if the engine names the wrong person, a member reads nothing at all. + * + *

The bug it exists for: the engine recorded the BILLING principal as the actor, which for a + * stored policy is always its owner. So an attended failure was filed under someone who never + * touched the document, and the member who did could not see it. + */ +@ExtendWith(MockitoExtension.class) +class PolicyFailureAttributionTest { + + private static final String ROTATE = "/api/v1/general/rotate-pdf"; + private static final Long TEAM = 3L; + + @Mock private InternalApiClient internalApiClient; + @Mock private ToolMetadataService toolMetadataService; + @Mock private TaskManager taskManager; + @Mock private FileStorage fileStorage; + @Mock private JobOwnershipService jobOwnershipService; + @Mock private ResourceMonitor resourceMonitor; + @Mock private JobQueue jobQueue; + @Mock private PolicyStore policyStore; + @Mock private PolicyManagementAuthority authority; + @Mock private UserServiceInterface userService; + + @TempDir Path tempDir; + + private PolicyEngine engine; + private FileRunEventService service; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSecurity().setEnableLogin(true); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("failure-attribution-test-"); + + FileRunEventStore store = new FileRunEventStore(new InMemoryFileRunEventRepository()); + service = + new FileRunEventService( + store, + new FailureActionRegistry( + List.of(new AcknowledgeAction(store), new DismissAction(store))), + authority, + userService, + props); + + PolicyFailureRecorder recorder = + new PolicyFailureRecorder( + new FailureClassifier(JsonMapper.builder().build()), store, policyStore); + PolicyExecutor executor = + new PolicyExecutor( + internalApiClient, + toolMetadataService, + new TempFileManager(new TempFileRegistry(), props), + JsonMapper.builder().build()); + engine = + new PolicyEngine( + executor, + taskManager, + new PolicyRunRegistry(new ApplicationProperties()), + recorder, + fileStorage, + jobOwnershipService, + List.of(new InlineOutputSink(fileStorage)), + new PolicyOutputResolver(new InProcessSourceStore()), + resourceMonitor, + jobQueue, + new PolicyAssetResolver(new InProcessPolicyAssetStore())); + + lenient() + .when(jobOwnershipService.createScopedJobKey(anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false); + lenient().when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + // The team is resolved from the policy, so the recorded row lands in the reader's team. + lenient().when(policyStore.get(anyString())).thenReturn(Optional.of(sharedPolicy())); + lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); + } + + /** Alice's policy, shared with her team. Bob is a member of it and does not own it. */ + private static Policy sharedPolicy() { + return new Policy( + "p1", + "rotate", + "alice", + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline(), + TEAM); + } + + /** + * Run the shared policy so its single tool step fails, as {@code triggeredBy} (null = sweep). + */ + private void runAndFail(String triggeredBy, String sourceId, String fileIdentity) + throws Exception { + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + if (triggeredBy != null) { + MDC.put("auditPrincipal", triggeredBy); + } + try { + engine.runPolicy( + sharedPolicy(), + PolicyInputs.of(List.of(pdf())), + PolicyProgressListener.NOOP, + sourceId, + fileIdentity) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + } + + private static ByteArrayResource pdf() { + return new ByteArrayResource("input".getBytes()) { + @Override + public String getFilename() { + return "input.pdf"; + } + }; + } + + /** Read as a plain member, who is narrowed to the rows they are the actor on. */ + private FileRunEvent asMember(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(false); + List visible = service.list(null, null, 10); + return visible.isEmpty() ? null : visible.getFirst(); + } + + /** Read as a team leader, who reviews the whole team's incidents. */ + private FileRunEvent asReviewer(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(true); + return service.list(null, null, 10).getFirst(); + } + + @Nested + @DisplayName("a non-owner runs a shared policy on their own upload") + class AttendedByANonOwner { + + @Test + void theTriggeringUserCanReadTheFailureTheyCaused() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // The whole point: Bob's read scope narrows to his own rows, so the row only reaches + // him if the engine named him. Before the fix this list was empty. + FileRunEvent mine = asMember("bob"); + assertThat(mine).as("bob must be able to see the failure he caused").isNotNull(); + assertThat(mine.actor()).isEqualTo("bob"); + } + + @Test + void thePolicyOwnerIsNotNamedAsTheActorMerelyForBeingBilled() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // Alice owns the policy and pays for the run, but she never touched the document. + assertThat(asReviewer("alice").actor()).isEqualTo("bob"); + } + + @Test + void aColleagueWhoDidNotTriggerItCannotSeeItAtAll() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + assertThat(asMember("carol")).isNull(); + } + } + + @Nested + @DisplayName("an unattended sweep pulls a file from a source") + class UnattendedSweep { + + @Test + void theRowIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(asReviewer("alice").actor()) + .as("a trigger-fired run has no user to name") + .isNull(); + } + + @Test + void theSourceThatFedItIsStillRecorded() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + FileRunEvent unattended = asReviewer("alice"); + assertThat(unattended.sourceId()).isEqualTo("src-watched-folder"); + assertThat(unattended.fileId()).isEqualTo("file-hash-1"); + } + + @Test + void aMemberDoesNotInheritAnUnattendedFailureAsTheirOwn() throws Exception { + // An unowned row must not fall to whoever happens to be reading: with no actor there is + // nothing for a member's narrowed read to match. + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(asMember("bob")).isNull(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java index e1a17843f0..73c5add6ad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java @@ -95,7 +95,7 @@ class PolicyFailureRecorderTest { "Policy run failed: locked", passwordFailure()); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED); assertThat(event.runId()).isEqualTo("run-1"); assertThat(event.policyId()).isEqualTo("policy-1"); @@ -121,7 +121,7 @@ class PolicyFailureRecorderTest { "Policy run failed: something we do not recognise", new RuntimeException("boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN); assertThat(event.detail()).contains("something we do not recognise"); } @@ -139,7 +139,7 @@ class PolicyFailureRecorderTest { "Policy run failed: java.lang.NullPointerException", new RuntimeException("npe")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN); assertThat(event.detail()).contains("NullPointerException"); } @@ -152,7 +152,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailureAs( FailureKind.UNKNOWN, "run-3", "policy-1", null, null, "could not be queued"); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -180,7 +180,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - List events = store.list(TEAM, null, null, 10); + List events = store.list(TEAM, null, null, null, 10); assertThat(events).hasSize(2); assertThat(events).allMatch(event -> event.occurrences() == 1); assertThat(events) @@ -203,7 +203,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.sourceId()).isEqualTo("src-s3-invoices"); assertThat(event.actor()).isNull(); } @@ -218,7 +218,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailureAs( FailureKind.UNKNOWN, "run-2", "policy-1", "src-b", null, "unreachable"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .hasSize(2) .extracting(FileRunEvent::sourceId) .containsExactlyInAnyOrder("src-a", "src-b"); @@ -245,7 +245,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -263,7 +263,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", "policy-1", null, null, null, "boom", new RuntimeException()); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -273,8 +273,8 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", null, null, null, null, "boom", new RuntimeException()); - assertThat(store.list(null, null, null, 10)).hasSize(1); - assertThat(store.list(TEAM, null, null, 10)).isEmpty(); + assertThat(store.list(null, null, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).isEmpty(); } @Test @@ -293,7 +293,7 @@ class PolicyFailureRecorderTest { new RuntimeException())) .doesNotThrowAnyException(); // Still recorded, just unteamed: a lookup problem must not lose the incident. - assertThat(store.list(null, null, null, 10)).hasSize(1); + assertThat(store.list(null, null, null, null, 10)).hasSize(1); } } @@ -344,7 +344,7 @@ class PolicyFailureRecorderTest { "no cause", null)) .doesNotThrowAnyException(); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } } @@ -362,7 +362,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", "policy-1", null, null, null, "boom", new IOException("x")); - List events = store.list(TEAM, null, null, 10); + List events = store.list(TEAM, null, null, null, 10); assertThat(events).hasSize(1); assertThat(events.getFirst().occurrences()).isEqualTo(2); } @@ -377,7 +377,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-2", "policy-1", null, null, null, "boom", new IOException("x")); - assertThat(store.list(TEAM, null, null, 10)).hasSize(2); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(2); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index c38f5c7d89..84e9998b90 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -216,7 +216,7 @@ class PolicyControllerTest { } private static PolicyRunHandle handle(String runId) { - PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null); + PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null, null); return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run)); } @@ -317,7 +317,7 @@ class PolicyControllerTest { @Test @DisplayName("returns the run view when present") void found() { - PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null); + PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null, null); when(runRegistry.get("run-3")).thenReturn(run); ResponseEntity response = controller.status("run-3"); @@ -346,11 +346,11 @@ class PolicyControllerTest { @Test @DisplayName("excludes ad-hoc runs and runs owned by others") void filtersRuns() { - PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null); + PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null, null); PolicyRun ownedStored = - new PolicyRun("owned", "policy-A", definitionWithStep(), null, null); + new PolicyRun("owned", "policy-A", definitionWithStep(), null, null, null); PolicyRun otherStored = - new PolicyRun("other", "policy-B", definitionWithStep(), null, null); + new PolicyRun("other", "policy-B", definitionWithStep(), null, null, null); when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored)); // ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index b3b45dcd96..1efe094fe4 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -9,6 +9,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; @@ -231,20 +232,15 @@ class PolicyEngineTest { @Test void recordsWhichSourceFedAFailedRun() throws Exception { // The source is threaded onto the run so an unattended failure is attributable: there is no - // user to name for a file that arrived from a bucket. + // user to name for a file that arrived from a bucket. The actor is asserted null rather + // than + // any(): a loose matcher here is what let the owner be recorded as the actor unnoticed. when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); PolicyRunHandle handle = engine.runPolicy( - new Policy( - "p1", - "rotate", - "owner", - true, - List.of(), - List.of(new PipelineStep(ROTATE, Map.of())), - OutputSpec.inline()), + policyOwnedBy("owner"), PolicyInputs.of(List.of(pdf("input", "input.pdf"))), PolicyProgressListener.NOOP, "src-s3-invoices", @@ -257,11 +253,136 @@ class PolicyEngineTest { any(), eq("src-s3-invoices"), eq("file-hash-1"), - any(), + isNull(), anyString(), any(Throwable.class)); } + @Test + void anAttendedFailureIsRecordedAgainstWhoTriggeredItNotThePolicysOwner() throws Exception { + // Bob runs Alice's shared policy on his own upload and it fails. The row must name Bob: he + // is the one whose browser holds the document, and a member's read scope narrows to their + // own rows, so filing it under Alice hides it from the only person who can act on it. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + MDC.put("auditPrincipal", "bob"); // the request thread's acting user + try { + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + null, + "bob-doc-1") + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + isNull(), + eq("bob-doc-1"), + eq("bob"), + anyString(), + any(Throwable.class)); + } + + @Test + void anUnattendedFailureIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception { + // The two identities are deliberately different, and this pins both at once: usage is + // charged to the owner (MDC audit principal on the worker), but the failure has no actor, + // which is what makes it UNOWNED and hands the owner actions to the team's reviewer. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + String[] principalAtDispatch = {""}; + when(internalApiClient.post(eq(ROTATE), any())) + .thenAnswer( + invocation -> { + principalAtDispatch[0] = MDC.get("auditPrincipal"); + throw new RuntimeException("boom"); + }); + + // No MDC and no security context: exactly a trigger-fired sweep. + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + "src-watched-folder", + "file-hash-1") + .completion() + .get(10, TimeUnit.SECONDS); + + assertEquals("alice", principalAtDispatch[0], "billing must still be the policy owner"); + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + eq("src-watched-folder"), + eq("file-hash-1"), + isNull(), + anyString(), + any(Throwable.class)); + } + + @Test + void anAdHocFailureIsRecordedAgainstTheSubmittingUser() throws Exception { + // An ad-hoc run has no stored policy, so the submitter is both payer and actor. Asserted so + // the two entry points cannot drift apart. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + MDC.put("auditPrincipal", "bob"); + try { + engine.submit( + definition(new PipelineStep(ROTATE, Map.of())), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + any(), + any(), + eq("bob"), + anyString(), + any(Throwable.class)); + } + + @Test + void aRunRefusedAtAdmissionIsRecordedAgainstWhoeverTriggeredIt() throws Exception { + // The queue-full path records its own row, and it is attended: the user is still holding + // the + // document, so it must reach them rather than landing as an ownerless incident. + when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true); + CompletableFuture rejected = new CompletableFuture<>(); + rejected.completeExceptionally(new RuntimeException("Job queue full")); + doReturn(rejected).when(jobQueue).queueJob(anyString(), anyInt(), any(), anyLong()); + + MDC.put("auditPrincipal", "bob"); + try { + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + null, + "bob-doc-1"); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailureAs(any(), anyString(), any(), isNull(), eq("bob"), anyString()); + } + @Test void recordingAFailureNeverChangesTheRunsOutcome() throws Exception { // Recording is best-effort: losing the incident row is bad, but turning a classified @@ -540,6 +661,17 @@ class PolicyEngineTest { return new PipelineDefinition("test", List.of(steps), OutputSpec.inline()); } + private static Policy policyOwnedBy(String owner) { + return new Policy( + "p1", + "rotate", + owner, + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline()); + } + private void stubEndpoint(String endpoint, Resource body) { when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body)); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java index 1eb0a1a1fe..692fe84c37 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java @@ -101,6 +101,7 @@ class PolicyRunRegistryTest { null, new PipelineDefinition(runId, List.of(), List.of()), null, + null, null); registry.register(run); return run;