From 60741e305d46cab2082bbef166ffbe6f1d313596 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Sun, 16 Aug 2026 11:22:19 +0100
Subject: [PATCH 01/59] Update CONTRIBUTING.md to include license information
(#7512)
Added a section about licensing contributions to the project.
# Description of Changes
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
CONTRIBUTING.md | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9092d5cd72..65fc4bc262 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,6 +2,13 @@
Thank you for your interest in contributing to Stirling-PDF! There are many ways to contribute other than writing code. For example, reporting bugs, creating suggestions, and adding or modifying translations.
+## License
+
+By contributing to this project, you agree that your contributions will be licensed under the project [license](LICENSE), which follows an open-core model.
+The codebase is a mix of MIT and source-available code, so your contribution is licensed according to the directory it is committed to.
+
+PRs are welcome in any directory by any user, just be aware of which license applies to the code you change.
+
## Issue Guidelines
Issues can be used to report bugs, request features, or ask questions. If you have a question, you could also ask us in our [Discord](https://discord.gg/FJUSXUSYec).
@@ -63,7 +70,3 @@ For technical guides, setup instructions, and development resources:
For configuration and usage guides, see:
- [Database Guide](DATABASE.md) - Database setup and configuration
- [OCR Guide](HowToUseOCR.md) - OCR setup and configuration
-
-## License
-
-By contributing to this project, you agree that your contributions will be licensed under the [MIT License](LICENSE).
From 3bb3b721de206f28346dc410df1512f6a90814f2 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:15:34 +0100
Subject: [PATCH 02/59] build(deps): bump logback from 1.5.32 to 1.6.1 (#7339)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps `logback` from 1.5.32 to 1.6.1.
Updates `ch.qos.logback:logback-core` from 1.5.32 to 1.6.1
Release notes
• In TimeBasedRollingPolicy, when the file option is set, the
intermediate file renamed before asynchronous compression now receives
the target archive name without the compression suffix (e.g.
.gz, .zip, .xz). Previously it
used a nanotime-based .tmp suffix. This makes the file
easier to identify if compression fails during rollover. (See also the
following paragraph.)
• On GZ, ZIP, or XZ compression failure, the original (uncompressed)
log file is no longer deleted. Compression strategies now delete the
source file only after successful compression and emit a warning that
the original was left intact.
• ConsoleAppender with now probes JLine's
org.jline.jansi.AnsiConsole first and falls back to the legacy
FuseSource org.fusesource.jansi.AnsiConsole class. This keeps ANSI
coloring working after Jansi moved under the JLine project. The optional
org.jline:jansi-core artifact is declared as a dependency alongside the
existing FuseSource jansi dependency. A preferredJansiClassName property
was added for tests. This issue was reported in issues/1043
by seonwoo_jung who also
provided the relevant PR.
• LayoutWrappingEncoder now reports an error at start() when no
layout is set and guards encode() against a null layout. Previously, a
missing layout (for example after an ignored // branch) allowed
the encoder to start and then fail with a NullPointerException on every
event, resulting in silent log loss. This issue was reported in issues/1046
by seonwoo_jung who also
provided the relevant PR.
• FileCollisionAnalyser now detects file collisions involving nested
appenders of SiftingAppender. When the nested file or fileNamePattern
does not textually reference the discriminator key (e.g. ${userId}), a
warning is issued at configuration time naming the appender, the key,
and the shared target. This closes a gap where statically declared file
appenders were checked but sifted nested appenders were not. This
enhancement was contributed in [PR #1041](qos-ch/logback#1041)
by seonwoo_jung.
• More defensive handling in SyslogOutputStream and
SyslogAppenderBase: the close() method now ensures that resources are
closed, writes and flushes check that the underlying resources are in a
valid state and fallback to no-op otherwise.
• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
57759f433000a133088ef0441038963134437fbd associated with the tag
v_1.6.1. The release was built using Java "21" 2023-10-17 LTS
build 21.0.1.+12-LTS-29 under Linux Debian 11.6.
• Removed certain deprecated variables, methods, and classes. For the
list of removed members see release_1.6.0.txt.
• In AsyncAppenderBase, the
put(ILoggingEvent) method now has the protected modifier to
allow access from derived classes. This change was requested by Thomas
Skjølberg in pr#1053.
• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
b07adf36019b51a10f824fdd94009985c587b1d3 associated with the tag
v_1.6.0. The release was built using Java "21" 2023-10-17 LTS
build 21.0.1.+12-LTS-29 under Linux Debian 11.6.
Logback 1.5.38
2026-07-09 Release of logback version 1.5.38
• In HardenedObjectInputStream, fixed a typo preventing
Throwable objects from being white-filtered. This issue was
reported in [PR #1045](qos-ch/logback#1045)
by t0rchwo0d.
• A bitwise identical binary of this version can be reproduced by
building from source code at commit
d04984a41fce42977466f45a2f076f0ee5cc4207 associated with the tag
v_1.5.38. Release built using Java "21" 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.
Logback 1.5.37
2026-06-26 Release of logback version 1.5.37
• Given the numerous vulnerabilities related to conditional
configuration processing based on the evaluation of Java expressions
using the Janino library, support for such expressions has been removed.
Users are offered the an online
migration service or the <condition> element
introduced in version 1.5.20. See the relevant
documentation for more details.
• A bitwise identical binary of this version can be reproduced by
building from source code at commit
c1df7f522e648eec7b4ef6a12c8758fec0f00048 associated with the tag
v_1.5.37. Release built using Java "21" 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.
Logback 1.5.36
2026-06-25 Release of logback version 1.5.36
• The 'condition' attribute in <if> elements now
reject certain references that are associated with ACE attacks. This
issue was reported by "yulate" (yulate531@gmail.com.com) and
registered as CVE-2026-13006.
Please note that version 1.5.37 provides the full fix to this
vulnerability.
• In TimeBasedRollingPolicy, when the file option is set, the
intermediate file renamed before asynchronous compression now receives
the target archive name without the compression suffix (e.g.
.gz, .zip, .xz). Previously it
used a nanotime-based .tmp suffix. This makes the file
easier to identify if compression fails during rollover. (See also the
following paragraph.)
• On GZ, ZIP, or XZ compression failure, the original (uncompressed)
log file is no longer deleted. Compression strategies now delete the
source file only after successful compression and emit a warning that
the original was left intact.
• ConsoleAppender with now probes JLine's
org.jline.jansi.AnsiConsole first and falls back to the legacy
FuseSource org.fusesource.jansi.AnsiConsole class. This keeps ANSI
coloring working after Jansi moved under the JLine project. The optional
org.jline:jansi-core artifact is declared as a dependency alongside the
existing FuseSource jansi dependency. A preferredJansiClassName property
was added for tests. This issue was reported in issues/1043
by seonwoo_jung who also
provided the relevant PR.
• LayoutWrappingEncoder now reports an error at start() when no
layout is set and guards encode() against a null layout. Previously, a
missing layout (for example after an ignored // branch) allowed
the encoder to start and then fail with a NullPointerException on every
event, resulting in silent log loss. This issue was reported in issues/1046
by seonwoo_jung who also
provided the relevant PR.
• FileCollisionAnalyser now detects file collisions involving nested
appenders of SiftingAppender. When the nested file or fileNamePattern
does not textually reference the discriminator key (e.g. ${userId}), a
warning is issued at configuration time naming the appender, the key,
and the shared target. This closes a gap where statically declared file
appenders were checked but sifted nested appenders were not. This
enhancement was contributed in [PR #1041](qos-ch/logback#1041)
by seonwoo_jung.
• More defensive handling in SyslogOutputStream and
SyslogAppenderBase: the close() method now ensures that resources are
closed, writes and flushes check that the underlying resources are in a
valid state and fallback to no-op otherwise.
• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
57759f433000a133088ef0441038963134437fbd associated with the tag
v_1.6.1. The release was built using Java "21" 2023-10-17 LTS
build 21.0.1.+12-LTS-29 under Linux Debian 11.6.
• Removed certain deprecated variables, methods, and classes. For the
list of removed members see release_1.6.0.txt.
• In AsyncAppenderBase, the
put(ILoggingEvent) method now has the protected modifier to
allow access from derived classes. This change was requested by Thomas
Skjølberg in pr#1053.
• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
b07adf36019b51a10f824fdd94009985c587b1d3 associated with the tag
v_1.6.0. The release was built using Java "21" 2023-10-17 LTS
build 21.0.1.+12-LTS-29 under Linux Debian 11.6.
Logback 1.5.38
2026-07-09 Release of logback version 1.5.38
• In HardenedObjectInputStream, fixed a typo preventing
Throwable objects from being white-filtered. This issue was
reported in [PR #1045](qos-ch/logback#1045)
by t0rchwo0d.
• A bitwise identical binary of this version can be reproduced by
building from source code at commit
d04984a41fce42977466f45a2f076f0ee5cc4207 associated with the tag
v_1.5.38. Release built using Java "21" 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.
Logback 1.5.37
2026-06-26 Release of logback version 1.5.37
• Given the numerous vulnerabilities related to conditional
configuration processing based on the evaluation of Java expressions
using the Janino library, support for such expressions has been removed.
Users are offered the an online
migration service or the <condition> element
introduced in version 1.5.20. See the relevant
documentation for more details.
• A bitwise identical binary of this version can be reproduced by
building from source code at commit
c1df7f522e648eec7b4ef6a12c8758fec0f00048 associated with the tag
v_1.5.37. Release built using Java "21" 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.
Logback 1.5.36
2026-06-25 Release of logback version 1.5.36
• The 'condition' attribute in <if> elements now
reject certain references that are associated with ACE attacks. This
issue was reported by "yulate" (yulate531@gmail.com.com) and
registered as CVE-2026-13006.
Please note that version 1.5.37 provides the full fix to this
vulnerability.
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
build.gradle | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.gradle b/build.gradle
index 2b2d0c625f..c7d8eb6f1d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -30,7 +30,7 @@ ext {
openSamlVersion = "5.2.1"
commonmarkVersion = "0.28.0"
googleJavaFormatVersion = "1.35.0"
- logback = "1.5.32"
+ logback = "1.6.1"
commonsIoVersion = "2.22.0"
commonsLang3 = "3.20.0"
rhinoVersion = "1.9.1"
From 88afdd07afa9f16359cf0297957b7bffd2f27db6 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:15:58 +0100
Subject: [PATCH 03/59] build(deps): bump awsSdkVersion from 2.44.12 to 2.51.2
(#7371)
Bumps `awsSdkVersion` from 2.44.12 to 2.51.2.
Updates `software.amazon.awssdk:s3` from 2.44.12 to 2.51.2
Updates `software.amazon.awssdk:url-connection-client` from 2.44.12 to
2.51.2
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
build.gradle | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.gradle b/build.gradle
index c7d8eb6f1d..5c8f4b47bb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -44,7 +44,7 @@ ext {
batikVersion = "1.19"
jpdfiumVersion = "1.0.4"
jwtVersion = "0.13.0"
- awsSdkVersion = "2.44.12"
+ awsSdkVersion = "2.51.2"
jschVersion = "2.28.6"
commonsNetVersion = "3.11.1"
smbjVersion = "0.14.0"
From fb362f70ab77fcd4914983fa842459f46280915a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:16:15 +0100
Subject: [PATCH 04/59] build(deps): bump github/codeql-action/upload-sarif
from 4.37.4 to 4.37.6 (#7467)
Bumps
[github/codeql-action/upload-sarif](https://github.com/github/codeql-action)
from 4.37.4 to 4.37.6.
Release notes
Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
.github/codeql-config.yml to align it with the suggested
path that is used elsewhere. #4070
v4.37.5
Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the init Action instead
of falling back to downloading the bundle before extracting it. #4061
See the releases
page for the relevant changes to the CodeQL CLI and language
packs.
[UNRELEASED]
Update default CodeQL bundle version to 2.26.3.
#4085
4.37.6 - 04 Aug 2026
Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
.github/codeql-config.yml to align it with the suggested
path that is used elsewhere. #4070
4.37.5 - 03 Aug 2026
Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the init Action instead
of falling back to downloading the bundle before extracting it. #4061
4.37.4 - 29 Jul 2026
This version of the CodeQL Action adds support for the
tools input for the codeql-action/init step to
be specified using a github-codeql-toolsrepository
property. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to toolcache to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for tools in the workflow definition
always takes precedence unless the value of the repository property
starts with !. #4037
Update default CodeQL bundle version to 2.26.2.
#4051
4.37.3 - 22 Jul 2026
No user facing changes.
4.37.2 - 21 Jul 2026
The new address format for the config-file input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the remote= prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. #4023
The CodeQL Action can now make use of configured
private registries in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. #4007
4.37.1 - 16 Jul 2026
Upcoming breaking change: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. #3956
Update default CodeQL bundle version to 2.26.1.
#4019
4.37.0 - 08 Jul 2026
Update default CodeQL bundle version to 2.26.0.
#3995
In addition to the existing input format, the
config-file input for the codeql-action/init
step will soon support a new [owner/]repo[@ref][:path]
format. All components except the repository name are optional. If
omitted, owner defaults to the same owner as the repository
the analysis is running for, ref to main, and
path to .github/codeql-action.yaml. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. #3973
4.36.3 - 01 Jul 2026
No user facing changes.
4.36.2 - 04 Jun 2026
Cache CodeQL CLI version information across Actions steps. #3943
Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. #3937
Update default CodeQL bundle version to 2.25.6.
#3948
... (truncated)
Commits
5595cca
Merge pull request #4071
from github/update-v4.37.6-6a9359a1b
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/scorecards.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml
index 2eab52b1d4..0e9b27c829 100644
--- a/.github/workflows/scorecards.yml
+++ b/.github/workflows/scorecards.yml
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
- uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
+ uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
sarif_file: results.sarif
From eece9fbdf8f80a09ca66dfd70dabbb65bde141a7 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:16:32 +0100
Subject: [PATCH 05/59] build(deps): bump commons-net:commons-net from 3.11.1
to 3.13.0 (#7517)
Bumps [commons-net:commons-net](https://github.com/apache/commons-net)
from 3.11.1 to 3.13.0.
Changelog
The Apache Commons Net team is pleased to announce the release of
Apache Commons Net 3.13.0.
Apache Commons Net library contains a collection of network utilities
and protocol implementations.
Supported protocols include Echo, Finger, FTP, NNTP, NTP, POP3(S),
SMTP(S), Telnet, and Whois.
This is a feature and maintenance release. Java 8 or later is
required.
For complete information on Apache Commons Net, including
instructions on how to submit bug reports,
patches, or suggestions for improvement, see the Apache Commons Net
website:
o Add DatagramSocketClient.getDefaultTimeoutDuration() and deprecate
getDefaultTimeout(). Thanks to Gary Gregory.
o NET-741: Add subnet IPv6 handling with SubnetUtils6 #391.
Thanks to Maros Orsak, Gary Gregory.
Fixed Bugs
o DaytimeTCPClientTest now should now pass inside most VPNs. Thanks
to Henri Biestro, Gary Gregory.
o Migrate tests to JUnit5 #358,
#359.
Thanks to Jakub Kupczyk, Gary Gregory.
o Fix malformed Javadoc comments. Thanks to Gary Gregory.
o IMAPExportMbox now restores the current thread's interrupt flag when
catching InterruptedException. Thanks to Gary Gregory.
o IOUtil.readWrite() now restores the current thread's interrupt flag
when catching InterruptedException. Thanks to Gary Gregory.
o TelnetInputStream now restores the current thread's interrupt flag
when catching InterruptedException. Thanks to Gary Gregory.
o NET-740: FTP fails to parse listings for Linux vsftpd in Chinese or
Japanese #393.
Thanks to Jianwei Guo, Gary Gregory.
o TelnetInputStream.read() doesn't preserve the original
InterruptedException as the cause of its InterruptedIOException. Thanks
to Gary Gregory.
o FTPClient._storeFile(String, String, InputStream) doesn't always close
it's internal socket when an exception is thrown early in processing.
Thanks to Gary Gregory.
o ListenerList.removeListener(T) now ignores null input to avoid a
NullPointerException. Thanks to Gary Gregory.
o ListenerList.addListener(T) now ignores null input. Thanks to Gary
Gregory.
o Fix typo in FTPConnectionClosedException message from
FTP.getReply(boolean). Thanks to Gary Gregory.
o Reimplement Util.copyReader() with IOUtils.copyLarge(). Thanks to Gary
Gregory.
o Reimplement Util.copyStream() with IOUtils.copyLarge(). Thanks to Gary
Gregory.
o Reimplement Util.copyStream() with IOUtils.copyLarge(). Thanks to Gary
Gregory.
o Deprecate Util.copyReader(Reader, Writer) in favor of
IOUtils.copyLarge(Reader, Writer). Thanks to Gary Gregory.
Changes
o Bump org.apache.commons:commons-parent from 85 to 97 #371,
#388,
#389.
Thanks to Gary Gregory, Dependabot.
o Bump org.apache.commons:commons-lang3 from 3.18.0 to 3.19.0. Thanks to
Gary Gregory, Dependabot.
... (truncated)
Commits
35fa0df
Prepare for the release candidate 3.13.0 RC1
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
build.gradle | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.gradle b/build.gradle
index 5c8f4b47bb..b7fcb794cc 100644
--- a/build.gradle
+++ b/build.gradle
@@ -46,7 +46,7 @@ ext {
jwtVersion = "0.13.0"
awsSdkVersion = "2.51.2"
jschVersion = "2.28.6"
- commonsNetVersion = "3.11.1"
+ commonsNetVersion = "3.13.0"
smbjVersion = "0.14.0"
tinkVersion = "1.23.0"
testcontainersMinioVersion = "1.21.4"
From e2700bf3914bac9d855d8f7ed9b77e1ff5398187 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:16:50 +0100
Subject: [PATCH 06/59] build(deps): bump go-task/setup-task from 2.0.0 to
2.1.0 (#7468)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [go-task/setup-task](https://github.com/go-task/setup-task) from
2.0.0 to 2.1.0.
Release notes
Replaced typed-rest-client with
@actions/http-client for GitHub API calls
to eliminate the Node 24 DEP0169 deprecation warning about
url.parse() (#5 by
@vmaerten).
Modernized the TypeScript tooling stack (vitest, oxlint,
@actions/core@2,
@actions/io@2, updated @types/node,
@vercel/ncc, prettier, etc.) (#5 by
@vmaerten).
Migrated the project to ESM (sources + bundle). Aligns with the new
@actions/* ESM-only majors and produces a ~47% smaller
dist/index.js (#5 by
@vmaerten).
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
frontend/editor/src-tauri/Cargo.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock
index c7d25757c9..fe8352f5a7 100644
--- a/frontend/editor/src-tauri/Cargo.lock
+++ b/frontend/editor/src-tauri/Cargo.lock
@@ -276,9 +276,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
-version = "0.23.0"
+version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
@@ -4551,7 +4551,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
- "base64 0.23.0",
+ "base64 0.23.1",
"core-foundation 0.10.1",
"core-services",
"keyring",
From 0deb6fc3d552c25246c1cc4ded5e06ab97413de8 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:17:18 +0100
Subject: [PATCH 08/59] build(deps): bump dorny/paths-filter from 4.0.2 to
4.0.3 (#7465)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from
4.0.2 to 4.0.3.
Release notes
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/PR-Auto-Deploy-V2.yml | 2 +-
.github/workflows/build.yml | 2 +-
.github/workflows/frontend-backend-licenses-update.yml | 2 +-
.github/workflows/testdriver.yml | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml
index 1ae657fa76..c867929905 100644
--- a/.github/workflows/PR-Auto-Deploy-V2.yml
+++ b/.github/workflows/PR-Auto-Deploy-V2.yml
@@ -334,7 +334,7 @@ jobs:
# under the exact same access rules as the app preview.
- name: Detect Storybook changes
id: sb-changes
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
+ uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
list-files: json
filters: |
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 939b701ce9..ee9ebf7bd4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -55,7 +55,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
+ uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
with:
filters: .github/config/.files.yaml
diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml
index f2cca9cee4..69c2a90c63 100644
--- a/.github/workflows/frontend-backend-licenses-update.yml
+++ b/.github/workflows/frontend-backend-licenses-update.yml
@@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
+ uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
with:
filters: .github/config/.files.yaml
diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml
index f98d874dfa..fe27e0ba1f 100644
--- a/.github/workflows/testdriver.yml
+++ b/.github/workflows/testdriver.yml
@@ -159,7 +159,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
+ uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
with:
filters: ".github/config/.files.yaml"
From 88db26b001ebff746442a79a20f696fd15399ac7 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:17:29 +0100
Subject: [PATCH 09/59] build(deps): bump the eclipse-temurin group across 3
directories with 1 update (#7453)
Bumps the eclipse-temurin group with 1 update in the /docker/backend
directory: eclipse-temurin.
Bumps the eclipse-temurin group with 1 update in the /docker/base
directory: eclipse-temurin.
Bumps the eclipse-temurin group with 1 update in the /docker/embedded
directory: eclipse-temurin.
Updates `eclipse-temurin` from `2f1da10` to `fbcf915`
Updates `eclipse-temurin` from `2f1da10` to `fbcf915`
Updates `eclipse-temurin` from `2f1da10` to `fbcf915`
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
docker/backend/Dockerfile | 2 +-
docker/base/Dockerfile | 2 +-
docker/embedded/Dockerfile | 2 +-
docker/embedded/Dockerfile.fat | 2 +-
docker/embedded/Dockerfile.ultra-lite | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile
index 454fdedcfd..fd26ee2fb7 100644
--- a/docker/backend/Dockerfile
+++ b/docker/backend/Dockerfile
@@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
-FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract
+FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile
index 6cffbf88f9..95e4e84795 100644
--- a/docker/base/Dockerfile
+++ b/docker/base/Dockerfile
@@ -364,7 +364,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
# Final runtime image - the actual base image
-FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS runtime
+FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile
index de7da3789e..d4ad422476 100644
--- a/docker/embedded/Dockerfile
+++ b/docker/embedded/Dockerfile
@@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
-FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract
+FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat
index 300729e560..58eafec851 100644
--- a/docker/embedded/Dockerfile.fat
+++ b/docker/embedded/Dockerfile.fat
@@ -56,7 +56,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
-FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract
+FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite
index 68a65dea6a..d4a21b7812 100644
--- a/docker/embedded/Dockerfile.ultra-lite
+++ b/docker/embedded/Dockerfile.ultra-lite
@@ -57,7 +57,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
# Stage 2: Runtime image
# glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked.
-FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175
+FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
From fb0281fc80ef6baee42f76232fd1bdf99d6f3d9a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:18:23 +0100
Subject: [PATCH 10/59] build(deps-dev): bump codespell from 2.4.2 to 2.4.3 in
/engine (#7456)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [codespell](https://github.com/codespell-project/codespell) from
2.4.2 to 2.4.3.
Release notes
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
engine/pyproject.toml | 2 +-
engine/uv.lock | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/engine/pyproject.toml b/engine/pyproject.toml
index 3f2c277fbb..87e7cc99c0 100644
--- a/engine/pyproject.toml
+++ b/engine/pyproject.toml
@@ -64,7 +64,7 @@ updater-signatures = [
]
# Pinned repository-wide pre-commit tooling.
pre-commit = [
- "codespell==2.4.2",
+ "codespell==2.4.3",
"ruff==0.15.5",
"tomli-w==1.2.0",
]
diff --git a/engine/uv.lock b/engine/uv.lock
index 0b25716add..fbfa665bc6 100644
--- a/engine/uv.lock
+++ b/engine/uv.lock
@@ -414,11 +414,11 @@ wheels = [
[[package]]
name = "codespell"
-version = "2.4.2"
+version = "2.4.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" },
]
[[package]]
@@ -718,7 +718,7 @@ engine-dev = [
{ name = "ruff", specifier = "==0.15.5" },
]
pre-commit = [
- { name = "codespell", specifier = "==2.4.2" },
+ { name = "codespell", specifier = "==2.4.3" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "tomli-w", specifier = "==1.2.0" },
]
From beea37cb92bcdcd18a53e0311cffb4c91e2f585e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 14:18:51 +0100
Subject: [PATCH 11/59] build(deps): bump step-security/harden-runner from
2.20.0 to 2.20.1 (#7466)
Bumps
[step-security/harden-runner](https://github.com/step-security/harden-runner)
from 2.20.0 to 2.20.1.
Release notes
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/tauri-build.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml
index c25430262e..ab6d8aca71 100644
--- a/.github/workflows/tauri-build.yml
+++ b/.github/workflows/tauri-build.yml
@@ -150,7 +150,7 @@ jobs:
# only recompiles the app crate. Written on main; PRs and the merge queue
# restore from it.
- name: Cache Rust build
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: frontend/editor/src-tauri
# Stable key shared across workflows so the nightly warmer.
From 9a90797041e7d49201f60b529cd7219db6029c98 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 16 Aug 2026 17:15:47 +0100
Subject: [PATCH 13/59] build(deps): bump awsSdkVersion from 2.51.2 to 2.51.3
(#7529)
Bumps `awsSdkVersion` from 2.51.2 to 2.51.3.
Updates `software.amazon.awssdk:s3` from 2.51.2 to 2.51.3
Updates `software.amazon.awssdk:url-connection-client` from 2.51.2 to
2.51.3
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
build.gradle | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/build.gradle b/build.gradle
index b7fcb794cc..cadf4bafa6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -44,7 +44,7 @@ ext {
batikVersion = "1.19"
jpdfiumVersion = "1.0.4"
jwtVersion = "0.13.0"
- awsSdkVersion = "2.51.2"
+ awsSdkVersion = "2.51.3"
jschVersion = "2.28.6"
commonsNetVersion = "3.13.0"
smbjVersion = "0.14.0"
From 2c89f5fe5210df7c93c6feeb74ba19b23afdb635 Mon Sep 17 00:00:00 2001
From: Ludy
Date: Sun, 16 Aug 2026 18:17:16 +0200
Subject: [PATCH 14/59] fix(ci): locale path and docs (#7525)
# Description of Changes
Update the TOML locale check to match the current frontend path and
align comments/docs with the relocated editor and proprietary source
layout. This keeps CI filtering and internal references accurate after
the frontend reorganization.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
.github/config/.files.yaml | 36 +++++++++----------
.github/workflows/check_toml.yml | 2 +-
.../src/core/services/httpErrorHandler.ts | 2 +-
3 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml
index e6e4f08230..70a964b020 100644
--- a/.github/config/.files.yaml
+++ b/.github/config/.files.yaml
@@ -1,7 +1,7 @@
-# CI routing infra. Editing the top-level router (build.yml) or this filter
-# config re-runs every area's jobs, so every job-gating filter below includes
-# *ci. That makes a change to how jobs are dispatched actually exercise those
-# jobs (self-testing), instead of a router edit only matching the project filter.
+# CI routing infrastructure. Changes to the top-level router (build.yml) or
+# this filter configuration rerun every area's jobs. Every job-gating filter
+# therefore includes *ci, so routing changes exercise the jobs they affect
+# instead of matching only the project filter.
ci: &ci
- .github/workflows/build.yml
- .github/config/.files.yaml
@@ -24,9 +24,9 @@ openapi: &openapi
docker-base: &docker-base
- docker/base/Dockerfile
-# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
-# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
-# Dockerfile itself changes, not on every code PR.
+# Dockerfiles only (base, embedded, and unoserver). The slow multi-architecture
+# (arm64) leg of the PR Docker test build runs only when a Dockerfile changes,
+# rather than for every code PR.
dockerfiles: &dockerfiles
- docker/**/Dockerfile*
@@ -88,8 +88,8 @@ frontend: &frontend
- .github/workflows/e2e-stubbed.yml
- .github/workflows/e2e-live.yml
-# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
-# job on changes to any of these.
+# Files that affect the Tauri desktop bundle. Changes to any of these files
+# trigger the multi-OS Tauri build job.
tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
@@ -102,9 +102,9 @@ tauri: &tauri
- Taskfile.yml
- .taskfiles/desktop.yml
-# Files that affect the AI engine (Python tool models, fixers, tests). Gate
-# the engine validation job on changes to engine sources or to the Java
-# tool surfaces it generates models from.
+# Files that affect the AI engine, including its Python tool models, fixers,
+# and tests. The engine validation job also runs when the Java tool surfaces
+# used to generate those models change.
engine: &engine
- *ci
- engine/**
@@ -114,10 +114,10 @@ engine: &engine
- .taskfiles/engine.yml
# Files that can make the committed generated API models (frontend tool API
-# types + engine tool models) go stale: the Java tool surfaces they derive from,
-# the generators, the generated files themselves (to catch a hand-edit), and the
-# tasks that drive generation. Deliberately excludes the broad frontend/docker/
-# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
+# types and engine tool models) stale: their Java sources, generators,
+# generated outputs (to catch hand edits), and generation tasks. Broad
+# frontend, Docker, and testing globs are intentionally excluded, so a CSS-only
+# PR does not start the backend to rebuild the specification.
generated-models: &generated-models
- *ci
- *openapi
@@ -141,8 +141,8 @@ licenses-backend: &licenses-backend
- ".github/workflows/frontend-backend-licenses-update.yml"
- *build
-# Files that can affect premium / enterprise behaviour. Gate the enterprise
-# Playwright job on changes to any of these on PRs.
+# Files that can affect premium or enterprise behaviour. Changes to any of
+# these files trigger the enterprise Playwright job for pull requests.
proprietary: &proprietary
- *ci
- app/proprietary/**
diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml
index ffb3e01226..1681546e71 100644
--- a/.github/workflows/check_toml.yml
+++ b/.github/workflows/check_toml.yml
@@ -125,7 +125,7 @@ jobs:
const changedFiles = files
.filter(file =>
file.status !== "removed" &&
- /^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
+ /^frontend\/editor\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
)
.map(file => file.filename);
diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts
index ed7108b6a6..55b0a425f9 100644
--- a/frontend/editor/src/core/services/httpErrorHandler.ts
+++ b/frontend/editor/src/core/services/httpErrorHandler.ts
@@ -1,4 +1,4 @@
-// frontend/src/services/httpErrorHandler.ts
+// frontend/editor/src/core/services/httpErrorHandler.ts
import { alert } from "@app/components/toast";
import {
broadcastErroredFiles,
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 15/59] 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