Compare commits

..
Author SHA1 Message Date
Connor Yoh afe11a85a1 PAYG usage card: review nits on the pending-sync note
- Gate the unsynced-only note on pendingUnits (not meterUnits) and interpolate
  pendingUnits directly, so the "pending sync from linked instances" wording is
  always exact — robust to a hypothetical zero-doc entry carrying synced units.
- Pluralise the key (unitsPending_one/_other + count) so it reads "1 meter unit"
  not "1 meter units", matching the project's _one/_other convention.

(Headline still shows the synced PDF count — "0 PDFs" in the unsynced-only state
is by design: count dimension vs meter units, with the note explaining the gap.)
2026-07-13 09:48:54 +01:00
Connor Yoh 4648cb8b41 PAYG usage card: address review follow-ups (avg/PDF, empty-state, unique wording)
Follow-up to #6957, from the review:

1. avg-per-PDF no longer blends unsynced units over synced-only docs. It now
   divides SYNCED units (spendUnitsThisPeriod) by synced docs, so numerator and
   denominator cover the same population; combined-billing pendingUnits (units-
   only, no doc count) no longer inflates the average. The "meter units" figure
   still shows synced+pending (total current usage).

2. Empty-state: docs==0 with pending meter units (combined-billing, unsynced-
   only) now shows a "{n} meter units pending sync from linked instances" note
   instead of a bare "0 PDFs" headline with a count-less summary and no split.
   New i18n key + a UnsyncedOnly story variant.

3. uniquePdfsThisPeriod wording softened to be accurate: the fingerprint is over
   a charge's whole input SET, so the same file reused across different groupings
   (standalone vs a later merge) counts per grouping — a close approximation,
   exact for the single-input common case. Updated the FE type doc + the
   WalletLedgerEntry.document_fingerprint javadoc.
2026-07-10 15:35:42 +01:00
375 changed files with 5433 additions and 11041 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
build: &build
- build.gradle
- app/(common|core|proprietary|saas)/build.gradle
- app/(common|core|proprietary)/build.gradle
- Taskfile.yml
- .taskfiles/backend.yml
openapi: &openapi
- *build
- app/(common|core|proprietary|saas)/src/main/java/**
- app/(common|core|proprietary)/src/main/java/**
docker-base: &docker-base
- docker/base/Dockerfile
@@ -23,9 +23,9 @@ docker: &docker
- *docker-base
project: &project
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
- app/(common|core|proprietary)/src/(main|test)/java/**
- *build
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- exampleYmlFiles/**
- gradle/**
- libs/**
@@ -82,7 +82,7 @@ tauri: &tauri
# tool surfaces it generates models from.
engine: &engine
- engine/**
- app/(common|core|proprietary|saas)/src/main/java/**
- app/(common|core|proprietary)/src/main/java/**
- .github/workflows/ai-engine.yml
- Taskfile.yml
- .taskfiles/engine.yml
-7
View File
@@ -63,7 +63,6 @@ labels:
files:
- 'app/core/src/main/resources/static/.*'
- 'app/proprietary/src/main/resources/static/.*'
- 'app/saas/src/main/resources/static/.*'
- 'frontend/**'
- 'frontend/.*'
- 'frontend/**/.*'
@@ -84,7 +83,6 @@ labels:
- 'app/common/src/main/java/.*.java'
- 'app/proprietary/src/main/java/.*.java'
- 'app/core/src/main/java/.*.java'
- 'app/saas/src/main/java/.*.java'
- label: 'Back End'
files:
@@ -92,9 +90,6 @@ labels:
- 'app/core/src/main/java/stirling/software/SPDF/controller/.*'
- 'app/core/src/main/resources/settings.yml.template'
- 'app/core/src/main/resources/application.properties'
- 'app/proprietary/src/main/resources/application-proprietary.properties'
- 'app/saas/src/main/resources/application-dev.properties'
- 'app/saas/src/main/resources/application-saas.properties'
- 'app/core/src/main/resources/banner.txt'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
@@ -158,7 +153,6 @@ labels:
- 'app/common/src/test/.*'
- 'app/proprietary/src/test/.*'
- 'app/core/src/test/.*'
- 'app/saas/src/test/.*'
- 'testing/.*'
- '.github/workflows/scorecards.yml'
- 'exampleYmlFiles/test_cicd.yml'
@@ -177,4 +171,3 @@ labels:
- 'app/common/build.gradle'
- 'app/proprietary/build.gradle'
- 'app/core/build.gradle'
- 'app/saas/build.gradle'
+1 -1
View File
@@ -256,7 +256,7 @@ jobs:
- name: Build and push V2 image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -270,7 +270,7 @@ jobs:
- name: Build and push PR-specific image (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -296,7 +296,7 @@ jobs:
- name: Build and push engine image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ./engine
file: ./engine/Dockerfile
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
distribution: temurin
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+2 -2
View File
@@ -121,7 +121,7 @@ jobs:
- name: Build and push frontend image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./docker/frontend/Dockerfile
@@ -150,7 +150,7 @@ jobs:
- name: Build and push backend image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./docker/backend/Dockerfile
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
- name: Build and push base image
id: build-push-base
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: docker/base
+5 -5
View File
@@ -66,7 +66,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
@@ -145,7 +145,7 @@ jobs:
id: build-push-latest
# Empty-tag guard: build-push-action errors when asked to push with no tags.
if: env.RUN_MAIN_APP == 'true' && steps.meta.outputs.tags != ''
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -192,7 +192,7 @@ jobs:
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-fat.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
@@ -236,7 +236,7 @@ jobs:
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-lite.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
@@ -365,7 +365,7 @@ jobs:
- name: Build and push unoserver image
id: build-push-unoserver
if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode != 'skip'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
+3 -3
View File
@@ -95,7 +95,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
@@ -186,7 +186,7 @@ jobs:
# (multi-platform + gha cache) against the published base image.
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -259,7 +259,7 @@ jobs:
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
- name: Build and push test image (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./docker/embedded/Dockerfile
-5
View File
@@ -1,5 +0,0 @@
{
"ignoredFiles": [
"frontend/editor/src-tauri/icons/icon.png"
]
}
-6
View File
@@ -80,12 +80,6 @@ tasks:
OPEN: '{{.OPEN | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
# full path, hostname, or user. Consumed at dev-serve time by vite.config
# and dropped from production builds.
STIRLING_DEV_LABEL:
sh: basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
@@ -254,9 +254,6 @@ public class ApplicationProperties {
* in-network object store.
*/
private boolean allowPrivateS3Endpoints = false;
/** Max inbound webhook body (bytes); over it is rejected 413. Default 100 MB. */
private long webhookMaxBytes = 104857600L;
}
@Data
@@ -8,7 +8,6 @@ import java.nio.file.Files;
import java.time.Duration;
import java.util.regex.Pattern;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.FileSystemResource;
@@ -61,17 +60,6 @@ public class InternalApiClient {
*/
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
/**
* Header carrying the parent policy's name onto each sub-step dispatch, read from MDC key
* {@link #POLICY_NAME_MDC_KEY} (set by the policy runner on the worker thread). Lets the audit
* layer attribute a tool step to the policy that ran it, instead of showing it as a bare direct
* call.
*/
public static final String POLICY_NAME_HEADER = "X-Stirling-Policy-Name";
/** MDC key the policy runner stamps with the running policy's name; forwarded as a header. */
public static final String POLICY_NAME_MDC_KEY = "auditPolicyName";
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final TempFileManager tempFileManager;
@@ -132,19 +120,6 @@ public class InternalApiClient {
headers.add(AutomationRunContext.RUN_ID_HEADER, runId);
}
// Forward the parent policy name (set in MDC by the policy runner) so the audited sub-step
// ties back to its policy. Single-line, length-capped: it becomes an HTTP header value.
String policyName = MDC.get(POLICY_NAME_MDC_KEY);
if (policyName != null && !policyName.isBlank()) {
String safe = policyName.replaceAll("[\\r\\n]", " ").trim();
if (safe.length() > 200) {
safe = safe.substring(0, 200);
}
if (!safe.isEmpty()) {
headers.add(POLICY_NAME_HEADER, safe);
}
}
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
@@ -144,10 +144,8 @@ public class TempFileCleanupService {
int directoriesDeletedCount = 0;
for (Path directory : registry.getTempDirectories()) {
try {
if (Files.exists(directory)
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
if (Files.exists(directory)) {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
directoriesDeletedCount++;
log.debug("Cleaned up temporary directory: {}", directory);
}
@@ -277,21 +275,6 @@ public class TempFileCleanupService {
return totalDeletedCount.get();
}
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
if (maxAgeMillis <= 0) {
return true;
}
try {
long currentTime = System.currentTimeMillis();
long lastModified = Files.getLastModifiedTime(directory).toMillis();
return (currentTime - lastModified) > maxAgeMillis;
} catch (IOException e) {
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
return false;
}
}
/** Get the system temp directory path based on configuration or system property. */
private Path getSystemTempPath() {
String systemTempDir =
@@ -198,8 +198,6 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
// Webhook receiver: authenticated per-request by HMAC signature, not a session.
|| trimmedUri.startsWith("/api/v1/webhooks/")
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints - access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
@@ -155,7 +155,6 @@ public class TempFileManager {
if (directory != null && Files.isDirectory(directory)) {
try {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
log.debug("Deleted temp directory: {}", directory.toString());
} catch (IOException e) {
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
@@ -85,18 +85,6 @@ public class TempFileRegistry {
return directory;
}
/**
* Unregister a temporary directory from the registry.
*
* @param directory The directory to unregister
*/
public void unregisterDirectory(Path directory) {
if (directory != null) {
tempDirectories.remove(directory);
log.debug("Unregistered temp directory: {}", directory.toString());
}
}
/**
* Register a third-party temporary file that requires special handling.
*
@@ -176,13 +176,11 @@ class TempFileCleanupServiceMoreTest {
class ScheduledCleanup {
@Test
@DisplayName("deletes stale registered temp directories and reports counts")
@DisplayName("deletes registered temp directories and reports counts")
void deletesRegisteredDirectories() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Files.setLastModifiedTime(
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
@@ -195,22 +193,6 @@ class TempFileCleanupServiceMoreTest {
verify(tempFileManager).cleanupOldTempFiles(anyLong());
}
@Test
@DisplayName("keeps a fresh registered temp directory")
void keepsFreshRegisteredDirectory() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
withIsolatedUserHome(cleanupService::scheduledCleanup);
assertThat(Files.exists(regDir)).isTrue();
}
@Test
@DisplayName("skips a registered directory that no longer exists")
void skipsMissingRegisteredDirectory() {
@@ -176,13 +176,6 @@ class RequestUriUtilsTest {
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", ""));
}
@Test
void testIsPublicAuthEndpoint_webhookReceiver() {
// The webhook source receiver authenticates each delivery by HMAC signature, not a session.
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/whk_abc123", ""));
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/api/v1/webhooks/whk_abc123", "/app"));
}
@Test
void testIsPublicAuthEndpoint_withContextPath() {
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app"));
@@ -136,17 +136,11 @@ public class RedactController {
+ "Users can provide text patterns to redact, with options for regex and whole word matching. "
+ "Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
String rawListOfText = request.getListOfText();
String[] listOfText = request.getListOfText().split("\n");
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
if (rawListOfText == null || rawListOfText.trim().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.redaction.no.patterns", "No text patterns provided for redaction");
}
String[] listOfText = rawListOfText.split("\n");
if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) {
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
throw ExceptionUtils.createIllegalArgumentException(
"error.redaction.no.patterns", "No text patterns provided for redaction");
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -1 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 24"><symbol id="icon-redact-auto" viewBox="0 0 24 24"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect width="24" height="24" style="fill:none"/><g><path d="M17.541,15.64258a.91793.91793,0,0,1,.55469-.18555h1.1084a.91586.91586,0,0,1,.55469.18555,1.30889,1.30889,0,0,1,.40429.499,1.57206,1.57206,0,0,1,.15039.68457v5.47754H19.2041V20.21094H18.0957v2.09277H16.9873V16.82617a1.55843,1.55843,0,0,1,.15039-.68457A1.2979,1.2979,0,0,1,17.541,15.64258Zm1.66309,1.10547H18.0957v2.17187h1.1084Z" style="fill:currentColor"/><path d="M5.68653,22.30351a2.00588,2.00588,0,0,1-2-2v-16A1.92585,1.92585,0,0,1,4.274,2.891a1.92585,1.92585,0,0,1,1.4125-.5875h8l6,6v5.66931h-2V9.30351h-5v-5h-7v16h9.74021v2Z" style="fill:currentColor"/><rect width="4.338" height=".795" x="7.698" y="10.432" style="fill:currentColor"/><rect width="7.312" height="1.213" x="7.698" y="12.169" style="fill:currentColor"/><rect width="7.312" height="1.213" x="7.698" y="17.146" style="fill:currentColor"/><rect width="7.312" height=".575" x="7.698" y="14.324" style="fill:currentColor"/><rect width="5.256" height=".448" x="7.698" y="15.798" style="fill:currentColor"/></g></g></g></symbol></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
<symbol id="icon-redact-auto" viewBox="0 0 24 24"> <g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<rect width="24" height="24" style="fill: none"/>
<g>
<path d="M17.541,15.64258a.91793.91793,0,0,1,.55469-.18555h1.1084a.91586.91586,0,0,1,.55469.18555,1.30889,1.30889,0,0,1,.40429.499,1.57206,1.57206,0,0,1,.15039.68457v5.47754H19.2041V20.21094H18.0957v2.09277H16.9873V16.82617a1.55843,1.55843,0,0,1,.15039-.68457A1.2979,1.2979,0,0,1,17.541,15.64258Zm1.66309,1.10547H18.0957v2.17187h1.1084Z" style="fill: currentColor"/>
<path d="M5.68653,22.30351a2.00588,2.00588,0,0,1-2-2v-16A1.92585,1.92585,0,0,1,4.274,2.891a1.92585,1.92585,0,0,1,1.4125-.5875h8l6,6v5.66931h-2V9.30351h-5v-5h-7v16h9.74021v2Z" style="fill: currentColor"/>
<rect x="7.69809" y="10.43189" width="4.33778" height="0.79501" style="fill: currentColor"/>
<rect x="7.69809" y="12.16889" width="7.31192" height="1.21288" style="fill: currentColor"/>
<rect x="7.69809" y="17.14555" width="7.31192" height="1.21288" style="fill: currentColor"/>
<rect x="7.69809" y="14.32375" width="7.31192" height="0.57517" style="fill: currentColor"/>
<rect x="7.69809" y="15.79848" width="5.25578" height="0.4475" style="fill: currentColor"/>
</g>
</g>
</g>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="800" version="1.1" viewBox="0 0 512 512"><title>rename</title><symbol id="icon-rename" viewBox="0 0 512 512"><g id="Page-1" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="Combined-Shape" fill="currentColor"><path d="M362.666667,1.42108547e-14 L362.666667,21.3333333 L320,21.333 L320,362.666 L362.666667,362.666667 L362.666667,384 L320,383.999 L320,384 L298.666667,384 L298.666,383.999 L256,384 L256,362.666667 L298.666,362.666 L298.666,21.333 L256,21.3333333 L256,1.42108547e-14 L362.666667,1.42108547e-14 Z M426.666667,64 L426.666667,320 L341.333333,320 L341.333333,277.333333 L384,277.333333 L384,106.666667 L341.333333,106.666667 L341.333333,64 L426.666667,64 Z M277.333333,64 L277.333333,320 L3.55271368e-14,320 L3.55271368e-14,64 L277.333333,64 Z M179.2,89.6 L149.333333,89.6 L149.333333,234.666667 C149.333333,248 148.5,256.333333 147.875,264.354167 L147.792993,265.422171 L147.792993,265.422171 L147.714003,266.48894 C147.417695,270.579012 147.2,274.696296 147.2,279.466667 L147.2,279.466667 L177.066667,279.466667 L177.066667,260.266667 C184.941497,273.926888 199.708077,282.130544 215.466667,281.6 C229.540046,281.805757 242.921593,275.508559 251.733333,264.533333 C263.162478,248.989677 269.832496,230.461848 270.933333,211.2 C270.933333,170.666667 249.6,142.933333 217.6,142.933333 C202.507405,142.999748 188.308689,150.099106 179.2,162.133333 L179.2,162.133333 L179.2,89.6 Z M119.466667,162.133333 C107.961824,149.843793 91.4322333,143.546807 74.6666667,145.066667 C57.6785115,144.485924 40.8138255,148.15216 25.6,155.733333 L25.6,155.733333 L34.1333333,177.066667 C45.3979052,171.147831 57.7246848,167.522308 70.4,166.4 C78.5613135,165.511423 86.6853595,168.371259 92.4903835,174.176283 C98.2954074,179.981307 101.155244,188.105353 100.266667,196.266667 L100.266667,196.266667 L100.266667,198.4 L78.9333333,198.4 C65.8181975,197.679203 52.705771,199.864608 40.5333333,204.8 C26.2806563,210.950309 17.6507691,225.621117 19.2,241.066667 C19.0625857,252.057651 23.6679763,262.574827 31.8381493,269.927982 C40.0083223,277.281138 50.9508304,280.757072 61.8666667,279.466667 C77.2795695,280.291768 92.2192911,274.001359 102.4,262.4 L102.4,262.4 L102.4,277.333333 L130.133333,277.333333 C128.292479,266.054406 127.577851,254.620365 128,243.2 L128,243.2 L128,204.8 C129.999138,190.023932 126.995128,175.003882 119.466667,162.133333 Z M98.1333333,213.333333 L98.1333333,238.933333 C92.082572,249.988391 80.836024,257.218314 68.2666667,258.133333 C63.0655139,258.520242 57.9538681,256.621996 54.2659359,252.934064 C50.5780036,249.246132 48.6797582,244.134486 49.0666667,238.933333 C49.0666667,224 59.7333333,215.466667 85.3333333,213.333333 L85.3333333,213.333333 L98.1333333,213.333333 Z M209.066667,166.4 C226.133333,166.4 238.933333,183.466667 238.933333,211.2 C238.933333,238.933333 228.266667,256 211.2,256 C197.298049,255.69869 184.825037,247.383349 179.2,234.666667 L179.2,234.666667 L179.2,187.733333 C185.154203,176.240507 196.263981,168.304951 209.066667,166.4 Z" transform="translate(42.666667, 64.000000)"/></g></g></symbol></svg>
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>rename</title>
<symbol id="icon-rename" viewBox="0 0 512 512">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Combined-Shape" fill="currentColor" transform="translate(42.666667, 64.000000)">
<path d="M362.666667,1.42108547e-14 L362.666667,21.3333333 L320,21.333 L320,362.666 L362.666667,362.666667 L362.666667,384 L320,383.999 L320,384 L298.666667,384 L298.666,383.999 L256,384 L256,362.666667 L298.666,362.666 L298.666,21.333 L256,21.3333333 L256,1.42108547e-14 L362.666667,1.42108547e-14 Z M426.666667,64 L426.666667,320 L341.333333,320 L341.333333,277.333333 L384,277.333333 L384,106.666667 L341.333333,106.666667 L341.333333,64 L426.666667,64 Z M277.333333,64 L277.333333,320 L3.55271368e-14,320 L3.55271368e-14,64 L277.333333,64 Z M179.2,89.6 L149.333333,89.6 L149.333333,234.666667 C149.333333,248 148.5,256.333333 147.875,264.354167 L147.792993,265.422171 L147.792993,265.422171 L147.714003,266.48894 C147.417695,270.579012 147.2,274.696296 147.2,279.466667 L147.2,279.466667 L177.066667,279.466667 L177.066667,260.266667 C184.941497,273.926888 199.708077,282.130544 215.466667,281.6 C229.540046,281.805757 242.921593,275.508559 251.733333,264.533333 C263.162478,248.989677 269.832496,230.461848 270.933333,211.2 C270.933333,170.666667 249.6,142.933333 217.6,142.933333 C202.507405,142.999748 188.308689,150.099106 179.2,162.133333 L179.2,162.133333 L179.2,89.6 Z M119.466667,162.133333 C107.961824,149.843793 91.4322333,143.546807 74.6666667,145.066667 C57.6785115,144.485924 40.8138255,148.15216 25.6,155.733333 L25.6,155.733333 L34.1333333,177.066667 C45.3979052,171.147831 57.7246848,167.522308 70.4,166.4 C78.5613135,165.511423 86.6853595,168.371259 92.4903835,174.176283 C98.2954074,179.981307 101.155244,188.105353 100.266667,196.266667 L100.266667,196.266667 L100.266667,198.4 L78.9333333,198.4 C65.8181975,197.679203 52.705771,199.864608 40.5333333,204.8 C26.2806563,210.950309 17.6507691,225.621117 19.2,241.066667 C19.0625857,252.057651 23.6679763,262.574827 31.8381493,269.927982 C40.0083223,277.281138 50.9508304,280.757072 61.8666667,279.466667 C77.2795695,280.291768 92.2192911,274.001359 102.4,262.4 L102.4,262.4 L102.4,277.333333 L130.133333,277.333333 C128.292479,266.054406 127.577851,254.620365 128,243.2 L128,243.2 L128,204.8 C129.999138,190.023932 126.995128,175.003882 119.466667,162.133333 Z M98.1333333,213.333333 L98.1333333,238.933333 C92.082572,249.988391 80.836024,257.218314 68.2666667,258.133333 C63.0655139,258.520242 57.9538681,256.621996 54.2659359,252.934064 C50.5780036,249.246132 48.6797582,244.134486 49.0666667,238.933333 C49.0666667,224 59.7333333,215.466667 85.3333333,213.333333 L85.3333333,213.333333 L98.1333333,213.333333 Z M209.066667,166.4 C226.133333,166.4 238.933333,183.466667 238.933333,211.2 C238.933333,238.933333 228.266667,256 211.2,256 C197.298049,255.69869 184.825037,247.383349 179.2,234.666667 L179.2,234.666667 L179.2,187.733333 C185.154203,176.240507 196.263981,168.304951 209.066667,166.4 Z">
</path>
</g>
</g>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -299,18 +299,6 @@ class RedactControllerMoreTest {
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
}
@Test
@DisplayName("null listOfText throws an illegal-argument error before any load")
void nullPatternsThrows() throws Exception {
RedactPdfRequest request = new RedactPdfRequest();
request.setFileInput(pdfFile(new byte[] {1, 2, 3}));
request.setListOfText(null);
assertThatThrownBy(() -> controller.redactPdf(request))
.isInstanceOf(RuntimeException.class);
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
}
@Test
@DisplayName("null file input is reported as a failure")
void nullFileThrows() {
@@ -142,9 +142,6 @@ public class AuditAspect {
auditService.addTimingData(
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
// Merge controller-set policy context + the internal-automation marker onto the event.
auditService.addAutomationContext(auditData, req);
// Resolve the event type based on annotation and context
String httpMethod = null;
String path = null;
@@ -1,18 +0,0 @@
package stirling.software.proprietary.audit;
/**
* Request-scoped keys a controller can set to enrich its own audit event with context the generic
* aspect can't infer from the HTTP request alone (e.g. the policy a pipeline run belongs to). The
* aspect copies these into the audit data in its {@code finally} block, after the controller body
* has run. See {@code AuditService#addAutomationContext}.
*/
public final class AuditContext {
/** Request attribute: the name of the policy/pipeline a run executes. */
public static final String REQ_ATTR_POLICY_NAME = "stirling.audit.policyName";
/** Request attribute: the ordered tool endpoint paths a run executes. */
public static final String REQ_ATTR_POLICY_STEPS = "stirling.audit.policySteps";
private AuditContext() {}
}
@@ -205,10 +205,6 @@ public class ControllerAuditAspect {
// Call auditService but with isHttpRequest=true to skip additional timing
auditService.addTimingData(data, start, resp, level, true);
// Merge controller-set policy context + the internal-automation marker (set after
// the body ran, so it must happen here rather than with the pre-proceed HTTP data).
auditService.addAutomationContext(data, req);
// Resolve the event type using the unified method
AuditEventType eventType =
auditService.resolveEventType(
@@ -43,10 +43,6 @@ public class IntegrationConfigService {
private final OwnershipService ownership;
private final SecretMasker secretMasker;
private final ResourceGrantRepository grantRepository;
// Bean-discovered extension points: features that understand a type contribute its config
// schema and report what still references a config, without this module depending on them.
private final List<IntegrationConfigValidator> validators;
private final List<IntegrationConfigUsageCheck> usageChecks;
// ---- commands ----
@@ -70,21 +66,13 @@ public class IntegrationConfigService {
? DefaultAccessPolicy.EXPLICIT_ONLY
: request.defaultAccess());
// TEAM scope may omit the team id: default to the caller's own team so clients (the
// portal) need not know it. assignOwnership still enforces admin-or-leader of that team.
Long ownerTeamId = request.ownerTeamId();
if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) {
ownerTeamId = currentUser.getTeam().getId();
}
ownership.assignOwnership(
cfg,
scope,
ownerTeamId,
request.ownerTeamId(),
currentUser,
() -> lockedServerExists(cfg.getIntegrationType()));
Map<String, Object> config = secretMasker.sanitize(request.config());
validateConfig(cfg.getIntegrationType(), config);
cfg.setConfig(writeJson(config));
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
return repository.save(cfg);
}
@@ -113,10 +101,8 @@ public class IntegrationConfigService {
cfg.setDefaultAccess(request.defaultAccess());
}
if (request.config() != null) {
Map<String, Object> merged =
secretMasker.merge(readJson(cfg.getConfig()), request.config());
validateConfig(cfg.getIntegrationType(), merged);
cfg.setConfig(writeJson(merged));
cfg.setConfig(
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
}
return repository.save(cfg);
}
@@ -127,15 +113,6 @@ public class IntegrationConfigService {
if (!ownership.canManage(TYPE, cfg, currentUser)) {
throw forbidden("You cannot manage this integration");
}
// Refuse to pull a connection out from under whatever still references it.
List<String> usages =
usageChecks.stream()
.flatMap(check -> check.usagesOf(cfg.getId()).stream())
.toList();
if (!usages.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages));
}
// Drop grants sharing this config so they do not dangle as dead rows.
grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId()));
repository.delete(cfg);
@@ -211,19 +188,6 @@ public class IntegrationConfigService {
// ---- integration-specific glue ----
/** Runs every registered validator for the type; unknown types save free-form. */
private void validateConfig(IntegrationType type, Map<String, Object> config) {
for (IntegrationConfigValidator validator : validators) {
if (validator.type() == type) {
try {
validator.validate(config == null ? Map.of() : config);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
}
}
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
private boolean lockedServerExists(IntegrationType type) {
return repository.findByScope(OwnerScope.SERVER).stream()
@@ -1,15 +0,0 @@
package stirling.software.proprietary.integration.service;
import java.util.List;
/**
* Reports what still references an integration config, so deletion can be refused instead of
* pulling a connection out from under a live consumer. Implementations are beans discovered by
* {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that
* reference a connection).
*/
public interface IntegrationConfigUsageCheck {
/** Human-readable labels of everything still using the config; empty when unreferenced. */
List<String> usagesOf(long configId);
}
@@ -1,23 +0,0 @@
package stirling.software.proprietary.integration.service;
import java.util.Map;
import stirling.software.proprietary.integration.model.IntegrationType;
/**
* Validates one integration type's config map at save time. Implementations are beans discovered by
* {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3
* backend) owns its schema without the integration module depending on it. Types with no registered
* validator save free-form.
*/
public interface IntegrationConfigValidator {
/** The type this validator understands. */
IntegrationType type();
/**
* Validates the config as it will be stored (secrets already sanitized/merged, so values are
* real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config.
*/
void validate(Map<String, Object> config);
}
@@ -17,9 +17,6 @@ public class InfraAuditSummary {
@Schema(description = "Total events in the returned window", example = "40")
private int totalEvents;
@Schema(description = "Policy-run events", example = "3")
private int policy;
@Schema(description = "Processing-category events", example = "24")
private int processing;
@@ -23,8 +23,6 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -34,7 +32,6 @@ import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -45,7 +42,6 @@ import stirling.software.common.model.job.JobResponse;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.audit.AuditContext;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
@@ -56,7 +52,6 @@ import stirling.software.proprietary.policy.engine.SweepOutcome;
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineDefinition;
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.model.PolicyRun;
@@ -118,9 +113,7 @@ public class PolicyController {
@RequestPart("json") PipelineDefinition definition,
@Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocOutput(definition);
PolicyInputs inputs = toInputs(files);
PolicyRunHandle handle =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
@@ -139,9 +132,7 @@ public class PolicyController {
@RequestPart("json") PipelineDefinition definition,
@Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocOutput(definition);
PolicyInputs inputs = toInputs(files);
SseEmitter emitter =
@@ -473,7 +464,6 @@ public class PolicyController {
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
stampPolicyAudit(policy.toDefinition());
PolicyInputs inputs = toInputs(files);
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
@@ -507,49 +497,6 @@ public class PolicyController {
}
}
/**
* Stamp this run's policy name and step endpoints onto the current request so the audit aspect
* can label the event as the policy it ran (rather than the generic {@code /run} endpoint) and
* record which tools it executed. No-op outside a web request.
*/
private static void stampPolicyAudit(PipelineDefinition definition) {
if (definition == null
|| !(RequestContextHolder.getRequestAttributes()
instanceof ServletRequestAttributes attrs)) {
return;
}
HttpServletRequest request = attrs.getRequest();
if (definition.name() != null && !definition.name().isBlank()) {
request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, definition.name());
}
List<String> steps =
definition.steps().stream()
.map(PipelineStep::operation)
.filter(op -> op != null && !op.isBlank())
.toList();
if (!steps.isEmpty()) {
request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, steps);
}
}
/**
* Authorization-check an ad-hoc run's output while the caller's principal is present (this
* request thread). The worker thread that later delivers carries no security context, so an S3
* output's connection-access check would be skipped there; without this gate a caller could
* reference another tenant's connection by id and write to it (confused deputy). Stored
* policies are covered by save-time {@link PolicyValidator#validate} instead.
*/
private void validateAdHocOutput(PipelineDefinition definition) {
if (definition.output() == null) {
return;
}
try {
policyValidator.validateOutput(definition.output());
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
/**
* Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents
* feed the same virtual editor source as stored editor policies, counted against the caller's
@@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.AutomationRunContext;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
@@ -150,7 +149,6 @@ public class PolicyEngine {
runAsPrincipal(
billingPrincipal,
fileOwner,
definition.name(),
() -> runToCompletion(run, inputs, tracking, completion));
// One admission unit per run; steps run synchronously within it, so this gates heavy work
@@ -379,26 +377,18 @@ public class PolicyEngine {
* dispatch attributes (and charges) usage to that user. A null/blank principal runs as-is.
* Restores the previous MDC value afterward (defensive — worker threads aren't pooled).
*/
private static void runAsPrincipal(
String billingPrincipal, String fileOwner, String policyName, Runnable body) {
private static void runAsPrincipal(String billingPrincipal, String fileOwner, Runnable body) {
// Billing identity (MDC auditPrincipal) and output-file ownership (JobContext owner) are
// set
// independently: usage is charged to billingPrincipal, but stored output files are owned by
// fileOwner — the user who triggered an org-wide policy — so they can fetch their results.
// Either may be null (e.g. login disabled, or a trigger-fired run); each is applied only
// when present and restored afterward (defensive — worker threads aren't pooled). The
// policy
// name rides MDC too so each tool step's loopback dispatch (InternalApiClient) can forward
// it as a header, letting the audit tie the step back to its policy.
// when present and restored afterward (defensive — worker threads aren't pooled).
String previousPrincipal = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
String previousPolicyName = MDC.get(InternalApiClient.POLICY_NAME_MDC_KEY);
String previousOwner = JobContext.getOwner();
if (billingPrincipal != null && !billingPrincipal.isBlank()) {
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, billingPrincipal);
}
if (policyName != null && !policyName.isBlank()) {
MDC.put(InternalApiClient.POLICY_NAME_MDC_KEY, policyName);
}
if (fileOwner != null && !fileOwner.isBlank()) {
JobContext.setOwner(fileOwner);
}
@@ -410,11 +400,6 @@ public class PolicyEngine {
} else {
MDC.remove(AUDIT_PRINCIPAL_MDC_KEY);
}
if (previousPolicyName != null) {
MDC.put(InternalApiClient.POLICY_NAME_MDC_KEY, previousPolicyName);
} else {
MDC.remove(InternalApiClient.POLICY_NAME_MDC_KEY);
}
JobContext.setOwner(previousOwner);
}
}
@@ -52,19 +52,7 @@ public class PolicyValidator {
InputSpec spec = source.toInputSpec();
inputSourceFor(spec).validate(spec);
}
validateOutput(policy.output());
}
/**
* Validate an output spec against its sink. Must be called on a request thread (caller's
* principal present) so an S3 output's connection is authorization-checked against the caller -
* ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker
* thread that later delivers has no principal, so this is their only access gate.
*
* @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible
*/
public void validateOutput(OutputSpec output) {
outputSinkFor(output).validate(output);
outputSinkFor(policy.output()).validate(policy.output());
}
private PolicyTrigger triggerFor(TriggerConfig config) {
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.input;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -23,12 +22,6 @@ public interface InputSource {
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
default void validate(InputSpec spec) {}
/** Normalise a source's options before persistence (default no-op); must not mutate the arg. */
default Map<String, Object> prepareOptionsForSave(
Map<String, Object> options, boolean isCreate) {
return options;
}
/**
* Resolve the spec into zero or more units of work, each carrying one run's files and a
* completion hook. Empty list means nothing to run right now. Discovery is read-only - files
@@ -18,7 +18,6 @@ import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.s3.S3Config;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
import stirling.software.proprietary.policy.s3.S3Identities;
import software.amazon.awssdk.core.exception.SdkException;
@@ -37,14 +36,15 @@ import software.amazon.awssdk.services.s3.model.S3Object;
* Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit
* of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and
* version gate come from {@link S3Identities}, so the steady-state sweep never downloads content.
* Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning
* bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix"
* (only keys starting with it are read) and "mode" are per-source, where mode is "consume"
* (default: a processed object is deleted once every policy that claimed it has settled
* successfully and it is still the version that ran; failures stay in place and are not retried
* until they change) or "snapshot" (stateless, every run sees the full set). Keys ending in "/"
* (folder placeholders) and keys with a dot-prefixed path segment are never picked up, mirroring
* the folder source's hidden-file rule.
* Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only
* keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style
* addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are
* never signed with the server's own AWS identity), and "mode" which is "consume" (default: a
* processed object is deleted once every policy that claimed it has settled successfully and it is
* still the version that ran; failures stay in place and are not retried until they change) or
* "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and
* keys with a dot-prefixed path segment are never picked up, mirroring the folder source's
* hidden-file rule.
*/
@Slf4j
@Service
@@ -55,7 +55,6 @@ public class S3InputSource implements InputSource {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final S3ConnectionResolver connectionResolver;
@Override
public String type() {
@@ -68,12 +67,12 @@ public class S3InputSource implements InputSource {
}
/**
* Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private
* endpoint without the operator opt-in, or a bucket the connection cannot list.
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
* a bucket the supplied credentials cannot list.
*/
@Override
public void validate(InputSpec spec) {
S3Config config = connectionResolver.resolve(spec.options());
S3Config config = S3Config.from(spec.options());
try {
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
} catch (SdkException e) {
@@ -90,7 +89,7 @@ public class S3InputSource implements InputSource {
@Override
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
S3Config config = connectionResolver.resolve(spec.options());
S3Config config = S3Config.from(spec.options());
S3Client client = connectionPool.clientFor(config);
// A listing failure propagates so the sweep reads it as "could not list" (which vetoes
// presence cleanup), never as "verifiably no objects".
@@ -1,191 +0,0 @@
package stirling.software.proprietary.policy.input;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.ledger.FolderIdentities;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.webhook.WebhookConfig;
import stirling.software.proprietary.policy.webhook.WebhookIds;
import stirling.software.proprietary.policy.webhook.WebhookSpool;
/** Push source: deliveries staged to an S3 connection or the local spool, read via the ledger. */
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookInputSource implements InputSource {
static final String TYPE = "webhook";
private final WebhookSpool spool;
private final FileReadinessChecker readinessChecker;
// Connection-backed staging delegates to the S3 source; else the local spool.
private final S3InputSource s3InputSource;
@Override
public String type() {
return TYPE;
}
@Override
public boolean supports(InputSpec spec) {
return spec != null && TYPE.equals(spec.type());
}
@Override
public void validate(InputSpec spec) {
WebhookConfig config = WebhookConfig.from(spec.options());
if (config.usesConnection()) {
// Resolve with the caller present so save fails if they can't use the connection.
s3InputSource.validate(stagingSpec(config));
}
}
/** The webhook's durable staging as an {@code s3} input spec for {@link S3InputSource}. */
private static InputSpec stagingSpec(WebhookConfig config) {
return new InputSpec(
"s3",
Map.of(
WebhookConfig.CONNECTION_ID_OPTION,
config.connectionId(),
"prefix",
config.stagingPrefix(),
"mode",
config.mode()));
}
/** Mint the routing id + signing secret on create; an existing webhook is left untouched. */
@Override
public Map<String, Object> prepareOptionsForSave(
Map<String, Object> options, boolean isCreate) {
boolean hasId =
options.get(WebhookConfig.WEBHOOK_ID_OPTION) != null
&& !options.get(WebhookConfig.WEBHOOK_ID_OPTION).toString().isBlank();
if (!isCreate && hasId) {
return options;
}
Map<String, Object> prepared = new LinkedHashMap<>(options);
if (!hasId) {
prepared.put(WebhookConfig.WEBHOOK_ID_OPTION, WebhookIds.newWebhookId());
}
Object secret = prepared.get(WebhookConfig.SIGNING_SECRET_OPTION);
if (secret == null || secret.toString().isBlank()) {
prepared.put(WebhookConfig.SIGNING_SECRET_OPTION, WebhookIds.newSigningSecret());
}
return prepared;
}
@Override
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
WebhookConfig config = WebhookConfig.from(spec.options());
if (config.usesConnection()) {
// Durable staging read by the S3 source (no principal; the save-time check is trusted).
return s3InputSource.resolve(stagingSpec(config), ctx);
}
Path dir = spool.dirFor(config.webhookId());
if (!Files.isDirectory(dir)) {
// No deliveries yet: verifiably empty (a missing dir is normal here, not an error).
ctx.reportPresent(List.of());
return List.of();
}
Path canonicalDir = FolderIdentities.canonicalDir(dir);
List<Path> present = listFiles(dir);
if (config.snapshot()) {
List<ResolvedInput> work = new ArrayList<>();
for (Path file : present) {
if (readinessChecker.isReady(file)) {
work.add(ResolvedInput.of(PolicyInputs.of(List.of(fileResource(file)))));
}
}
return work;
}
ctx.reportPresent(
present.stream()
.map(file -> FolderIdentities.identity(canonicalDir, dir, file))
.toList());
List<ResolvedInput> work = new ArrayList<>();
for (Path file : present) {
if (!readinessChecker.isReady(file)) {
continue;
}
String identity = FolderIdentities.identity(canonicalDir, dir, file);
String gate;
boolean claimed;
try {
gate = FolderIdentities.statGate(file);
claimed = ctx.claim(identity, gate, null);
} catch (IOException | UncheckedIOException e) {
log.debug("Could not read {} for its version: {}", file, e.getMessage());
continue; // vanished or unreadable mid-sweep; the next sweep sees the truth
}
if (!claimed) {
continue;
}
work.add(
new ResolvedInput(
PolicyInputs.of(List.of(fileResource(file))),
success -> completeConsumed(ctx, identity, file, gate, success)));
}
return work;
}
/** Settle, then delete when unchanged and every claimant settled DONE (consensus). */
private static void completeConsumed(
ResolveContext ctx, String identity, Path file, String claimGate, boolean success) {
ctx.settle(identity, claimGate, null, success);
if (!success) {
return;
}
try {
if (FolderIdentities.statGate(file).equals(claimGate) && ctx.allSettledDone(identity)) {
Files.deleteIfExists(file);
}
} catch (java.nio.file.NoSuchFileException alreadyGone) {
// Removed by the user or a co-watching policy's own consensus delete: nothing to do.
} catch (IOException e) {
log.warn("Could not remove consumed webhook delivery {}: {}", file, e.getMessage());
}
}
/** Every non-hidden regular file currently spooled for the webhook. */
private static List<Path> listFiles(Path dir) throws IOException {
List<Path> files = new ArrayList<>();
try (Stream<Path> entries = Files.list(dir)) {
entries.filter(Files::isRegularFile)
.filter(file -> !file.getFileName().toString().startsWith("."))
.forEach(files::add);
}
return files;
}
private static Resource fileResource(Path path) {
String name = WebhookSpool.displayName(path.getFileName().toString());
return new FileSystemResource(path.toFile()) {
@Override
public String getFilename() {
return name;
}
};
}
}
@@ -27,7 +27,6 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.s3.S3Config;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
import stirling.software.proprietary.policy.s3.S3Identities;
import software.amazon.awssdk.core.exception.SdkException;
@@ -60,7 +59,6 @@ public class S3OutputSink implements PolicyOutputSink {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final S3ConnectionResolver connectionResolver;
private final ProcessedLedger processedLedger;
@Override
@@ -74,19 +72,19 @@ public class S3OutputSink implements PolicyOutputSink {
}
/**
* Connection resolution (including the saving user's right to use it) and endpoint guard only -
* no network probe, since write-only credentials (s3:PutObject without s3:ListBucket) are a
* legitimate setup for an output bucket and a listing probe would wrongly reject them.
* Config shape and endpoint guard only - no network probe, since write-only credentials
* (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a
* listing probe would wrongly reject them.
*/
@Override
public void validate(OutputSpec spec) {
connectionPool.clientFor(connectionResolver.resolve(spec.options()));
connectionPool.clientFor(S3Config.from(spec.options()));
}
@Override
public List<ResultFile> deliver(
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
S3Config config = connectionResolver.resolve(spec.options());
S3Config config = S3Config.from(spec.options());
S3Client client = connectionPool.clientFor(config);
List<ResultFile> results = new ArrayList<>();
@@ -1,212 +0,0 @@
package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.OwnerScope;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.security.repository.TeamRepository;
import tools.jackson.databind.ObjectMapper;
/**
* One-time, idempotent extraction of legacy embedded S3 credentials into stored connections:
* sources and policy outputs written before connections shipped carry bucket/credentials in their
* own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and
* keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at
* the application layer, so no SQL migration can read it.
*
* <p>Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find
* nothing to do. Connections are deduplicated against both this run's extractions and existing S3
* connections; a concurrent multi-node boot can at worst create a redundant connection row, never
* corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a
* team, server-scoped otherwise (single-operator self-hosted).
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class EmbeddedS3CredentialMigration {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final List<String> CONNECTION_OPTIONS =
List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey");
// Field separator for the dedup key: a unit-separator control char that cannot appear in a
// bucket/region/endpoint/credential, so distinct field sets can never collide.
private static final char DELIMITER = '\u001f';
private final SourceStore sourceStore;
private final PolicyStore policyStore;
private final IntegrationConfigRepository connections;
private final TeamRepository teamRepository;
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void migrate() {
Map<String, IntegrationConfig> byCredentialKey = indexExistingConnections();
int migrated = 0;
for (Source source : sourceStore.all()) {
if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) {
continue;
}
IntegrationConfig connection =
connectionFor(source.options(), source.teamId(), byCredentialKey);
sourceStore.save(withOptions(source, referencing(connection, source.options(), true)));
migrated++;
}
for (Policy policy : policyStore.all()) {
OutputSpec output = policy.output();
if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) {
continue;
}
IntegrationConfig connection =
connectionFor(output.options(), policy.teamId(), byCredentialKey);
policyStore.save(
withOutput(
policy,
new OutputSpec(
output.type(),
referencing(connection, output.options(), false))));
migrated++;
}
if (migrated > 0) {
log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated);
}
}
private static boolean embedsCredentials(Map<String, Object> options) {
return options.get("accessKeyId") != null;
}
/** Reuses an existing connection with identical coordinates+credentials, else creates one. */
private IntegrationConfig connectionFor(
Map<String, Object> options, Long teamId, Map<String, IntegrationConfig> byKey) {
String key = credentialKey(options);
IntegrationConfig existing = byKey.get(key);
if (existing != null) {
return existing;
}
IntegrationConfig connection = new IntegrationConfig();
connection.setIntegrationType(IntegrationType.S3);
connection.setName(connectionName(options, byKey));
connection.setEnabled(true);
connection.setLocked(false);
connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY);
Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null);
if (team != null) {
connection.setScope(OwnerScope.TEAM);
connection.setOwnerTeam(team);
} else {
// No team (teamless self-hosted, or a source whose team was since deleted): server
// scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need
// an admin to re-share the connection - acceptable for the narrow orphaned case.
connection.setScope(OwnerScope.SERVER);
}
Map<String, Object> config = new LinkedHashMap<>();
for (String option : CONNECTION_OPTIONS) {
Object value = options.get(option);
if (value != null && !value.toString().isBlank()) {
config.put(option, value);
}
}
connection.setConfig(OBJECT_MAPPER.writeValueAsString(config));
IntegrationConfig saved = connections.save(connection);
byKey.put(key, saved);
return saved;
}
/** The rewritten options: the connection reference plus per-use settings only. */
private static Map<String, Object> referencing(
IntegrationConfig connection, Map<String, Object> legacy, boolean keepMode) {
Map<String, Object> options = new LinkedHashMap<>();
options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId());
Object prefix = legacy.get("prefix");
if (prefix != null && !prefix.toString().isBlank()) {
options.put("prefix", prefix);
}
Object mode = legacy.get("mode");
if (keepMode && mode != null && !mode.toString().isBlank()) {
options.put("mode", mode);
}
return options;
}
private Map<String, IntegrationConfig> indexExistingConnections() {
Map<String, IntegrationConfig> byKey = new LinkedHashMap<>();
for (IntegrationConfig connection : connections.findAll()) {
if (connection.getIntegrationType() != IntegrationType.S3) {
continue;
}
try {
Map<String, Object> config =
OBJECT_MAPPER.readValue(connection.getConfig(), Map.class);
byKey.putIfAbsent(credentialKey(config), connection);
} catch (Exception e) {
log.debug(
"Skipping unreadable S3 connection {} while indexing: {}",
connection.getId(),
e.getMessage());
}
}
return byKey;
}
private static String credentialKey(Map<String, Object> options) {
StringBuilder key = new StringBuilder();
for (String option : CONNECTION_OPTIONS) {
Object value = options.get(option);
key.append(value == null ? "" : value.toString().trim()).append(DELIMITER);
}
return key.toString();
}
private static String connectionName(
Map<String, Object> options, Map<String, IntegrationConfig> byKey) {
String base = "S3: " + options.getOrDefault("bucket", "bucket");
long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count();
return sameName == 0 ? base : base + " (" + (sameName + 1) + ")";
}
private static Source withOptions(Source source, Map<String, Object> options) {
return new Source(
source.id(),
source.name(),
source.type(),
options,
source.enabled(),
source.owner(),
source.teamId());
}
private static Policy withOutput(Policy policy, OutputSpec output) {
return new Policy(
policy.id(),
policy.name(),
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.sourceIds(),
policy.steps(),
output,
policy.teamId());
}
}
@@ -1,56 +0,0 @@
package stirling.software.proprietary.policy.s3;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection
* cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source
* delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live
* stores.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
private final SourceStore sourceStore;
private final PolicyStore policyStore;
@Override
public List<String> usagesOf(long configId) {
List<String> usages = new ArrayList<>();
for (Source source : sourceStore.all()) {
if (references(source.options(), configId)) {
usages.add("source '" + source.name() + "'");
}
}
for (Policy policy : policyStore.all()) {
if (references(policy.output().options(), configId)) {
usages.add("pipeline '" + policy.name() + "'");
}
}
return usages;
}
private static boolean references(Map<String, Object> options, long configId) {
try {
Long reference = S3ConnectionResolver.connectionId(options);
return reference != null && reference == configId;
} catch (IllegalArgumentException unparseable) {
return false;
}
}
}
@@ -5,12 +5,10 @@ import java.net.URISyntaxException;
import java.util.Map;
/**
* The fully resolved connection settings the S3 input source and output sink run with - normally
* produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint,
* credentials) with per-use options (prefix, mode), or parsed directly from legacy options that
* still embed credentials. Credentials are required: there is deliberately no fallback to the
* server's own AWS credential chain, so user-supplied config can never borrow the host's identity.
* {@code snapshot} is input-only and ignored by the sink.
* Connection settings shared by the S3 input source and output sink, parsed from a spec's options
* map. Credentials are required: there is deliberately no fallback to the server's own AWS
* credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot}
* is input-only and ignored by the sink.
*/
public record S3Config(
String bucket,
@@ -1,151 +0,0 @@
package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code
* connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region,
* endpoint, and credentials; the options own per-use settings such as prefix and mode). Options
* with no {@code connectionId} fall back to legacy embedded credentials, so rows written before
* connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them.
*
* <p>When an authenticated caller is present (save-time validation), they must be allowed to use
* the connection. Background sweeps and deliveries run with no caller and skip that check: the
* referencing source or policy was access-checked when it was saved.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionResolver {
static final String CONNECTION_ID_OPTION = "connectionId";
private static final String PREFIX_OPTION = "prefix";
private static final String MODE_OPTION = "mode";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final IntegrationConfigRepository connections;
private final OwnershipService ownership;
private final UserService userService;
public S3Config resolve(Map<String, Object> options) {
Long connectionId = connectionId(options);
if (connectionId == null) {
// Legacy embedded credentials, pending migration.
return S3Config.from(options);
}
IntegrationConfig connection =
connections
.findById(connectionId)
.filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3)
.filter(this::usableByCurrentUser)
// Existence and access collapse into one error: a caller must not be able
// to tell "no such connection" from "someone else's connection" and
// enumerate ids. The id/name are never echoed.
.orElseThrow(
() ->
new IllegalArgumentException(
"unknown or inaccessible s3 connection"));
if (!connection.isEnabled()) {
throw new IllegalArgumentException("s3 connection is disabled");
}
Map<String, Object> merged = new LinkedHashMap<>(connectionConfig(connection));
copyPerUseOption(options, merged, PREFIX_OPTION);
copyPerUseOption(options, merged, MODE_OPTION);
return S3Config.from(merged);
}
/** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */
static Long connectionId(Map<String, Object> options) {
Object reference = options.get(CONNECTION_ID_OPTION);
if (reference == null || (reference instanceof String s && s.isBlank())) {
return null;
}
if (reference instanceof Number number) {
return number.longValue();
}
try {
return Long.valueOf(reference.toString().trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"s3 'connectionId' is not a valid connection reference: " + reference);
}
}
/**
* Whether the current caller may use this connection. With no principal - a background sweep or
* delivery on a worker thread that carries no {@code SecurityContext} - access is treated as
* already established: stored policies are validated with the caller present at save time, and
* ad-hoc runs are validated on the request thread before dispatch (see {@code
* PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing
* standing between a caller and a connection, or the check becomes a confused deputy.
*/
private boolean usableByCurrentUser(IntegrationConfig connection) {
User user = currentUser();
return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user);
}
// Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated.
private User currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return null;
}
Object principal = auth.getPrincipal();
if (principal instanceof User user) {
return user;
}
if (principal instanceof UserDetails userDetails) {
return userService.findByUsername(userDetails.getUsername()).orElse(null);
}
if (principal instanceof String username && !"anonymousUser".equals(username)) {
return userService.findByUsername(username).orElse(null);
}
return null;
}
private static Map<String, Object> connectionConfig(IntegrationConfig connection) {
String json = connection.getConfig();
if (json == null || json.isBlank()) {
return Map.of();
}
try {
return OBJECT_MAPPER.readValue(
json, new TypeReference<LinkedHashMap<String, Object>>() {});
} catch (Exception e) {
throw new IllegalArgumentException(
"s3 connection '" + connection.getName() + "' has unreadable config", e);
}
}
private static void copyPerUseOption(
Map<String, Object> options, Map<String, Object> merged, String key) {
Object value = options.get(key);
if (value != null && !value.toString().isBlank()) {
merged.put(key, value);
}
}
}
@@ -1,51 +0,0 @@
package stirling.software.proprietary.policy.s3;
import java.net.URI;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.cluster.s3.S3Clients;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
/**
* The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and
* credentials required, endpoint an http(s) URL that must not reach private addresses without the
* operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save
* time so a bad connection fails in the form rather than in a sweep.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3IntegrationValidator implements IntegrationConfigValidator {
private final ApplicationProperties applicationProperties;
@Override
public IntegrationType type() {
return IntegrationType.S3;
}
@Override
public void validate(Map<String, Object> config) {
S3Config parsed = S3Config.from(config);
if (parsed.endpoint() == null) {
return;
}
try {
S3Clients.validateEndpointHost(
URI.create(parsed.endpoint()),
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
"S3 connection endpoint",
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
+ " MinIO).");
} catch (IllegalStateException e) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
}
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
@@ -47,8 +46,6 @@ import stirling.software.proprietary.util.SecretMasker;
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceController {
private static final String WEBHOOK_TYPE = "webhook";
private final SourceStore sourceStore;
private final SourceAccessGuard sourceAccessGuard;
private final SourceOverviewService overviewService;
@@ -112,8 +109,7 @@ public class SourceController {
public ResponseEntity<Source> save(@RequestBody Source source) {
requireSourceEditingAllowed();
requireNotEditor(source.id(), source.type());
boolean isCreate = source.id() == null || source.id().isBlank();
Source owned = withPreparedOptions(withStoredSecrets(resolveOwnership(source)), isCreate);
Source owned = withStoredSecrets(resolveOwnership(source));
try {
validateConfig(owned);
} catch (IllegalArgumentException e) {
@@ -123,7 +119,7 @@ public class SourceController {
// An edited folder source can change which directory needs watching, so re-sync trigger
// registrations now instead of waiting for the next reconcile.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.ok(revealOnCreate(saved, isCreate));
return ResponseEntity.ok(withMaskedSecrets(saved));
}
@DeleteMapping("/{sourceId}")
@@ -225,36 +221,14 @@ public class SourceController {
/** Validate the config against the bean that handles the source's type, as the engine will. */
private void validateConfig(Source source) {
InputSpec spec = source.toInputSpec();
inputSourceFor(spec)
inputSources.stream()
.filter(inputSource -> inputSource.supports(spec))
.findFirst()
.orElseThrow(
() -> new IllegalArgumentException("unknown source type: " + source.type()))
.validate(spec);
}
/** Let the source type populate server-owned config before persistence. */
private Source withPreparedOptions(Source source, boolean isCreate) {
InputSpec spec = source.toInputSpec();
InputSource input = inputSourceFor(spec).orElse(null);
if (input == null) {
return source;
}
Map<String, Object> prepared = input.prepareOptionsForSave(source.options(), isCreate);
// Treat a null return as "unchanged" (else the record wipes config to empty).
return prepared == null ? source : withOptions(source, prepared);
}
/** Reveal a webhook's minted secret once on the create response; other reads mask. */
private static Source revealOnCreate(Source saved, boolean isCreate) {
if (isCreate && WEBHOOK_TYPE.equals(saved.type())) {
return saved;
}
return withMaskedSecrets(saved);
}
private Optional<InputSource> inputSourceFor(InputSpec spec) {
return inputSources.stream().filter(input -> input.supports(spec)).findFirst();
}
/**
* Editing sources requires the editor role for the caller's team (a team leader on SaaS), the
* same rule as policies. Single-user deployments (login disabled) trust the local operator.
@@ -102,8 +102,7 @@ public class SourceOverviewService {
List.of(),
docs.total(),
docs.last24h(),
docs.last30d(),
null);
docs.last30d());
}
/**
@@ -144,17 +143,7 @@ public class SourceOverviewService {
configRows(source),
docs.total(),
docs.last24h(),
docs.last30d(),
webhookPath(source));
}
/** The server-relative delivery path for a webhook source, else null. Never a secret. */
private static String webhookPath(Source source) {
if (!"webhook".equals(source.type())) {
return null;
}
Object webhookId = source.options().get("webhookId");
return webhookId == null ? null : "/api/v1/webhooks/" + webhookId;
docs.last30d());
}
/** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */
@@ -2,7 +2,11 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
/** One Sources-overview row; {@code webhookPath} is a webhook's delivery path, else null. */
/**
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
* policies reference it (and which) and how many documents it has fed into runs ({@code docsTotal}
* lifetime plus the trailing 24-hour and 30-day windows).
*/
public record SourceView(
String id,
String name,
@@ -13,8 +17,7 @@ public record SourceView(
List<DetailRow> config,
long docsTotal,
long docs24h,
long docs30d,
String webhookPath) {
long docs30d) {
/** A policy that references this source. */
public record PolicyRef(String id, String name) {}
@@ -1,137 +0,0 @@
package stirling.software.proprietary.policy.trigger;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.SweepKind;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.webhook.WebhookConfig;
/** Fires webhook policies on delivery plus a periodic reconcile safety net. */
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookTrigger implements PolicyTrigger {
static final String TYPE = "webhook";
// The webhook source type (equals the trigger type but a distinct concept).
private static final String WEBHOOK_SOURCE_TYPE = "webhook";
private final PolicyStore policyStore;
private final PolicyRunner policyRunner;
private final SourceStore sourceStore;
private final ApplicationProperties applicationProperties;
private volatile ScheduledExecutorService reconciler;
@Override
public String type() {
return TYPE;
}
@Override
public boolean requiresSource() {
return true;
}
@Override
public Set<String> supportedSourceTypes() {
return Set.of(WEBHOOK_SOURCE_TYPE);
}
@Override
public void validate(Policy policy) {
boolean hasWebhookSource =
policy.sourceIds().stream()
.map(sourceStore::get)
.flatMap(java.util.Optional::stream)
.anyMatch(source -> WEBHOOK_SOURCE_TYPE.equals(source.type()));
if (!hasWebhookSource) {
throw new IllegalArgumentException(
"webhook trigger requires at least one webhook input source");
}
}
@Override
public synchronized void start() {
if (reconciler != null) {
return;
}
long reconcileSeconds = applicationProperties.getPolicies().getWatchReconcileSeconds();
reconciler =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("policy-webhook-reconcile-", 0).factory());
// First reconcile runs immediately so deliveries spooled before startup are picked up.
reconciler.scheduleAtFixedRate(this::safeReconcile, 0, reconcileSeconds, TimeUnit.SECONDS);
log.info("Webhook trigger started (reconcile every {}s)", reconcileSeconds);
}
@Override
public synchronized void stop() {
if (reconciler != null) {
reconciler.shutdownNow();
reconciler = null;
}
}
/** Run every webhook policy referencing the delivered-to source (LIGHT, best-effort). */
public void fireForWebhook(String webhookId) {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
if (!referencesWebhook(policy, webhookId)) {
continue;
}
try {
log.debug("Webhook policy {} ({}) saw a delivery", policy.id(), policy.name());
policyRunner.run(policy, SweepKind.LIGHT);
} catch (RuntimeException e) {
log.warn("Webhook run failed for policy {}: {}", policy.id(), e.getMessage());
}
}
}
private void safeReconcile() {
try {
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
try {
policyRunner.run(policy);
} catch (RuntimeException e) {
log.warn(
"Webhook reconcile run failed for policy {}: {}",
policy.id(),
e.getMessage());
}
}
} catch (RuntimeException e) {
log.error("Webhook reconcile failed: {}", e.getMessage(), e);
}
}
/** Whether the policy references a webhook source whose routing id is {@code webhookId}. */
private boolean referencesWebhook(Policy policy, String webhookId) {
for (String sourceId : policy.sourceIds()) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) {
continue;
}
Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION);
if (configured != null && configured.toString().equals(webhookId)) {
return true;
}
}
return false;
}
}
@@ -1,89 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.util.Map;
/** Webhook source config: server-minted ids, staging via an S3 connection or the local spool. */
public record WebhookConfig(
String webhookId, String signingSecret, Long connectionId, boolean snapshot) {
public static final String WEBHOOK_ID_OPTION = "webhookId";
public static final String SIGNING_SECRET_OPTION = "signingSecret";
public static final String CONNECTION_ID_OPTION = "connectionId";
private static final String MODE_OPTION = "mode";
private static final String MODE_CONSUME = "consume";
private static final String MODE_SNAPSHOT = "snapshot";
/** Reserved key namespace webhook deliveries are staged under in a connection's bucket. */
private static final String STAGING_ROOT = "stirling-webhook";
public static WebhookConfig from(Map<String, Object> options) {
String webhookId = trimmed(options.get(WEBHOOK_ID_OPTION));
if (webhookId == null) {
throw new IllegalArgumentException("webhook config requires a 'webhookId' option");
}
if (!WebhookIds.isValidId(webhookId)) {
throw new IllegalArgumentException("webhook config 'webhookId' has an invalid format");
}
String signingSecret = trimmed(options.get(SIGNING_SECRET_OPTION));
if (signingSecret == null) {
throw new IllegalArgumentException("webhook config requires a 'signingSecret' option");
}
String mode = trimmed(options.get(MODE_OPTION));
if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) {
throw new IllegalArgumentException(
"webhook config 'mode' must be 'consume' or 'snapshot'");
}
return new WebhookConfig(
webhookId, signingSecret, connectionId(options), MODE_SNAPSHOT.equals(mode));
}
/** Whether deliveries are staged to a durable S3 connection rather than the local spool. */
public boolean usesConnection() {
return connectionId != null;
}
/** Reserved per-webhook staging prefix inside the connection's bucket. */
public String stagingPrefix() {
return STAGING_ROOT + "/" + webhookId;
}
public String mode() {
return snapshot ? MODE_SNAPSHOT : MODE_CONSUME;
}
private static Long connectionId(Map<String, Object> options) {
Object reference = options.get(CONNECTION_ID_OPTION);
if (reference == null || (reference instanceof String s && s.isBlank())) {
return null;
}
if (reference instanceof Number number) {
return number.longValue();
}
try {
return Long.valueOf(reference.toString().trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"webhook 'connectionId' is not a valid connection reference: " + reference);
}
}
private static String trimmed(Object value) {
if (value == null) {
return null;
}
String text = value.toString().trim();
return text.isEmpty() ? null : text;
}
/** Never prints the signing secret, so an accidental log line cannot leak it. */
@Override
public String toString() {
return "WebhookConfig[webhookId="
+ webhookId
+ ", connectionId="
+ connectionId
+ ", snapshot="
+ snapshot
+ "]";
}
}
@@ -1,38 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.regex.Pattern;
/** Generates and validates a webhook's routing id and signing secret. */
public final class WebhookIds {
/** URL-safe base64 without padding: the characters a single path segment allows. */
private static final Pattern VALID_ID = Pattern.compile("^[A-Za-z0-9_-]{16,128}$");
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private WebhookIds() {}
/** A fresh routing token (~24 chars), unguessable so it cannot be enumerated. */
public static String newWebhookId() {
return randomToken(18);
}
/** A fresh HMAC signing key (~43 chars) revealed to the operator once at creation. */
public static String newSigningSecret() {
return randomToken(32);
}
/** Whether {@code id} is well-formed and safe as a path segment / directory name. */
public static boolean isValidId(String id) {
return id != null && VALID_ID.matcher(id).matches();
}
private static String randomToken(int bytes) {
byte[] buffer = new byte[bytes];
RANDOM.nextBytes(buffer);
return ENCODER.encodeToString(buffer);
}
}
@@ -1,201 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.s3.S3Config;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
/** Public receiver: HMAC-verifies a signed delivery, stages it, and fires the policies. */
@Slf4j
@RestController
@RequestMapping("/api/v1/webhooks")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Webhooks", description = "Inbound webhook source receiver")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookReceiverController {
static final String SIGNATURE_HEADER = "X-Stirling-Signature";
static final String FILENAME_HEADER = "X-Stirling-Filename";
private static final String WEBHOOK_TYPE = "webhook";
private final SourceStore sourceStore;
private final WebhookSpool spool;
private final WebhookTrigger webhookTrigger;
private final ApplicationProperties applicationProperties;
private final S3ConnectionResolver connectionResolver;
private final S3ConnectionPool connectionPool;
@PostMapping("/{webhookId}")
@Operation(
summary = "Deliver a document to a webhook source",
description =
"The body is the raw document; sign it with the source's secret and present"
+ " 'sha256=<hex>' in the X-Stirling-Signature header. Returns 202 once"
+ " the document is spooled for the referencing policies.")
public ResponseEntity<WebhookDeliveryResponse> receive(
@PathVariable String webhookId,
@RequestHeader(value = SIGNATURE_HEADER, required = false) String signature,
@RequestHeader(value = FILENAME_HEADER, required = false) String filename,
HttpServletRequest request) {
if (!WebhookIds.isValidId(webhookId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook");
}
Source source = findWebhookSource(webhookId);
if (source == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook");
}
WebhookConfig config = WebhookConfig.from(source.options());
byte[] body = readBoundedBody(request);
if (!WebhookSignatures.verify(config.signingSecret(), body, signature)) {
// Same 401 whether the header was absent or wrong: never confirm a guess.
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature");
}
if (!source.enabled()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Webhook source is paused; deliveries are not accepted");
}
if (body.length == 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Empty request body");
}
String storedName =
config.usesConnection()
? stageToConnection(config, filename, body)
: stageToSpool(webhookId, filename, body);
// Fire the referencing policies now; the trigger's reconcile is the safety net.
webhookTrigger.fireForWebhook(webhookId);
log.info(
"Accepted webhook delivery '{}' ({} bytes) for {}",
storedName,
body.length,
webhookId);
return ResponseEntity.accepted()
.contentType(MediaType.APPLICATION_JSON)
.body(new WebhookDeliveryResponse(true, storedName, body.length));
}
/** The enabled-or-not webhook source whose routing id matches, or null if there is none. */
private Source findWebhookSource(String webhookId) {
for (Source source : sourceStore.all()) {
if (!WEBHOOK_TYPE.equals(source.type())) {
continue;
}
Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION);
if (configured != null && configured.toString().equals(webhookId)) {
return source;
}
}
return null;
}
/** Stage a delivery to the node-local spool; returns its display (original) name. */
private String stageToSpool(String webhookId, String filename, byte[] body) {
try {
return WebhookSpool.displayName(
spool.store(webhookId, filename, body).getFileName().toString());
} catch (IOException e) {
log.error("Could not spool webhook delivery for {}: {}", webhookId, e.getMessage());
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery");
}
}
/** Stage a delivery to the S3 connection (save-time access check trusted). */
private String stageToConnection(WebhookConfig config, String filename, byte[] body) {
S3Config s3 =
connectionResolver.resolve(
Map.of(
WebhookConfig.CONNECTION_ID_OPTION,
config.connectionId(),
"prefix",
config.stagingPrefix()));
S3Client client = connectionPool.clientFor(s3);
String key = keyPrefix(s3.prefix()) + WebhookSpool.objectKeySuffix(filename);
try {
client.putObject(
PutObjectRequest.builder().bucket(s3.bucket()).key(key).build(),
RequestBody.fromBytes(body));
} catch (SdkException e) {
log.error(
"Could not stage webhook delivery for {} to s3://{}/{}: {}",
config.webhookId(),
s3.bucket(),
key,
e.getMessage());
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery");
}
return WebhookSpool.objectDisplayName(filename);
}
/** The configured prefix as a key-path prefix ("inbox" and "inbox/" mean the same). */
private static String keyPrefix(String prefix) {
if (prefix == null || prefix.isEmpty() || prefix.endsWith("/")) {
return prefix == null ? "" : prefix;
}
return prefix + "/";
}
/** Read the body into memory, capped at {@code policies.webhookMaxBytes}. */
private byte[] readBoundedBody(HttpServletRequest request) {
long maxBytes = applicationProperties.getPolicies().getWebhookMaxBytes();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[8192];
long total = 0;
try (InputStream in = request.getInputStream()) {
int read;
while ((read = in.read(chunk)) != -1) {
total += read;
if (total > maxBytes) {
throw new ResponseStatusException(
HttpStatus.PAYLOAD_TOO_LARGE,
"Delivery exceeds the " + maxBytes + "-byte limit");
}
buffer.write(chunk, 0, read);
}
} catch (IOException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Could not read request body");
}
return buffer.toByteArray();
}
/** The 202 body: the stored (display) name and byte count of an accepted delivery. */
public record WebhookDeliveryResponse(boolean accepted, String filename, int bytes) {}
}
@@ -1,53 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/** HMAC-SHA256 signing of a webhook delivery's raw body (header sha256=<hex>). */
public final class WebhookSignatures {
private static final String ALGORITHM = "HmacSHA256";
private static final String PREFIX = "sha256=";
private WebhookSignatures() {}
/** The header a sender presents for {@code body}: {@code sha256=<lowercase hex>}. */
public static String sign(String signingSecret, byte[] body) {
return PREFIX + HexFormat.of().formatHex(hmac(signingSecret, body));
}
/** Whether the presented signature is valid for {@code body}; false on bad input. */
public static boolean verify(String signingSecret, byte[] body, String presented) {
if (signingSecret == null || presented == null || body == null) {
return false;
}
String hex = presented.trim();
if (hex.regionMatches(true, 0, PREFIX, 0, PREFIX.length())) {
hex = hex.substring(PREFIX.length());
}
byte[] presentedBytes;
try {
presentedBytes = HexFormat.of().parseHex(hex);
} catch (IllegalArgumentException notHex) {
return false;
}
return MessageDigest.isEqual(hmac(signingSecret, body), presentedBytes);
}
private static byte[] hmac(String signingSecret, byte[] body) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), ALGORITHM));
return mac.doFinal(body);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
// HmacSHA256 is a required JCE algorithm and the key is always non-empty here.
throw new IllegalStateException("HMAC-SHA256 unavailable", e);
}
}
}
@@ -1,106 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import stirling.software.common.configuration.InstallationPathConfig;
/** Server-owned per-webhook staging directory under the install path. */
@Component
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class WebhookSpool {
private static final String SPOOL_DIR = "policy-webhook-spool";
private static final String TEMP_SUFFIX = ".part";
private static final String DEFAULT_NAME = "document.pdf";
private static final int UNIQUE_LEN = 32; // UUID hex without dashes
private final Path spoolRoot;
public WebhookSpool() {
this(Path.of(InstallationPathConfig.getPath(), SPOOL_DIR));
}
// Lets a caller (and tests) root the spool at a chosen directory; Spring uses the no-arg one.
public WebhookSpool(Path spoolRoot) {
this.spoolRoot = spoolRoot.toAbsolutePath().normalize();
}
/** The staging directory for one webhook. Never escapes the spool root; may not yet exist. */
public Path dirFor(String webhookId) {
if (!WebhookIds.isValidId(webhookId)) {
throw new IllegalArgumentException("invalid webhookId");
}
Path dir = spoolRoot.resolve(webhookId).normalize();
if (!dir.getParent().equals(spoolRoot)) {
// A validated id is a single safe segment; this only trips on a bug, never user input.
throw new IllegalArgumentException("invalid webhookId");
}
return dir;
}
/** Write a delivery into the spool atomically (staged .part, then moved into place). */
public Path store(String webhookId, String filename, byte[] content) throws IOException {
Path dir = dirFor(webhookId);
Files.createDirectories(dir);
String finalName = spoolName(filename);
Path target = dir.resolve(finalName);
Path temp = dir.resolve("." + finalName + TEMP_SUFFIX);
Files.write(temp, content);
try {
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException atomicUnsupported) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
return target;
}
/** The spool file name for a delivery: a unique prefix plus the sanitised original name. */
static String spoolName(String filename) {
return UUID.randomUUID().toString().replace("-", "") + "-" + sanitize(filename);
}
/** The original name recovered from a spool file name, for the resolved input's filename. */
public static String displayName(String spoolFileName) {
int dash = spoolFileName.indexOf('-');
// The unique prefix is fixed-length hex with no dashes, so the first dash is the separator.
if (dash == UNIQUE_LEN && dash + 1 < spoolFileName.length()) {
return spoolFileName.substring(dash + 1);
}
return spoolFileName;
}
/** S3 object-key suffix {@code <unique>/<name>}; the subfolder keeps the basename clean. */
public static String objectKeySuffix(String filename) {
return UUID.randomUUID().toString().replace("-", "") + "/" + sanitize(filename);
}
/** The staged delivery's display name (basename of {@link #objectKeySuffix}). */
public static String objectDisplayName(String filename) {
return sanitize(filename);
}
/** Reduce a client-supplied filename to a safe bare basename; fall back to a default. */
private static String sanitize(String filename) {
if (filename == null) {
return DEFAULT_NAME;
}
String base = filename.replace('\\', '/');
int slash = base.lastIndexOf('/');
if (slash >= 0) {
base = base.substring(slash + 1);
}
base = base.replaceAll("[^A-Za-z0-9._-]", "_").trim();
// Strip leading dots so the result is never hidden (which resolve would skip) or empty.
while (base.startsWith(".")) {
base = base.substring(1);
}
return base.isEmpty() ? DEFAULT_NAME : base;
}
}
@@ -184,20 +184,6 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
Page<PersistentAuditEvent> findByPrincipalIn(
@Param("principals") List<String> principals, Pageable pageable);
// Noise-excluding scans for the portal: keep the page window full of meaningful events so the
// list doesn't shrink as read/polling noise (UI_DATA/HTTP_REQUEST) grows in the recent window.
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type NOT IN :excludedTypes")
Page<PersistentAuditEvent> findByTypeNotIn(
@Param("excludedTypes") List<String> excludedTypes, Pageable pageable);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type NOT IN :excludedTypes AND e.principal"
+ " IN :principals")
Page<PersistentAuditEvent> findByTypeNotInAndPrincipalIn(
@Param("excludedTypes") List<String> excludedTypes,
@Param("principals") List<String> principals,
Pageable pageable);
@Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByTypeInAndTimestampBetween(
@@ -37,11 +37,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.accountlink.BillableOperationClassifier;
import stirling.software.proprietary.audit.AuditContext;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@@ -471,73 +469,6 @@ public class AuditService {
}
}
/**
* Merge controller-supplied context into the audit data, read in the aspect's {@code finally}
* after the controller body has run. A policy run stamps its name and step endpoints as request
* attributes ({@link AuditContext}); the internal loopback dispatch that executes each pipeline
* step carries the automation marker header ({@link InternalApiClient#AUTOMATION_HEADER}).
* Surfacing these lets the portal label a run as its policy instead of the raw {@code /run}
* endpoint, and flag its sub-steps as automation rather than direct user actions.
*
* @param data The existing audit data map
* @param req The current request, or null when not in a web context
*/
public void addAutomationContext(Map<String, Object> data, HttpServletRequest req) {
if (req == null) {
return;
}
Object policyName = req.getAttribute(AuditContext.REQ_ATTR_POLICY_NAME);
if (policyName == null) {
// A pipeline step's loopback dispatch carries its parent policy name as a header, so
// the
// step audit ties back to the policy that ran it (not a bare direct call).
String header = req.getHeader(InternalApiClient.POLICY_NAME_HEADER);
if (header != null && !header.isBlank()) {
policyName = header;
}
}
if (policyName != null) {
// Both sources are caller-controlled (the header is spoofable and, unlike the sender in
// InternalApiClient, uncapped); strip newlines and cap before persisting to audit JSON.
String safe = capLabel(String.valueOf(policyName));
if (!safe.isEmpty()) {
data.put("policyName", safe);
}
}
Object steps = req.getAttribute(AuditContext.REQ_ATTR_POLICY_STEPS);
if (steps instanceof List<?> list && !list.isEmpty()) {
// Caller-supplied and unbounded; cap count and each entry so a crafted run can't
// bloat the audit JSON the portal cache loads and parses in bulk.
List<String> safeSteps =
list.stream()
.limit(MAX_POLICY_STEPS)
.map(s -> capLabel(String.valueOf(s)))
.filter(s -> !s.isEmpty())
.toList();
if (!safeSteps.isEmpty()) {
data.put("policySteps", safeSteps);
}
}
if ("true".equalsIgnoreCase(req.getHeader(InternalApiClient.AUTOMATION_HEADER))) {
data.put("automation", Boolean.TRUE);
}
}
/** Max characters kept for a persisted policy label; mirrors the InternalApiClient send cap. */
private static final int MAX_LABEL_LEN = 200;
/** Max step endpoints kept on a run's audit event; the portal only shows the first few. */
private static final int MAX_POLICY_STEPS = 50;
/** Single-line, length-capped label safe to persist to audit JSON and render in the portal. */
private static String capLabel(String value) {
if (value == null) {
return "";
}
String safe = value.replaceAll("[\\r\\n]", " ").trim();
return safe.length() > MAX_LABEL_LEN ? safe.substring(0, MAX_LABEL_LEN) : safe;
}
/**
* Add method arguments to the audit data
*
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.PortalAuditEventRow;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
@@ -25,21 +24,12 @@ public class PortalAuditReadService {
/** Newest rows to scan; each surface filters this down to what it shows. */
private static final int SCAN_LIMIT = 400;
/**
* Read/polling noise excluded at the query level so the scan window stays full of meaningful
* events. Otherwise a busy scope's recent rows fill with these and the visible list shrinks as
* traffic grows - the "audit getting smaller over time" a user would see. No portal surface
* shows these types anyway (the infra tab and documents feed both drop them).
*/
private static final List<String> NOISE_TYPES =
List.of(AuditEventType.UI_DATA.name(), AuditEventType.HTTP_REQUEST.name());
private final PersistentAuditEventRepository auditRepository;
/** Recent whole-server events (admins). */
@Cacheable(value = CACHE_NAME, key = "'server'")
public List<PortalAuditEventRow> serverEvents() {
return toRows(auditRepository.findByTypeNotIn(NOISE_TYPES, recentPage()).getContent());
return toRows(auditRepository.findAll(recentPage()).getContent());
}
/** Recent events by the given principals (team scope). Empty principals yield an empty list. */
@@ -48,10 +38,7 @@ public class PortalAuditReadService {
if (principals.isEmpty()) {
return List.of();
}
return toRows(
auditRepository
.findByTypeNotInAndPrincipalIn(NOISE_TYPES, principals, recentPage())
.getContent());
return toRows(auditRepository.findByPrincipalIn(principals, recentPage()).getContent());
}
private static PageRequest recentPage() {
@@ -61,17 +61,8 @@ public class PortalDocumentsService {
continue;
}
String path = asString(data.get("path"));
// Pipeline steps run over an internal loopback (API-key auth), so origin alone reads as
// "API". The automation marker distinguishes a policy-run step from real API traffic.
boolean automation = isAutomation(data);
String policyName = asString(data.get("policyName"));
String source =
automation
? (policyName != null && !policyName.isBlank()
? "Policy: " + policyName
: "Policy automation")
: sourceLabel(asString(data.get("__origin")));
String product = automation ? "Automation" : productLabel(source);
String source = sourceLabel(asString(data.get("__origin")));
String product = "API integration".equals(source) ? "API" : "Editor";
String action = prettyTool(path);
boolean failed = isFailure(data);
Instant ts = event.timestamp();
@@ -183,15 +174,6 @@ public class PortalDocumentsService {
return "Web upload";
}
private static String productLabel(String source) {
return "API integration".equals(source) ? "API" : "Editor";
}
private static boolean isAutomation(Map<String, Object> data) {
Object v = data.get("automation");
return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v));
}
private static String docType(String contentType, String name) {
String ct = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT);
if (ct.contains("pdf") || name.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
@@ -54,7 +54,6 @@ public class PortalInfraAuditService {
.limit(RETURN_LIMIT)
.toList();
int policy = (int) events.stream().filter(e -> "policy".equals(e.getCategory())).count();
int processing =
(int) events.stream().filter(e -> "processing".equals(e.getCategory())).count();
int elevation =
@@ -64,7 +63,6 @@ public class PortalInfraAuditService {
InfraAuditSummary summary =
InfraAuditSummary.builder()
.totalEvents(events.size())
.policy(policy)
.processing(processing)
.elevation(elevation)
.config(config)
@@ -86,32 +84,20 @@ public class PortalInfraAuditService {
private InfraAuditEventDto toDto(PortalAuditEventRow event) {
Map<String, Object> data = parseData(event);
String path = asString(data.get("path"));
String policyName = asString(data.get("policyName"));
boolean automation = isAutomation(data);
// Classify a dispatch by its real run-path URI, not a policyName: the latter can be spoofed
// via the X-Stirling-Policy-Name header to make a direct call pose as a policy row.
boolean policyDispatch = isPolicyRunPath(path) && !automation;
// A dispatch is its own "policy" category so the UI badges it as a policy run, not a
// generic processing op; its internal steps keep their real tool category.
String category = policyDispatch ? "policy" : categoryFor(event.type(), path);
String category = categoryFor(event.type(), path);
return InfraAuditEventDto.builder()
.id(String.valueOf(event.id()))
.timestamp(event.timestamp() == null ? "" : TS_FORMAT.format(event.timestamp()))
.category(category)
.action(actionFor(event.type(), path, policyName, automation))
.action(actionFor(event.type(), path))
.actor(event.principal())
.target(targetFor(category, path, data, policyDispatch))
.target(targetFor(category, path, data))
.status(statusFor(event.type(), category, data))
.latencyMs(asLong(data.get("latencyMs")))
.build();
}
private static boolean isAutomation(Map<String, Object> data) {
Object v = data.get("automation");
return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v));
}
private Map<String, Object> parseData(PortalAuditEventRow event) {
if (event.data() == null || event.data().isEmpty()) {
return Map.of();
@@ -154,37 +140,7 @@ public class PortalInfraAuditService {
|| p.contains("redact");
}
/**
* Label for a row. A genuine policy dispatch (a {@code /policies/.../run} request) shows the
* policy it ran; an internal pipeline step (automation marker) is flagged so it isn't read as a
* direct action. The name is only shown as the action on a real run URI, so a spoofed
* X-Stirling-Policy-Name header on a direct tool call can't overwrite its true action.
*/
private static String actionFor(
String type, String path, String policyName, boolean automation) {
if (!automation && isPolicyRunPath(path)) {
// A run with no name (ad-hoc pipeline) still reads better than the "run" endpoint.
return policyName != null ? policyName : "Policy run";
}
String base = baseActionFor(type, path);
if (automation) {
return policyName != null
? base + " (policy: " + policyName + ")"
: base + " (automation)";
}
return base;
}
/**
* The pipeline-run endpoints: {@code /policies/run}, {@code /run/stream}, {@code /{id}/run}.
*/
private static boolean isPolicyRunPath(String path) {
return path != null
&& path.contains("/policies/")
&& (path.endsWith("/run") || path.endsWith("/run/stream"));
}
private static String baseActionFor(String type, String path) {
private static String actionFor(String type, String path) {
AuditEventType t = AuditEventType.fromString(type);
if (t == null) {
return prettyTool(path);
@@ -235,32 +191,7 @@ public class PortalInfraAuditService {
return sb.isEmpty() ? "PDF operation" : sb.toString();
}
/** "Auto Redact, Compress PDF" from the run's step endpoints; first three, then "+N more". */
private static String prettyStepList(Object steps) {
if (!(steps instanceof List<?> list) || list.isEmpty()) {
return null;
}
int shown = Math.min(3, list.size());
StringBuilder sb = new StringBuilder();
for (int i = 0; i < shown; i++) {
if (i > 0) {
sb.append(", ");
}
sb.append(prettyTool(asString(list.get(i))));
}
if (list.size() > shown) {
sb.append(" +").append(list.size() - shown).append(" more");
}
return sb.toString();
}
private static String targetFor(
String category, String path, Map<String, Object> data, boolean policyDispatch) {
if (policyDispatch) {
// The run touches no single file at this level; show the tools the policy runs instead.
String steps = prettyStepList(data.get("policySteps"));
return steps != null ? steps : "Pipeline";
}
private static String targetFor(String category, String path, Map<String, Object> data) {
if ("auth".equals(category)) {
// Auth events don't act on a resource; the session is the closest thing.
return "Web session";
@@ -20,8 +20,9 @@ public final class SecretMasker {
private static final Pattern SENSITIVE =
RegexPatternUtils.getInstance()
.getPattern(
// specific keys precede plain "secret" so camelCase still matches.
"(?i)\\b(password|token|secret[_-]?access[_-]?key|signing[_-]?secret|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
// secret[_-]?access[_-]?key precedes plain secret so camelCase keys
// like secretAccessKey (no word boundary after "secret") still match.
"(?i)\\b(password|token|secret[_-]?access[_-]?key|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
private SecretMasker() {}
@@ -2,7 +2,6 @@ package stirling.software.proprietary.access.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
@@ -259,24 +258,6 @@ class ResourceAccessServiceTest {
.isFalse();
}
@Test
void teamLeadDefaultOnTeamResourceDeniesForeignTeamsLead() {
// Team-owned resource: the default admits only the owning team's leads. A lead of
// some other team must be denied even though an unscoped "is any team leader"
// check would admit them (lenient stub: the scoped path must never consult it).
stubGrants();
User foreignLead = user(6);
lenient().when(teamLeadLookup.isAnyTeamLeader(foreignLead)).thenReturn(true);
assertThat(
service.canUseResource(
TYPE,
RID,
PrincipalRef.team(7L),
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS,
foreignLead))
.isFalse();
}
// ---- portal convenience (default policy ADMINS_AND_TEAM_LEADS) ----
@Test
@@ -13,9 +13,9 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
@@ -52,58 +52,7 @@ class IntegrationConfigServiceTest {
@Mock
private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository;
@Mock private IntegrationConfigValidator validator;
@Mock private IntegrationConfigUsageCheck usageCheck;
private IntegrationConfigService service;
@BeforeEach
void setUp() {
service =
new IntegrationConfigService(
repository,
ownership,
secretMasker,
grantRepository,
List.of(validator),
List.of(usageCheck));
}
@Test
void createRejectsAConfigItsTypeValidatorRefuses() {
when(secretMasker.sanitize(any())).thenReturn(Map.of());
when(validator.type()).thenReturn(IntegrationType.API);
org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'"))
.when(validator)
.validate(any());
assertThatThrownBy(
() ->
service.create(
request(IntegrationType.API, OwnerScope.USER, null),
user(7)))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
}
@Test
void deleteRefusedWhileAnythingStillReferencesTheConfig() {
IntegrationConfig cfg = config(9L);
when(repository.findById(9L)).thenReturn(Optional.of(cfg));
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true);
when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'"));
assertThatThrownBy(() -> service.delete(9L, user(7)))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.CONFLICT));
verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class));
}
@InjectMocks private IntegrationConfigService service;
@Test
void createDelegatesOwnershipAndSanitizesConfig() {
@@ -4,7 +4,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -195,29 +194,6 @@ class PolicyControllerTest {
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
}
@Test
@DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread")
void rejectsUnauthorizedAdHocOutput() {
// The confused-deputy guard: an S3 output referencing a connection the caller may not
// use is validated here (principal present) and refused before any worker dispatch.
PipelineDefinition definition =
new PipelineDefinition(
"pipe",
List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)),
new OutputSpec("s3", Map.of("connectionId", 999)));
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
.when(policyValidator)
.validateOutput(any());
assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles()))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
verify(policyRunner, never()).runAdHoc(any(), any(), any());
}
}
@Nested
@@ -82,28 +82,6 @@ class PolicyValidatorTest {
assertTrue(ex.getMessage().contains("schedule"));
}
@Test
void validateOutputDelegatesToTheSink() {
when(outputSink.supports(any())).thenReturn(true);
OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1));
validator.validateOutput(output);
verify(outputSink).validate(output);
}
@Test
void validateOutputSurfacesAnInaccessibleConnection() {
when(outputSink.supports(any())).thenReturn(true);
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
.when(outputSink)
.validate(any());
assertThrows(
IllegalArgumentException.class,
() -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1))));
}
@Test
void rejectsAnUnknownTriggerType() {
when(trigger.type()).thenReturn("schedule");
@@ -23,7 +23,6 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -83,9 +82,7 @@ class S3InputSourceMinioTest {
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(true);
source =
new S3InputSource(
new S3ConnectionPool(properties), S3TestConnections.legacyResolver());
source = new S3InputSource(new S3ConnectionPool(properties));
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@@ -164,9 +161,7 @@ class S3InputSourceMinioTest {
@Test
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
S3InputSource guarded =
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties()),
S3TestConnections.legacyResolver());
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
.isInstanceOf(IllegalArgumentException.class)
@@ -29,7 +29,6 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.core.exception.SdkClientException;
@@ -65,8 +64,7 @@ class S3InputSourceTest {
void setUp() {
source =
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
S3TestConnections.legacyResolver());
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@@ -1,221 +0,0 @@
package stirling.software.proprietary.policy.input;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.webhook.WebhookConfig;
import stirling.software.proprietary.policy.webhook.WebhookSpool;
/** Tests for {@link WebhookInputSource}: ledger-backed read/consume, and id/secret minting. */
@ExtendWith(MockitoExtension.class)
class WebhookInputSourceTest {
private static final String POLICY = "p1";
private static final String WEBHOOK_ID = "testwebhookid1234";
@Mock private FileReadinessChecker readinessChecker;
@Mock private S3InputSource s3InputSource;
@TempDir Path tempDir;
private WebhookSpool spool;
private WebhookInputSource source;
private InProcessProcessedLedger ledger;
private RecordingContext ctx;
@BeforeEach
void setUp() {
spool = new WebhookSpool(tempDir.resolve("spool"));
source = new WebhookInputSource(spool, readinessChecker, s3InputSource);
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
lenient().when(readinessChecker.isReady(any())).thenReturn(true);
}
private static InputSpec spec(String mode) {
return new InputSpec(
"webhook",
Map.of("webhookId", WEBHOOK_ID, "signingSecret", "secret", "mode", mode));
}
@Test
void consumeRemovesTheDeliveryOnceProcessed() throws IOException {
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
assertEquals(1, work.size());
assertEquals("doc.pdf", work.get(0).inputs().primary().get(0).getFilename());
// In flight: still spooled, but a second sweep does not pick it up again.
assertTrue(Files.exists(delivered));
assertTrue(source.resolve(spec("consume"), ctx).isEmpty());
work.get(0).onComplete().accept(true);
assertTrue(Files.notExists(delivered));
assertTrue(source.resolve(spec("consume"), ctx).isEmpty());
}
@Test
void aFailedRunLeavesTheDeliveryInPlace() throws IOException {
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
work.get(0).onComplete().accept(false);
assertTrue(Files.exists(delivered));
}
@Test
void snapshotReReadsEveryRunAndNeverDeletes() throws IOException {
Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes());
assertEquals(1, source.resolve(spec("snapshot"), ctx).size());
List<ResolvedInput> second = source.resolve(spec("snapshot"), ctx);
assertEquals(1, second.size());
second.get(0).onComplete().accept(true);
assertTrue(Files.exists(delivered));
}
@Test
void nothingDeliveredIsAnEmptySourceNotAnError() throws IOException {
List<ResolvedInput> work = source.resolve(spec("consume"), ctx);
assertTrue(work.isEmpty());
assertTrue(ctx.present.isEmpty());
}
@Test
void validateRejectsMissingIdOrSecret() {
assertThrows(
IllegalArgumentException.class,
() -> source.validate(new InputSpec("webhook", Map.of("signingSecret", "s"))));
assertThrows(
IllegalArgumentException.class,
() -> source.validate(new InputSpec("webhook", Map.of("webhookId", WEBHOOK_ID))));
}
@Test
void prepareMintsIdAndSecretOnCreate() {
Map<String, Object> prepared =
source.prepareOptionsForSave(Map.of("mode", "consume"), true);
String id = prepared.get(WebhookConfig.WEBHOOK_ID_OPTION).toString();
String secret = prepared.get(WebhookConfig.SIGNING_SECRET_OPTION).toString();
assertFalse(id.isBlank());
assertFalse(secret.isBlank());
assertEquals("consume", prepared.get("mode"));
Map<String, Object> other = source.prepareOptionsForSave(Map.of(), true);
assertNotEquals(id, other.get(WebhookConfig.WEBHOOK_ID_OPTION).toString());
}
@Test
void prepareLeavesAnExistingWebhookUntouchedOnEdit() {
Map<String, Object> existing =
Map.of("webhookId", WEBHOOK_ID, "signingSecret", "keepme", "mode", "snapshot");
Map<String, Object> prepared = source.prepareOptionsForSave(existing, false);
assertEquals(WEBHOOK_ID, prepared.get("webhookId"));
assertEquals("keepme", prepared.get("signingSecret"));
}
@Test
void aConnectionBackedWebhookDelegatesToTheS3SourceUnderItsReservedPrefix() throws IOException {
when(s3InputSource.resolve(any(), any())).thenReturn(List.of());
InputSpec spec =
new InputSpec(
"webhook",
Map.of(
"webhookId",
WEBHOOK_ID,
"signingSecret",
"secret",
"mode",
"consume",
"connectionId",
7));
source.resolve(spec, ctx);
ArgumentCaptor<InputSpec> delegated = ArgumentCaptor.forClass(InputSpec.class);
verify(s3InputSource).resolve(delegated.capture(), eq(ctx));
InputSpec s3 = delegated.getValue();
assertEquals("s3", s3.type());
assertEquals(7L, ((Number) s3.options().get("connectionId")).longValue());
assertEquals("stirling-webhook/" + WEBHOOK_ID, s3.options().get("prefix"));
assertEquals("consume", s3.options().get("mode"));
}
@Test
void aConnectionBackedWebhookValidatesTheConnectionThroughTheS3Source() {
InputSpec spec =
new InputSpec(
"webhook",
Map.of(
"webhookId",
WEBHOOK_ID,
"signingSecret",
"secret",
"connectionId",
7));
source.validate(spec);
// Save-time validation defers to the S3 source, which ownership-checks the connection.
verify(s3InputSource).validate(any());
}
/** Policy-scoped context backed by the in-process ledger, recording presence reports. */
private class RecordingContext implements ResolveContext {
private final List<String> present = new ArrayList<>();
@Override
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
return ledger.claim(POLICY, identity, gate, contentHash);
}
@Override
public void settle(
String identity, String finalGate, String finalContentHash, boolean success) {
ledger.settle(POLICY, identity, finalGate, finalContentHash, success);
}
@Override
public boolean allSettledDone(String identity) {
return ledger.allSettledDone(identity);
}
@Override
public void reportPresent(Collection<String> identities) {
present.addAll(identities);
}
}
}
@@ -28,7 +28,6 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -91,8 +90,8 @@ class S3OutputSinkMinioTest {
properties.getPolicies().setAllowPrivateS3Endpoints(true);
S3ConnectionPool pool = new S3ConnectionPool(properties);
ledger = new InProcessProcessedLedger();
sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger);
source = new S3InputSource(pool, S3TestConnections.legacyResolver());
sink = new S3OutputSink(pool, ledger);
source = new S3InputSource(pool);
}
@Test
@@ -32,7 +32,6 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.ledger.ProcessedFileStatus;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.exception.SdkClientException;
@@ -67,7 +66,6 @@ class S3OutputSinkTest {
sink =
new S3OutputSink(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
S3TestConnections.legacyResolver(),
ledger);
}
@@ -1,212 +0,0 @@
package stirling.software.proprietary.policy.s3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.access.model.OwnerScope;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.model.Team;
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.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.security.repository.TeamRepository;
/**
* Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated
* team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops.
*/
@ExtendWith(MockitoExtension.class)
class EmbeddedS3CredentialMigrationTest {
@Mock private IntegrationConfigRepository connections;
@Mock private TeamRepository teamRepository;
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
private EmbeddedS3CredentialMigration migration;
@BeforeEach
void setUp() {
migration =
new EmbeddedS3CredentialMigration(
sourceStore, policyStore, connections, teamRepository);
AtomicLong ids = new AtomicLong(100);
// Lenient: the nothing-to-migrate cases never create a connection.
lenient().when(connections.findAll()).thenReturn(List.of());
lenient()
.when(connections.save(any()))
.thenAnswer(
invocation -> {
IntegrationConfig saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(ids.incrementAndGet());
}
return saved;
});
}
@Test
void extractsSharedCredentialsIntoOneTeamScopedConnection() {
Team team = new Team();
team.setId(7L);
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
Source source =
sourceStore.save(
new Source(
null,
"Claims intake",
"s3",
Map.of(
"bucket", "inbox",
"prefix", "incoming/",
"mode", "snapshot",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"),
true,
"alice",
7L));
Policy policy =
policyStore.save(
new Policy(
null,
"Rotate",
"alice",
true,
null,
List.of(source.id()),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
new OutputSpec(
"s3",
Map.of(
"bucket", "inbox",
"prefix", "processed/",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")),
7L));
migration.migrate();
// Same bucket + credentials on both rows: exactly one connection extracted.
verify(connections, times(1)).save(any());
Map<String, Object> sourceOptions = sourceStore.get(source.id()).orElseThrow().options();
assertEquals(101L, sourceOptions.get("connectionId"));
assertEquals("incoming/", sourceOptions.get("prefix"));
assertEquals("snapshot", sourceOptions.get("mode"));
assertNull(sourceOptions.get("accessKeyId"));
assertNull(sourceOptions.get("secretAccessKey"));
assertNull(sourceOptions.get("bucket"));
Map<String, Object> outputOptions =
policyStore.get(policy.id()).orElseThrow().output().options();
assertEquals(101L, outputOptions.get("connectionId"));
assertEquals("processed/", outputOptions.get("prefix"));
assertNull(outputOptions.get("secretAccessKey"));
}
@Test
void connectionOwnershipFollowsTheSourceTeam() {
Team team = new Team();
team.setId(7L);
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
sourceStore.save(s3Source("teamed", 7L));
migration.migrate();
verify(connections)
.save(
org.mockito.ArgumentMatchers.argThat(
connection ->
connection.getScope() == OwnerScope.TEAM
&& connection.getOwnerTeam() == team));
}
@Test
void teamlessRowsBecomeServerScopedConnections() {
sourceStore.save(s3Source("solo", null));
migration.migrate();
verify(connections)
.save(
org.mockito.ArgumentMatchers.argThat(
connection -> connection.getScope() == OwnerScope.SERVER));
}
@Test
void aSecondRunFindsNothingToDo() {
sourceStore.save(s3Source("once", null));
migration.migrate();
migration.migrate();
// One connection from the first run; the rewritten source no longer embeds credentials.
verify(connections, times(1)).save(any());
}
@Test
void nonS3AndAlreadyMigratedRowsAreUntouched() {
Source folder =
sourceStore.save(
new Source(
null,
"Folder",
"folder",
Map.of("directory", "/in"),
true,
"alice",
null));
Source migrated =
sourceStore.save(
new Source(
null,
"Done already",
"s3",
Map.of("connectionId", 55L, "prefix", "in/"),
true,
"alice",
null));
migration.migrate();
verify(connections, times(0)).save(any());
assertEquals(
Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options());
assertEquals(
Map.of("connectionId", 55L, "prefix", "in/"),
sourceStore.get(migrated.id()).orElseThrow().options());
}
private static Source s3Source(String name, Long teamId) {
return new Source(
null,
name,
"s3",
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"),
true,
"alice",
teamId);
}
}
@@ -1,52 +0,0 @@
package stirling.software.proprietary.policy.s3;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
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.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */
class PolicyS3ConnectionUsageCheckTest {
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
private final PolicyS3ConnectionUsageCheck check =
new PolicyS3ConnectionUsageCheck(sourceStore, policyStore);
@Test
void reportsSourcesAndOutputsReferencingTheConnection() {
sourceStore.save(
new Source(
null,
"Claims intake",
"s3",
Map.of("connectionId", 5L, "prefix", "in/"),
true,
"alice",
null));
policyStore.save(
new Policy(
null,
"Rotate",
"alice",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
new OutputSpec("s3", Map.of("connectionId", "5")),
null));
assertThat(check.usagesOf(5))
.containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'");
assertThat(check.usagesOf(6)).isEmpty();
}
}
@@ -1,149 +0,0 @@
package stirling.software.proprietary.policy.s3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the
* legacy embedded fallback, and the save-time access check that background sweeps skip.
*/
@ExtendWith(MockitoExtension.class)
class S3ConnectionResolverTest {
@Mock private IntegrationConfigRepository connections;
@Mock private OwnershipService ownership;
@Mock private UserService userService;
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void resolvesAConnectionAndMergesPerUseOptions() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
S3Config config =
resolver()
.resolve(
Map.of(
"connectionId", 9L,
"prefix", "incoming/",
"mode", "snapshot"));
assertEquals("inbox", config.bucket());
assertEquals("AKIAEXAMPLE", config.accessKeyId());
assertEquals("incoming/", config.prefix());
assertTrue(config.snapshot());
}
@Test
void acceptsAStringConnectionReference() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket());
}
@Test
void fallsBackToLegacyEmbeddedCredentials() {
S3Config config =
resolver()
.resolve(
Map.of(
"bucket", "legacy",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"));
assertEquals("legacy", config.bucket());
}
@Test
void rejectsUnknownDisabledOrWrongTypeConnections() {
when(connections.findById(1L)).thenReturn(Optional.empty());
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 1L)));
when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false)));
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 2L)));
IntegrationConfig mcp = s3Connection(3L, true);
mcp.setIntegrationType(IntegrationType.MCP);
when(connections.findById(3L)).thenReturn(Optional.of(mcp));
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 3L)));
}
@Test
void anAuthenticatedSaverMustBeAllowedToUseTheConnection() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
User saver = new User();
saver.setUsername("alice");
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of()));
when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false);
// Denied reads the same as unknown and never echoes the connection name, so ids can't be
// enumerated by probing.
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 9L)));
try {
resolver().resolve(Map.of("connectionId", 9L));
} catch (IllegalArgumentException e) {
org.junit.jupiter.api.Assertions.assertFalse(
e.getMessage().contains("Claims bucket"),
"access-denied error must not leak the connection name");
}
}
@Test
void backgroundSweepsWithNoUserSkipTheAccessCheck() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
// No authentication in the context: resolution succeeds without consulting ownership.
assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket());
}
private S3ConnectionResolver resolver() {
return new S3ConnectionResolver(connections, ownership, userService);
}
private static IntegrationConfig s3Connection(long id, boolean enabled) {
IntegrationConfig connection = new IntegrationConfig();
connection.setId(id);
connection.setIntegrationType(IntegrationType.S3);
connection.setName("Claims bucket");
connection.setEnabled(enabled);
connection.setConfig(
"{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\","
+ "\"secretAccessKey\":\"shh\"}");
return connection;
}
}
@@ -1,71 +0,0 @@
package stirling.software.proprietary.policy.s3;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Map;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.integration.model.IntegrationType;
/**
* Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing
* credentials, bad endpoints, and private endpoints without the operator opt-in.
*/
class S3IntegrationValidatorTest {
@Test
void acceptsACompleteConnection() {
assertThatCode(
() ->
validator(false)
.validate(
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")))
.doesNotThrowAnyException();
}
@Test
void rejectsMissingCredentialsOrBucket() {
assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox")))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
validator(false)
.validate(
Map.of(
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsAPrivateEndpointWithoutTheOperatorOptIn() {
Map<String, Object> config =
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh",
"endpoint", "http://localhost:9000");
assertThatThrownBy(() -> validator(false).validate(config))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("allowPrivateS3Endpoints");
assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException();
}
@Test
void itOnlyClaimsTheS3Type() {
org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type());
}
private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints);
return new S3IntegrationValidator(properties);
}
}
@@ -1,24 +0,0 @@
package stirling.software.proprietary.policy.s3;
import static org.mockito.Mockito.mock;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.service.UserService;
/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */
public final class S3TestConnections {
private S3TestConnections() {}
/**
* A resolver for tests whose options embed credentials directly (the legacy pass-through path),
* so its collaborators are never touched.
*/
public static S3ConnectionResolver legacyResolver() {
return new S3ConnectionResolver(
mock(IntegrationConfigRepository.class),
mock(OwnershipService.class),
mock(UserService.class));
}
}
@@ -1,42 +1,33 @@
package stirling.software.proprietary.policy.source;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.S3InputSource;
import stirling.software.proprietary.policy.input.WebhookInputSource;
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.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.policy.webhook.WebhookSpool;
import stirling.software.proprietary.util.SecretMasker;
/**
@@ -50,9 +41,6 @@ class SourceControllerTest {
private final PolicyStore policyStore = new InProcessPolicyStore();
private PolicyTriggerManager triggerManager;
private SourceController controller;
private SourceController webhookController;
@TempDir Path tempDir;
@BeforeEach
void setUp() {
@@ -70,11 +58,9 @@ class SourceControllerTest {
policyGuard,
new InProcessSourceDocCounter());
triggerManager = mock(PolicyTriggerManager.class);
// A permissive input source; stub prepareOptionsForSave as a pass-through.
// A permissive input source so config validation passes and save can be exercised.
InputSource folderInput = mock(InputSource.class);
when(folderInput.supports(any())).thenReturn(true);
when(folderInput.prepareOptionsForSave(any(), anyBoolean()))
.thenAnswer(invocation -> invocation.getArgument(0));
controller =
new SourceController(
sourceStore,
@@ -86,48 +72,6 @@ class SourceControllerTest {
triggerManager,
properties,
List.of(folderInput));
WebhookInputSource webhookInput =
new WebhookInputSource(
new WebhookSpool(tempDir),
mock(FileReadinessChecker.class),
mock(S3InputSource.class));
webhookController =
new SourceController(
sourceStore,
sourceGuard,
overviewService,
policyStore,
policyGuard,
authority,
triggerManager,
properties,
List.of(webhookInput));
}
@Test
void creatingAWebhookRevealsItsSecretOnceThenMasks() {
Source created =
webhookController
.save(
new Source(
null,
"Partner uploads",
"webhook",
Map.of("mode", "consume"),
true,
null,
null))
.getBody();
String secret = String.valueOf(created.options().get("signingSecret"));
String webhookId = String.valueOf(created.options().get("webhookId"));
assertNotEquals(SecretMasker.REDACTED, secret);
assertFalse(secret.isBlank());
assertFalse(webhookId.isBlank());
Source read = webhookController.get(created.id()).getBody();
assertEquals(SecretMasker.REDACTED, read.options().get("signingSecret"));
assertEquals(webhookId, read.options().get("webhookId"));
}
@Test
@@ -1,117 +0,0 @@
package stirling.software.proprietary.policy.trigger;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.engine.SweepKind;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/** Tests for {@link WebhookTrigger}: fires only policies referencing the delivered-to webhook. */
@ExtendWith(MockitoExtension.class)
class WebhookTriggerTest {
private static final String TYPE = "webhook";
@Mock private PolicyStore policyStore;
@Mock private PolicyRunner policyRunner;
private final SourceStore sourceStore = new InProcessSourceStore();
private WebhookTrigger trigger;
@BeforeEach
void setUp() {
trigger =
new WebhookTrigger(
policyStore, policyRunner, sourceStore, new ApplicationProperties());
}
@Test
void firesOnlyPoliciesReferencingTheDeliveredWebhook() {
Policy matching = webhookPolicy("a", "whkA");
Policy other = webhookPolicy("b", "whkB");
when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(matching, other));
trigger.fireForWebhook("whkA");
verify(policyRunner).run(matching, SweepKind.LIGHT);
verify(policyRunner, never()).run(other, SweepKind.LIGHT);
}
@Test
void ignoresADeliveryForAnUnknownWebhookId() {
Policy policy = webhookPolicy("a", "whkA");
when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(policy));
trigger.fireForWebhook("whkZ");
verify(policyRunner, never()).run(any(), any(SweepKind.class));
}
@Test
void validateRequiresAWebhookSource() {
assertThrows(
IllegalArgumentException.class,
() -> trigger.validate(policy("p", webhookTriggerConfig(), List.of())));
trigger.validate(webhookPolicy("p", "whkA"));
}
private static TriggerConfig webhookTriggerConfig() {
return new TriggerConfig(TYPE, Map.of());
}
/** Persist a webhook source with the given routing id and return a policy referencing it. */
private Policy webhookPolicy(String id, String webhookId) {
String sourceId =
sourceStore
.save(
new Source(
null,
"hook",
"webhook",
Map.of(
"webhookId",
webhookId,
"signingSecret",
"s",
"mode",
"consume"),
true,
"owner",
null))
.id();
return policy(id, webhookTriggerConfig(), List.of(sourceId));
}
private static Policy policy(String id, TriggerConfig trigger, List<String> sourceIds) {
return new Policy(
id,
"hook",
"owner",
true,
trigger,
sourceIds,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
}
@@ -1,186 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
import stirling.software.proprietary.policy.webhook.WebhookReceiverController.WebhookDeliveryResponse;
/** Tests for the public webhook receiver: valid delivery spools + fires; bad requests rejected. */
class WebhookReceiverControllerTest {
private static final String WEBHOOK_ID = "receivertestid12";
private static final String SECRET = "topsecret";
private static final byte[] BODY = "a pdf".getBytes(StandardCharsets.UTF_8);
@TempDir Path tempDir;
private SourceStore sourceStore;
private WebhookSpool spool;
private WebhookTrigger trigger;
private ApplicationProperties properties;
private WebhookReceiverController controller;
@BeforeEach
void setUp() {
sourceStore = new InProcessSourceStore();
sourceStore.save(webhookSource(true));
spool = new WebhookSpool(tempDir.resolve("spool"));
trigger = mock(WebhookTrigger.class);
properties = new ApplicationProperties();
// The local-disk tests never touch a connection; mocks stand in for the S3 collaborators.
controller =
new WebhookReceiverController(
sourceStore,
spool,
trigger,
properties,
mock(S3ConnectionResolver.class),
mock(S3ConnectionPool.class));
}
private static Source webhookSource(boolean enabled) {
return new Source(
"s1",
"Partner uploads",
"webhook",
Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume"),
enabled,
"owner",
null);
}
private static MockHttpServletRequest request(byte[] body) {
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID);
req.setContent(body);
return req;
}
@Test
void aValidDeliveryIsSpooledAndFiresTheTrigger() throws IOException {
String signature = WebhookSignatures.sign(SECRET, BODY);
ResponseEntity<WebhookDeliveryResponse> response =
controller.receive(WEBHOOK_ID, signature, "invoice.pdf", request(BODY));
assertEquals(202, response.getStatusCode().value());
assertTrue(response.getBody().accepted());
assertEquals("invoice.pdf", response.getBody().filename());
assertEquals(1, spooledFiles().size());
verify(trigger).fireForWebhook(WEBHOOK_ID);
}
@Test
void aWrongSignatureIsRejectedAndStoresNothing() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() ->
controller.receive(
WEBHOOK_ID, "sha256=deadbeef", "x.pdf", request(BODY)));
assertEquals(401, ex.getStatusCode().value());
assertTrue(spooledFiles().isEmpty());
verify(trigger, never()).fireForWebhook(WEBHOOK_ID);
}
@Test
void anUnknownWebhookIsNotFound() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() ->
controller.receive(
"unknownwebhookid",
WebhookSignatures.sign(SECRET, BODY),
"x.pdf",
request(BODY)));
assertEquals(404, ex.getStatusCode().value());
}
@Test
void aPausedSourceRejectsDeliveries() {
sourceStore.save(webhookSource(false));
String signature = WebhookSignatures.sign(SECRET, BODY);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.receive(WEBHOOK_ID, signature, "x.pdf", request(BODY)));
assertEquals(403, ex.getStatusCode().value());
assertTrue(spooledFiles().isEmpty());
}
@Test
void anEmptyBodyIsRejected() {
byte[] empty = new byte[0];
String signature = WebhookSignatures.sign(SECRET, empty);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> controller.receive(WEBHOOK_ID, signature, null, request(empty)));
assertEquals(400, ex.getStatusCode().value());
}
@Test
void anOversizeDeliveryIsRejectedBeforeStoring() {
properties.getPolicies().setWebhookMaxBytes(2);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() ->
controller.receive(
WEBHOOK_ID,
WebhookSignatures.sign(SECRET, BODY),
"x.pdf",
request(BODY)));
assertEquals(413, ex.getStatusCode().value());
assertTrue(spooledFiles().isEmpty());
}
private List<Path> spooledFiles() {
Path dir = spool.dirFor(WEBHOOK_ID);
if (!Files.isDirectory(dir)) {
return List.of();
}
try (Stream<Path> entries = Files.list(dir)) {
return entries.filter(Files::isRegularFile)
.filter(p -> !p.getFileName().toString().startsWith("."))
.toList();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -1,263 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockHttpServletRequest;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.policy.input.ResolveContext;
import stirling.software.proprietary.policy.input.ResolvedInput;
import stirling.software.proprietary.policy.input.S3InputSource;
import stirling.software.proprietary.policy.input.WebhookInputSource;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.trigger.WebhookTrigger;
import stirling.software.proprietary.security.service.UserService;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.S3Object;
import tools.jackson.databind.ObjectMapper;
/** End-to-end connection-backed webhook against MinIO: stage, read via S3 delegation, consume. */
@Testcontainers(disabledWithoutDocker = true)
class WebhookS3ConnectionMinioTest {
private static final String POLICY = "p1";
private static final String ACCESS_KEY = "minioadmin";
private static final String SECRET_KEY = "minioadmin";
private static final String WEBHOOK_ID = "miniowebhookid12";
private static final String SECRET = "topsecret";
private static final long CONNECTION_ID = 7L;
@Container
static MinIOContainer minio =
new MinIOContainer("minio/minio:latest")
.withUserName(ACCESS_KEY)
.withPassword(SECRET_KEY);
private static S3Client adminClient;
private static int bucketCounter;
@TempDir Path tempDir;
private String bucket;
private String stagingPrefix;
private WebhookReceiverController receiver;
private WebhookInputSource inputSource;
private InProcessProcessedLedger ledger;
private RecordingContext ctx;
@BeforeEach
void setUp() {
if (adminClient == null) {
adminClient =
S3Client.builder()
.endpointOverride(URI.create(minio.getS3URL()))
.httpClient(UrlConnectionHttpClient.create())
.region(Region.US_EAST_1)
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
.serviceConfiguration(
S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
bucket = "webhook-inbox-" + ++bucketCounter;
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
stagingPrefix = "stirling-webhook/" + WEBHOOK_ID + "/";
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(true);
// A resolver over a MinIO S3 connection; no principal here, so ownership is skipped.
S3ConnectionResolver resolver = resolverFor(minioConnection());
S3ConnectionPool pool = new S3ConnectionPool(properties);
S3InputSource s3 = new S3InputSource(pool, resolver);
inputSource =
new WebhookInputSource(
new WebhookSpool(tempDir.resolve("spool")),
mock(FileReadinessChecker.class),
s3);
InProcessSourceStore sourceStore = new InProcessSourceStore();
sourceStore.save(
new Source(
"s1",
"Partner uploads",
"webhook",
Map.of(
"webhookId", WEBHOOK_ID,
"signingSecret", SECRET,
"mode", "consume",
"connectionId", CONNECTION_ID),
true,
"owner",
null));
receiver =
new WebhookReceiverController(
sourceStore,
new WebhookSpool(tempDir.resolve("spool")),
mock(WebhookTrigger.class),
properties,
resolver,
pool);
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@Test
void aDeliveryIsStagedToTheConnectionThenReadAndConsumed() throws IOException {
byte[] body = "a pdf".getBytes(StandardCharsets.UTF_8);
String signature = WebhookSignatures.sign(SECRET, body);
var response = receiver.receive(WEBHOOK_ID, signature, "invoice.pdf", request(body));
assertThat(response.getStatusCode().value()).isEqualTo(202);
List<S3Object> staged = listUnder(stagingPrefix);
assertThat(staged).hasSize(1);
// Staged under a unique subfolder so the object basename stays the clean original name.
assertThat(staged.get(0).key()).endsWith("/invoice.pdf");
List<ResolvedInput> work = inputSource.resolve(webhookSpec(), ctx);
assertThat(work).hasSize(1);
assertThat(work.get(0).inputs().primary().get(0).getFilename()).isEqualTo("invoice.pdf");
assertThat(read(work.get(0))).isEqualTo("a pdf");
// In flight: a second sweep claims nothing.
assertThat(inputSource.resolve(webhookSpec(), ctx)).isEmpty();
work.get(0).onComplete().accept(true);
assertThat(listUnder(stagingPrefix)).isEmpty();
assertThat(inputSource.resolve(webhookSpec(), ctx)).isEmpty();
}
@Test
void aRejectedDeliveryStagesNothing() {
byte[] body = "a pdf".getBytes(StandardCharsets.UTF_8);
try {
receiver.receive(WEBHOOK_ID, "sha256=deadbeef", "x.pdf", request(body));
} catch (RuntimeException expected) {
// 401 invalid signature
}
assertThat(listUnder(stagingPrefix)).isEmpty();
}
private IntegrationConfig minioConnection() {
IntegrationConfig connection = new IntegrationConfig();
connection.setId(CONNECTION_ID);
connection.setIntegrationType(IntegrationType.S3);
connection.setName("minio");
connection.setEnabled(true);
connection.setConfig(
new ObjectMapper()
.writeValueAsString(
Map.of(
"bucket", bucket,
"region", "us-east-1",
"endpoint", minio.getS3URL(),
"accessKeyId", ACCESS_KEY,
"secretAccessKey", SECRET_KEY)));
return connection;
}
private static S3ConnectionResolver resolverFor(IntegrationConfig connection) {
IntegrationConfigRepository connections = mock(IntegrationConfigRepository.class);
when(connections.findById(connection.getId())).thenReturn(Optional.of(connection));
return new S3ConnectionResolver(
connections, mock(OwnershipService.class), mock(UserService.class));
}
private static InputSpec webhookSpec() {
return new InputSpec(
"webhook",
Map.of(
"webhookId", WEBHOOK_ID,
"signingSecret", SECRET,
"mode", "consume",
"connectionId", CONNECTION_ID));
}
private static MockHttpServletRequest request(byte[] body) {
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID);
req.setContent(body);
return req;
}
private List<S3Object> listUnder(String prefix) {
return adminClient
.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).prefix(prefix).build())
.contents();
}
private static String read(ResolvedInput unit) throws IOException {
try (InputStream stream = unit.inputs().primary().get(0).getInputStream()) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private class RecordingContext implements ResolveContext {
private final List<String> present = new ArrayList<>();
@Override
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
return ledger.claim(POLICY, identity, gate, contentHash);
}
@Override
public void settle(
String identity, String finalGate, String finalContentHash, boolean success) {
ledger.settle(POLICY, identity, finalGate, finalContentHash, success);
}
@Override
public boolean allSettledDone(String identity) {
return ledger.allSettledDone(identity);
}
@Override
public void reportPresent(Collection<String> identities) {
present.addAll(identities);
}
}
}
@@ -1,49 +0,0 @@
package stirling.software.proprietary.policy.webhook;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
/** The HMAC-SHA256 webhook signing scheme: correct signatures verify, tampered ones do not. */
class WebhookSignaturesTest {
private static final String SECRET = "whsec_test_secret";
private static final byte[] BODY = "the document bytes".getBytes(StandardCharsets.UTF_8);
@Test
void aFreshlySignedBodyVerifies() {
String header = WebhookSignatures.sign(SECRET, BODY);
assertTrue(header.startsWith("sha256="));
assertTrue(WebhookSignatures.verify(SECRET, BODY, header));
}
@Test
void abarehexSignatureVerifiesToo() {
String header = WebhookSignatures.sign(SECRET, BODY);
String bareHex = header.substring("sha256=".length());
assertTrue(WebhookSignatures.verify(SECRET, BODY, bareHex));
}
@Test
void aWrongSecretDoesNotVerify() {
String header = WebhookSignatures.sign(SECRET, BODY);
assertFalse(WebhookSignatures.verify("other-secret", BODY, header));
}
@Test
void atamperedBodyDoesNotVerify() {
String header = WebhookSignatures.sign(SECRET, BODY);
byte[] tampered = "the document byteS".getBytes(StandardCharsets.UTF_8);
assertFalse(WebhookSignatures.verify(SECRET, tampered, header));
}
@Test
void aMissingOrMalformedHeaderIsFalseNotAnError() {
assertFalse(WebhookSignatures.verify(SECRET, BODY, null));
assertFalse(WebhookSignatures.verify(SECRET, BODY, "sha256=not-hex"));
assertFalse(WebhookSignatures.verify(SECRET, BODY, ""));
}
}
@@ -36,7 +36,6 @@ import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.InternalApiClient;
import stirling.software.proprietary.audit.AuditContext;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@@ -719,57 +718,6 @@ class AuditServiceTest {
}
}
@Nested
@DisplayName("addAutomationContext")
class AddAutomationContext {
@Test
@DisplayName("caps and strips newlines from a spoofable policy-name header")
void capsPolicyNameHeader() {
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/misc/compress-pdf");
req.addHeader(InternalApiClient.POLICY_NAME_HEADER, "evil\r\nname" + "x".repeat(500));
Map<String, Object> data = new HashMap<>();
service.addAutomationContext(data, req);
String stored = (String) data.get("policyName");
assertThat(stored).hasSize(200);
assertThat(stored).doesNotContain("\r").doesNotContain("\n");
}
@Test
@DisplayName("caps the number of persisted policy steps")
void capsPolicySteps() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/policies/run");
req.setAttribute(
AuditContext.REQ_ATTR_POLICY_STEPS,
java.util.stream.IntStream.range(0, 100)
.mapToObj(i -> "/api/v1/misc/step-" + i)
.toList());
Map<String, Object> data = new HashMap<>();
service.addAutomationContext(data, req);
@SuppressWarnings("unchecked")
java.util.List<String> stored = (java.util.List<String>) data.get("policySteps");
assertThat(stored).hasSize(50);
}
@Test
@DisplayName("marks automation from the header")
void marksAutomationHeader() {
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/misc/compress-pdf");
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
Map<String, Object> data = new HashMap<>();
service.addAutomationContext(data, req);
assertThat(data).containsEntry("automation", Boolean.TRUE);
}
}
// ===== helpers =====
private ProceedingJoinPoint joinPoint() {
@@ -1,73 +0,0 @@
package stirling.software.proprietary.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
/**
* The scan must drop read/polling noise (UI_DATA/HTTP_REQUEST) at the query level. Otherwise a busy
* scope's recent rows fill with noise and the visible audit list shrinks as traffic grows.
*/
@ExtendWith(MockitoExtension.class)
class PortalAuditReadServiceTest {
@Mock private PersistentAuditEventRepository repo;
@InjectMocks private PortalAuditReadService service;
private static Page<PersistentAuditEvent> emptyPage() {
return new PageImpl<>(List.of());
}
@Test
void serverScanExcludesReadNoiseAndNeverPullsEverything() {
when(repo.findByTypeNotIn(anyList(), any(Pageable.class))).thenReturn(emptyPage());
service.serverEvents();
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> excluded = ArgumentCaptor.forClass(List.class);
verify(repo).findByTypeNotIn(excluded.capture(), any(Pageable.class));
assertThat(excluded.getValue()).contains("UI_DATA", "HTTP_REQUEST");
verify(repo, never()).findAll(any(Pageable.class));
}
@Test
void teamScanExcludesReadNoiseForTheTeamPrincipals() {
when(repo.findByTypeNotInAndPrincipalIn(anyList(), anyList(), any(Pageable.class)))
.thenReturn(emptyPage());
service.scopedEvents("team:1", List.of("a@acme.com", "b@acme.com"));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> excluded = ArgumentCaptor.forClass(List.class);
verify(repo)
.findByTypeNotInAndPrincipalIn(excluded.capture(), anyList(), any(Pageable.class));
assertThat(excluded.getValue()).contains("UI_DATA", "HTTP_REQUEST");
}
@Test
void emptyTeamPrincipalsShortCircuitToNoQuery() {
assertThat(service.scopedEvents("team:1", List.of())).isEmpty();
verify(repo, never())
.findByTypeNotInAndPrincipalIn(anyList(), anyList(), any(Pageable.class));
}
}
@@ -1,73 +0,0 @@
package stirling.software.proprietary.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.PortalAuditEventRow;
import stirling.software.proprietary.model.api.documents.PortalReviewDocumentDto;
import tools.jackson.databind.json.JsonMapper;
/**
* The Documents feed showed a policy run's internal steps as ordinary "API" traffic. They should
* read as automation so a policy run isn't confused with a customer's own API integration.
*/
@ExtendWith(MockitoExtension.class)
class PortalDocumentsServiceTest {
@Mock private PortalAuditReadService auditReadService;
private PortalDocumentsService service;
@BeforeEach
void setUp() {
service = new PortalDocumentsService(auditReadService, JsonMapper.builder().build());
}
private static PortalAuditEventRow row(long id, String data) {
return new PortalAuditEventRow(
id, "con.yoh13@gmail.com", AuditEventType.PDF_PROCESS.name(), data, Instant.now());
}
private PortalReviewDocumentDto onlyDoc(String data) {
when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data)));
return service.serverDocuments().getDocuments().get(0);
}
@Test
void policyStepDocumentIsLabelledAutomation() {
PortalReviewDocumentDto doc =
onlyDoc(
"{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true,"
+ "\"__origin\":\"API\",\"files\":[{\"name\":\"mushroom life.pdf\","
+ "\"type\":\"application/pdf\"}],\"statusCode\":200}");
assertThat(doc.getName()).isEqualTo("mushroom life.pdf");
assertThat(doc.getAction()).isEqualTo("Auto Redact");
// Was product "API" (loopback used the API key); now clearly a policy automation step.
assertThat(doc.getProduct()).isEqualTo("Automation");
assertThat(doc.getSource()).isEqualTo("Policy automation");
}
@Test
void directApiDocumentStaysApi() {
PortalReviewDocumentDto doc =
onlyDoc(
"{\"path\":\"/api/v1/misc/compress-pdf\",\"__origin\":\"API\","
+ "\"files\":[{\"name\":\"a.pdf\",\"type\":\"application/pdf\"}],"
+ "\"statusCode\":200}");
assertThat(doc.getProduct()).isEqualTo("API");
assertThat(doc.getSource()).isEqualTo("API integration");
}
}
@@ -1,149 +0,0 @@
package stirling.software.proprietary.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.PortalAuditEventRow;
import stirling.software.proprietary.model.api.audit.InfraAuditEventDto;
import tools.jackson.databind.json.JsonMapper;
/**
* The Infrastructure → Audit tab reproduces the reported bug: a policy run showed as a bare "Run"
* row plus a separate "Auto Redact" row with no hint either belonged to a policy.
*/
@ExtendWith(MockitoExtension.class)
class PortalInfraAuditServiceTest {
@Mock private PortalAuditReadService auditReadService;
private PortalInfraAuditService service;
@BeforeEach
void setUp() {
service = new PortalInfraAuditService(auditReadService, JsonMapper.builder().build());
}
private static PortalAuditEventRow row(long id, String data) {
return new PortalAuditEventRow(
id,
"con.yoh13@gmail.com",
AuditEventType.PDF_PROCESS.name(),
data,
Instant.parse("2026-07-09T10:18:24Z"));
}
private InfraAuditEventDto onlyEvent(String data) {
when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data)));
return service.serverAuditLog().getEvents().get(0);
}
@Test
void policyDispatchShowsPolicyNameAndTheToolsItRuns() {
String data =
"{\"path\":\"/api/v1/policies/run\",\"policyName\":\"Redaction\","
+ "\"policySteps\":[\"/api/v1/security/auto-redact\","
+ "\"/api/v1/misc/compress-pdf\"],\"statusCode\":202,"
+ "\"latencyMs\":5}";
when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data)));
var resp = service.serverAuditLog();
InfraAuditEventDto e = resp.getEvents().get(0);
// Was "Run" / "Run" under "processing"; now names the policy, lists what it ran, and
// badges as its own "policy" category with its own summary count.
assertThat(e.getAction()).isEqualTo("Redaction");
assertThat(e.getCategory()).isEqualTo("policy");
assertThat(e.getTarget()).isEqualTo("Auto Redact, Compress PDF");
assertThat(resp.getSummary().getPolicy()).isEqualTo(1);
}
@Test
void internalPipelineStepIsFlaggedAsAutomation() {
InfraAuditEventDto e =
onlyEvent(
"{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true,"
+ "\"files\":[{\"name\":\"mushroom life.pdf\"}],"
+ "\"statusCode\":200,\"latencyMs\":300}");
// Was a bare "Auto Redact" indistinguishable from a direct user action.
assertThat(e.getAction()).isEqualTo("Auto Redact (automation)");
assertThat(e.getCategory()).isEqualTo("security");
assertThat(e.getTarget()).isEqualTo("mushroom life.pdf");
}
@Test
void internalStepCarryingItsPolicyNameLinksBackToThePolicy() {
InfraAuditEventDto e =
onlyEvent(
"{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true,"
+ "\"policyName\":\"Redaction demo\",\"files\":[{\"name\":"
+ "\"demo.pdf\"}],\"statusCode\":200}");
// The forwarded policy name makes the step's origin unmistakable.
assertThat(e.getAction()).isEqualTo("Auto Redact (policy: Redaction demo)");
assertThat(e.getCategory()).isEqualTo("security");
assertThat(e.getTarget()).isEqualTo("demo.pdf");
}
@Test
void adHocRunWithoutNameStillReadsAsPolicyRun() {
InfraAuditEventDto e =
onlyEvent(
"{\"path\":\"/api/v1/policies/run/stream\",\"statusCode\":200,"
+ "\"latencyMs\":4}");
assertThat(e.getAction()).isEqualTo("Policy run");
assertThat(e.getTarget()).isEqualTo("Pipeline");
}
@Test
void directToolRunIsUnchanged() {
InfraAuditEventDto e =
onlyEvent(
"{\"path\":\"/api/v1/misc/compress-pdf\","
+ "\"files\":[{\"name\":\"a.pdf\"}],\"statusCode\":200,"
+ "\"latencyMs\":100}");
assertThat(e.getAction()).isEqualTo("Compress PDF");
assertThat(e.getCategory()).isEqualTo("processing");
assertThat(e.getTarget()).isEqualTo("a.pdf");
}
/**
* A spoofed X-Stirling-Policy-Name header lands in audit data as policyName on a direct tool
* call (no automation marker, non-run path). It must NOT flip the row into a "policy" dispatch
* that overwrites the real action and hides the affected file - only a real /policies/.../run
* URI does that.
*/
@Test
void forgedPolicyNameOnDirectCallCannotMaskTheRealAction() {
when(auditReadService.serverEvents())
.thenReturn(
List.of(
row(
1L,
"{\"path\":\"/api/v1/security/remove-password\","
+ "\"policyName\":\"Daily cleanup\",\"files\":"
+ "[{\"name\":\"secret.pdf\"}],\"statusCode\":200}")));
var resp = service.serverAuditLog();
InfraAuditEventDto e = resp.getEvents().get(0);
// Real op and file stay visible; the forged name does not become the action or category.
assertThat(e.getAction()).isEqualTo("Remove Password");
assertThat(e.getCategory()).isEqualTo("security");
assertThat(e.getTarget()).isEqualTo("secret.pdf");
assertThat(resp.getSummary().getPolicy()).isEqualTo(0);
}
}
@@ -103,18 +103,6 @@ class SecretMaskerTest {
assertEquals("AKIAEXAMPLE", result.get("accessKeyId"));
}
@Test
@DisplayName("should mask camelCase signingSecret despite no word boundary")
void shouldMaskCamelCaseSigningSecret() {
Map<String, Object> input = Map.of("signingSecret", "shh", "webhookId", "whk_abc");
Map<String, Object> result = SecretMasker.mask(input);
assertEquals(SecretMasker.REDACTED, result.get("signingSecret"));
// The routing id is a public URL token, not a secret.
assertEquals("whk_abc", result.get("webhookId"));
}
@Test
@DisplayName("should mask nested map sensitive keys")
void shouldMaskNestedMapSensitiveKeys() {
@@ -87,9 +87,13 @@ public class WalletLedgerEntry implements Serializable {
private Integer docCount = 1;
/**
* SHA-256 of this entry's input file set; {@code COUNT(DISTINCT ...)} over a period gives
* unique PDFs processed. {@code null} for aggregate/system entries (grants, linked-instance
* sync) that don't map to a single document set.
* SHA-256 of this entry's input file <em>set</em> (the charge's whole input list, sorted).
* {@code COUNT(DISTINCT ...)} over a period approximates unique PDFs processed. Exact within a
* run (a file's chain/split steps share the run's charge, so one fingerprint), and for the
* single-input common case; but the <em>same</em> file reused across different groupings — e.g.
* standalone, then later merged as {A,B} — yields different set-fingerprints and is counted
* once per grouping. {@code null} for aggregate/system entries (grants, linked-instance sync)
* that don't map to a single document set.
*/
@Column(name = "document_fingerprint", length = 64)
private String documentFingerprint;
@@ -15,10 +15,6 @@ from stirling.documents.store import Document, DocumentStore, SearchResult, Stor
from stirling.models import OwnerId, PrincipalId
_READ_PERMISSION = "read"
# SQLite defaults to failing immediately (busy_timeout=0) when it can't grab the
# write lock. With multiple worker processes opening the same file, they collide on
# startup schema-init and get "database is locked". Wait for the lock instead.
_BUSY_TIMEOUT_MS = 5000
# sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS``
# so lexicographic comparison against ``datetime('now')`` matches chronological order.
_SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
@@ -56,8 +52,6 @@ class SqliteVecStore(DocumentStore):
# Required so cascade deletes from documents_meta clean up child tables.
conn.execute("PRAGMA foreign_keys=ON")
if self._db_path is not None:
# Set before the WAL switch below: that pragma also takes the lock.
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA journal_mode=WAL")
self._conn = conn
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "العقود"
"Financial reports" = "التقارير المالية"
"HR records" = "سجلات الموارد البشرية"
Insurance = "التأمين"
Invoices = "الفواتير"
"Legal filings" = "الإيداعات القانونية"
"Medical / PHI" = "طبي / PHI"
"Tax documents" = "المستندات الضريبية"
[policies.enforcement]
applying = "جارٍ تطبيق {{names}}"
applyingProgress = "جارٍ تطبيق {{names}} ({{done}} من {{total}})"
@@ -6015,6 +6025,51 @@ notify = "إشعار عند التوجيه"
onViolation = "عند عدم الامتثال"
webhookUrl = "Webhook URL"
[policies.fieldOption.archiveAfter]
"1 year" = "سنة واحدة"
"30 days" = "30 يومًا"
"90 days" = "90 يومًا"
Never = "أبدًا"
[policies.fieldOption.belowThreshold]
"Flag for review" = "وضع علامة للمراجعة"
Hold = "احتجاز"
"Route to bucket" = "توجيه إلى bucket"
[policies.fieldOption.destination]
Documents = "المستندات"
"S3 bucket" = "S3 bucket"
SharePoint = "SharePoint"
Webhook = "Webhook"
[policies.fieldOption.frameworks]
FedRAMP = "FedRAMP"
GDPR = "GDPR"
HIPAA = "HIPAA"
"ISO 27001" = "ISO 27001"
"PCI DSS" = "PCI DSS"
"SOC 2" = "SOC 2"
[policies.fieldOption.keepFor]
"1 year" = "سنة واحدة"
"3 years" = "3 سنوات"
"30 days" = "30 يومًا"
"7 years" = "7 سنوات"
Indefinite = "إلى أجل غير مسمى"
[policies.fieldOption.minConfidence]
"60%" = "60%"
"70%" = "70%"
"80%" = "80%"
"90%" = "90%"
"95%" = "95%"
[policies.fieldOption.onViolation]
"Auto-redact PHI" = "تنقيح PHI تلقائيًا"
"Block export" = "حظر التصدير"
"Flag for review" = "وضع علامة للمراجعة"
"Quarantine document" = "عزل المستند"
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6148,44 +6203,6 @@ dismiss = "إغلاق التراكب"
enforcingTitle = "جارٍ فرض السياسة..."
viewAnyway = "عرض الملف (لا تزال السياسة قيد الفرض)"
[policyOption]
autoRedactPhi = "تنقيح PHI تلقائيًا"
blockExport = "حظر التصدير"
contracts = "العقود"
documents = "المستندات"
fedramp = "FedRAMP"
financialReports = "التقارير المالية"
flagForReview = "وضع علامة للمراجعة"
gdpr = "GDPR"
hipaa = "HIPAA"
hold = "احتجاز"
hrRecords = "سجلات الموارد البشرية"
indefinite = "إلى أجل غير مسمى"
insurance = "التأمين"
invoices = "الفواتير"
iso27001 = "ISO 27001"
legalFilings = "الإيداعات القانونية"
medicalPhi = "طبي / PHI"
never = "أبدًا"
ninetyDays = "90 يومًا"
oneYear = "سنة واحدة"
p60 = "60%"
p70 = "70%"
p80 = "80%"
p90 = "90%"
p95 = "95%"
pciDss = "PCI DSS"
quarantineDocument = "عزل المستند"
routeToBucket = "توجيه إلى bucket"
s3Bucket = "S3 bucket"
sevenYears = "7 سنوات"
sharePoint = "SharePoint"
soc2 = "SOC 2"
taxDocuments = "المستندات الضريبية"
thirtyDays = "30 يومًا"
threeYears = "3 سنوات"
webhook = "Webhook"
[portal]
comingSoon = "قريبًا"
@@ -8050,6 +8067,10 @@ title = "انتهت صلاحية الجلسة"
[portal.users]
title = "المستخدمون"
[portal.users.roles]
subtitle = "كل دور موجود في كل خطة — وما يمكن لكل دور فعله ثابت عبر المؤسسة."
title = "الأدوار"
[portal.users.roles.admin]
label = "المسؤول (مالك المؤسسة)"
summary = "حوكمة كاملة على مساحة العمل والإعدادات والأعضاء."
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "Müqavilələr"
"Financial reports" = "Maliyyə hesabatları"
"HR records" = "HR qeydləri"
Insurance = "Sığorta"
Invoices = "Fakturalar"
"Legal filings" = "Hüquqi sənədlər"
"Medical / PHI" = "Tibbi / PHI"
"Tax documents" = "Vergi sənədləri"
[policies.enforcement]
applying = "{{names}} tətbiq edilir"
applyingProgress = "{{names}} tətbiq edilir ({{done}}/{{total}})"
@@ -6015,6 +6025,51 @@ notify = "Yönləndirmədə bildir"
onViolation = "Uyğunsuzluq olduqda"
webhookUrl = "Webhook URL"
[policies.fieldOption.archiveAfter]
"1 year" = "1 il"
"30 days" = "30 gün"
"90 days" = "90 gün"
Never = "Heç vaxt"
[policies.fieldOption.belowThreshold]
"Flag for review" = "Baxış üçün işarələ"
Hold = "Saxla"
"Route to bucket" = "Bucket-ə yönləndir"
[policies.fieldOption.destination]
Documents = "Sənədlər"
"S3 bucket" = "S3 bucket"
SharePoint = "SharePoint"
Webhook = "Webhook"
[policies.fieldOption.frameworks]
FedRAMP = "FedRAMP"
GDPR = "GDPR"
HIPAA = "HIPAA"
"ISO 27001" = "ISO 27001"
"PCI DSS" = "PCI DSS"
"SOC 2" = "SOC 2"
[policies.fieldOption.keepFor]
"1 year" = "1 il"
"3 years" = "3 il"
"30 days" = "30 gün"
"7 years" = "7 il"
Indefinite = "Qeyri-müəyyən"
[policies.fieldOption.minConfidence]
"60%" = "60%"
"70%" = "70%"
"80%" = "80%"
"90%" = "90%"
"95%" = "95%"
[policies.fieldOption.onViolation]
"Auto-redact PHI" = "PHI-ni avtomatik redaktə et"
"Block export" = "Eksportu blokla"
"Flag for review" = "Baxış üçün işarələ"
"Quarantine document" = "Sənədi karantinə al"
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6148,44 +6203,6 @@ dismiss = "Örtüyü bağla"
enforcingTitle = "Siyasət tətbiq edilir..."
viewAnyway = "Faylı göstər (siyasət hələ tətbiq edilir)"
[policyOption]
autoRedactPhi = "PHI-ni avtomatik redaktə et"
blockExport = "Eksportu blokla"
contracts = "Müqavilələr"
documents = "Sənədlər"
fedramp = "FedRAMP"
financialReports = "Maliyyə hesabatları"
flagForReview = "Baxış üçün işarələ"
gdpr = "GDPR"
hipaa = "HIPAA"
hold = "Saxla"
hrRecords = "HR qeydləri"
indefinite = "Qeyri-müəyyən"
insurance = "Sığorta"
invoices = "Fakturalar"
iso27001 = "ISO 27001"
legalFilings = "Hüquqi sənədlər"
medicalPhi = "Tibbi / PHI"
never = "Heç vaxt"
ninetyDays = "90 gün"
oneYear = "1 il"
p60 = "60%"
p70 = "70%"
p80 = "80%"
p90 = "90%"
p95 = "95%"
pciDss = "PCI DSS"
quarantineDocument = "Sənədi karantinə al"
routeToBucket = "Bucket-ə yönləndir"
s3Bucket = "S3 bucket"
sevenYears = "7 il"
sharePoint = "SharePoint"
soc2 = "SOC 2"
taxDocuments = "Vergi sənədləri"
thirtyDays = "30 gün"
threeYears = "3 il"
webhook = "Webhook"
[portal]
comingSoon = "Tezliklə"
@@ -8050,6 +8067,10 @@ title = "Sessiyanın müddəti bitib"
[portal.users]
title = "İstifadəçilər"
[portal.users.roles]
subtitle = "Hər rol hər planda mövcuddur — hər birinin nə edə biləcəyi təşkilat üzrə sabitdir."
title = "Rollar"
[portal.users.roles.admin]
label = "Admin (Təşkilat sahibi)"
summary = "İş sahəsi, parametrlər və üzvlər üzərində tam idarəetmə."
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "Договори"
"Financial reports" = "Финансови отчети"
"HR records" = "HR записи"
Insurance = "Застраховане"
Invoices = "Фактури"
"Legal filings" = "Правни документи"
"Medical / PHI" = "Медицински / PHI"
"Tax documents" = "Данъчни документи"
[policies.enforcement]
applying = "Прилагане на {{names}}"
applyingProgress = "Прилагане на {{names}} ({{done}} от {{total}})"
@@ -6015,6 +6025,51 @@ notify = "Уведоми при маршрутизиране"
onViolation = "При несъответствие"
webhookUrl = "Webhook URL"
[policies.fieldOption.archiveAfter]
"1 year" = "1 година"
"30 days" = "30 дни"
"90 days" = "90 дни"
Never = "Никога"
[policies.fieldOption.belowThreshold]
"Flag for review" = "Маркирай за преглед"
Hold = "Задръж"
"Route to bucket" = "Насочи към bucket"
[policies.fieldOption.destination]
Documents = "Документи"
"S3 bucket" = "S3 bucket"
SharePoint = "SharePoint"
Webhook = "Webhook"
[policies.fieldOption.frameworks]
FedRAMP = "FedRAMP"
GDPR = "GDPR"
HIPAA = "HIPAA"
"ISO 27001" = "ISO 27001"
"PCI DSS" = "PCI DSS"
"SOC 2" = "SOC 2"
[policies.fieldOption.keepFor]
"1 year" = "1 година"
"3 years" = "3 години"
"30 days" = "30 дни"
"7 years" = "7 години"
Indefinite = "Безсрочно"
[policies.fieldOption.minConfidence]
"60%" = "60%"
"70%" = "70%"
"80%" = "80%"
"90%" = "90%"
"95%" = "95%"
[policies.fieldOption.onViolation]
"Auto-redact PHI" = "Автоматично заличаване на PHI"
"Block export" = "Блокирай експортирането"
"Flag for review" = "Маркирай за преглед"
"Quarantine document" = "Постави документа под карантина"
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6148,44 +6203,6 @@ dismiss = "Скрий наслагването"
enforcingTitle = "Прилагане на политика..."
viewAnyway = "Виж файла (политиката все още се прилага)"
[policyOption]
autoRedactPhi = "Автоматично заличаване на PHI"
blockExport = "Блокирай експортирането"
contracts = "Договори"
documents = "Документи"
fedramp = "FedRAMP"
financialReports = "Финансови отчети"
flagForReview = "Маркирай за преглед"
gdpr = "GDPR"
hipaa = "HIPAA"
hold = "Задръж"
hrRecords = "HR записи"
indefinite = "Безсрочно"
insurance = "Застраховане"
invoices = "Фактури"
iso27001 = "ISO 27001"
legalFilings = "Правни документи"
medicalPhi = "Медицински / PHI"
never = "Никога"
ninetyDays = "90 дни"
oneYear = "1 година"
p60 = "60%"
p70 = "70%"
p80 = "80%"
p90 = "90%"
p95 = "95%"
pciDss = "PCI DSS"
quarantineDocument = "Постави документа под карантина"
routeToBucket = "Насочи към bucket"
s3Bucket = "S3 bucket"
sevenYears = "7 години"
sharePoint = "SharePoint"
soc2 = "SOC 2"
taxDocuments = "Данъчни документи"
thirtyDays = "30 дни"
threeYears = "3 години"
webhook = "Webhook"
[portal]
comingSoon = "Очаквайте скоро"
@@ -8050,6 +8067,10 @@ title = "Сесията е изтекла"
[portal.users]
title = "Потребители"
[portal.users.roles]
subtitle = "Всяка роля съществува във всеки план — това, което всяка може да прави, е фиксирано за цялата организация."
title = "Роли"
[portal.users.roles.admin]
label = "Администратор (собственик на организация)"
summary = "Пълно управление на работното пространство, настройките и членовете."
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "Contractes"
"Financial reports" = "Informes financers"
"HR records" = "Registres de RR. HH."
Insurance = "Assegurances"
Invoices = "Factures"
"Legal filings" = "Presentacions legals"
"Medical / PHI" = "Mèdic / PHI"
"Tax documents" = "Documents fiscals"
[policies.enforcement]
applying = "Aplicant {{names}}"
applyingProgress = "Aplicant {{names}} ({{done}} de {{total}})"
@@ -6015,6 +6025,51 @@ notify = "Notifica en encaminar"
onViolation = "Quan no compleix"
webhookUrl = "URL del webhook"
[policies.fieldOption.archiveAfter]
"1 year" = "1 any"
"30 days" = "30 dies"
"90 days" = "90 dies"
Never = "Mai"
[policies.fieldOption.belowThreshold]
"Flag for review" = "Marca per revisar"
Hold = "Retén"
"Route to bucket" = "Encamina al bucket"
[policies.fieldOption.destination]
Documents = "Documents"
"S3 bucket" = "bucket S3"
SharePoint = "SharePoint"
Webhook = "Webhook"
[policies.fieldOption.frameworks]
FedRAMP = "FedRAMP"
GDPR = "GDPR"
HIPAA = "HIPAA"
"ISO 27001" = "ISO 27001"
"PCI DSS" = "PCI DSS"
"SOC 2" = "SOC 2"
[policies.fieldOption.keepFor]
"1 year" = "1 any"
"3 years" = "3 anys"
"30 days" = "30 dies"
"7 years" = "7 anys"
Indefinite = "Indefinit"
[policies.fieldOption.minConfidence]
"60%" = "60%"
"70%" = "70%"
"80%" = "80%"
"90%" = "90%"
"95%" = "95%"
[policies.fieldOption.onViolation]
"Auto-redact PHI" = "Oculta automàticament PHI"
"Block export" = "Bloqueja l'exportació"
"Flag for review" = "Marca per revisar"
"Quarantine document" = "Posa el document en quarantena"
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6148,44 +6203,6 @@ dismiss = "Descarta la superposició"
enforcingTitle = "S'està aplicant la política..."
viewAnyway = "Mostra el fitxer (la política encara s'està aplicant)"
[policyOption]
autoRedactPhi = "Oculta automàticament PHI"
blockExport = "Bloqueja l'exportació"
contracts = "Contractes"
documents = "Documents"
fedramp = "FedRAMP"
financialReports = "Informes financers"
flagForReview = "Marca per revisar"
gdpr = "GDPR"
hipaa = "HIPAA"
hold = "Retén"
hrRecords = "Registres de RR. HH."
indefinite = "Indefinit"
insurance = "Assegurances"
invoices = "Factures"
iso27001 = "ISO 27001"
legalFilings = "Presentacions legals"
medicalPhi = "Mèdic / PHI"
never = "Mai"
ninetyDays = "90 dies"
oneYear = "1 any"
p60 = "60%"
p70 = "70%"
p80 = "80%"
p90 = "90%"
p95 = "95%"
pciDss = "PCI DSS"
quarantineDocument = "Posa el document en quarantena"
routeToBucket = "Encamina al bucket"
s3Bucket = "bucket S3"
sevenYears = "7 anys"
sharePoint = "SharePoint"
soc2 = "SOC 2"
taxDocuments = "Documents fiscals"
thirtyDays = "30 dies"
threeYears = "3 anys"
webhook = "Webhook"
[portal]
comingSoon = "Properament"
@@ -8050,6 +8067,10 @@ title = "Sessió caducada"
[portal.users]
title = "Usuaris"
[portal.users.roles]
subtitle = "Cada rol existeix en tots els plans — el que pot fer cadascun és fix a tota l'organització."
title = "Rols"
[portal.users.roles.admin]
label = "Admin (propietari de l'org)"
summary = "Governança completa sobre l'espai de treball, la configuració i els membres."
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "Smlouvy"
"Financial reports" = "Finanční zprávy"
"HR records" = "HR záznamy"
Insurance = "Pojištění"
Invoices = "Faktury"
"Legal filings" = "Právní podání"
"Medical / PHI" = "Zdravotní / PHI"
"Tax documents" = "Daňové dokumenty"
[policies.enforcement]
applying = "Probíhá použití {{names}}"
applyingProgress = "Probíhá použití {{names}} ({{done}} z {{total}})"
@@ -6015,6 +6025,51 @@ notify = "Upozornit při směrování"
onViolation = "Při nesouladu"
webhookUrl = "Webhook URL"
[policies.fieldOption.archiveAfter]
"1 year" = "1 rok"
"30 days" = "30 dní"
"90 days" = "90 dní"
Never = "Nikdy"
[policies.fieldOption.belowThreshold]
"Flag for review" = "Označit ke kontrole"
Hold = "Zadržet"
"Route to bucket" = "Směrovat do bucketu"
[policies.fieldOption.destination]
Documents = "Dokumenty"
"S3 bucket" = "S3 bucket"
SharePoint = "SharePoint"
Webhook = "Webhook"
[policies.fieldOption.frameworks]
FedRAMP = "FedRAMP"
GDPR = "GDPR"
HIPAA = "HIPAA"
"ISO 27001" = "ISO 27001"
"PCI DSS" = "PCI DSS"
"SOC 2" = "SOC 2"
[policies.fieldOption.keepFor]
"1 year" = "1 rok"
"3 years" = "3 roky"
"30 days" = "30 dní"
"7 years" = "7 let"
Indefinite = "Neurčitě"
[policies.fieldOption.minConfidence]
"60%" = "60%"
"70%" = "70%"
"80%" = "80%"
"90%" = "90%"
"95%" = "95%"
[policies.fieldOption.onViolation]
"Auto-redact PHI" = "Automaticky redigovat PHI"
"Block export" = "Blokovat export"
"Flag for review" = "Označit ke kontrole"
"Quarantine document" = "Umístit dokument do karantény"
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6148,44 +6203,6 @@ dismiss = "Zavřít překryvnou vrstvu"
enforcingTitle = "Vynucování zásady..."
viewAnyway = "Zobrazit soubor (zásada se stále vynucuje)"
[policyOption]
autoRedactPhi = "Automaticky redigovat PHI"
blockExport = "Blokovat export"
contracts = "Smlouvy"
documents = "Dokumenty"
fedramp = "FedRAMP"
financialReports = "Finanční zprávy"
flagForReview = "Označit ke kontrole"
gdpr = "GDPR"
hipaa = "HIPAA"
hold = "Zadržet"
hrRecords = "HR záznamy"
indefinite = "Neurčitě"
insurance = "Pojištění"
invoices = "Faktury"
iso27001 = "ISO 27001"
legalFilings = "Právní podání"
medicalPhi = "Zdravotní / PHI"
never = "Nikdy"
ninetyDays = "90 dní"
oneYear = "1 rok"
p60 = "60%"
p70 = "70%"
p80 = "80%"
p90 = "90%"
p95 = "95%"
pciDss = "PCI DSS"
quarantineDocument = "Umístit dokument do karantény"
routeToBucket = "Směrovat do bucketu"
s3Bucket = "S3 bucket"
sevenYears = "7 let"
sharePoint = "SharePoint"
soc2 = "SOC 2"
taxDocuments = "Daňové dokumenty"
thirtyDays = "30 dní"
threeYears = "3 roky"
webhook = "Webhook"
[portal]
comingSoon = "Již brzy"
@@ -8050,6 +8067,10 @@ title = "Relace vypršela"
[portal.users]
title = "Uživatelé"
[portal.users.roles]
subtitle = "Každá role existuje v každém plánu — co která může dělat, je pevně dané napříč organizací."
title = "Role"
[portal.users.roles.admin]
label = "Admin (vlastník org.)"
summary = "Plná správa governance nad workspace, nastavením a členy."
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "Kontrakter"
"Financial reports" = "Finansrapporter"
"HR records" = "HR-registreringer"
Insurance = "Forsikring"
Invoices = "Fakturaer"
"Legal filings" = "Juridiske indleveringer"
"Medical / PHI" = "Medicinsk / PHI"
"Tax documents" = "Skattedokumenter"
[policies.enforcement]
applying = "Anvender {{names}}"
applyingProgress = "Anvender {{names}} ({{done}} af {{total}})"
@@ -6015,6 +6025,51 @@ notify = "Giv besked ved routing"
onViolation = "Når ikke-kompatibel"
webhookUrl = "Webhook URL"
[policies.fieldOption.archiveAfter]
"1 year" = "1 år"
"30 days" = "30 dage"
"90 days" = "90 dage"
Never = "Aldrig"
[policies.fieldOption.belowThreshold]
"Flag for review" = "Markér til gennemgang"
Hold = "Tilbagehold"
"Route to bucket" = "Route til bucket"
[policies.fieldOption.destination]
Documents = "Dokumenter"
"S3 bucket" = "S3 bucket"
SharePoint = "SharePoint"
Webhook = "Webhook"
[policies.fieldOption.frameworks]
FedRAMP = "FedRAMP"
GDPR = "GDPR"
HIPAA = "HIPAA"
"ISO 27001" = "ISO 27001"
"PCI DSS" = "PCI DSS"
"SOC 2" = "SOC 2"
[policies.fieldOption.keepFor]
"1 year" = "1 år"
"3 years" = "3 år"
"30 days" = "30 dage"
"7 years" = "7 år"
Indefinite = "På ubestemt tid"
[policies.fieldOption.minConfidence]
"60%" = "60%"
"70%" = "70%"
"80%" = "80%"
"90%" = "90%"
"95%" = "95%"
[policies.fieldOption.onViolation]
"Auto-redact PHI" = "Auto-rediger PHI"
"Block export" = "Bloker eksport"
"Flag for review" = "Markér til gennemgang"
"Quarantine document" = "Sæt dokument i karantæne"
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6148,44 +6203,6 @@ dismiss = "Afvis overlay"
enforcingTitle = "Håndhæver politik..."
viewAnyway = "Vis fil (politik håndhæves stadig)"
[policyOption]
autoRedactPhi = "Auto-rediger PHI"
blockExport = "Bloker eksport"
contracts = "Kontrakter"
documents = "Dokumenter"
fedramp = "FedRAMP"
financialReports = "Finansrapporter"
flagForReview = "Markér til gennemgang"
gdpr = "GDPR"
hipaa = "HIPAA"
hold = "Tilbagehold"
hrRecords = "HR-registreringer"
indefinite = "På ubestemt tid"
insurance = "Forsikring"
invoices = "Fakturaer"
iso27001 = "ISO 27001"
legalFilings = "Juridiske indleveringer"
medicalPhi = "Medicinsk / PHI"
never = "Aldrig"
ninetyDays = "90 dage"
oneYear = "1 år"
p60 = "60%"
p70 = "70%"
p80 = "80%"
p90 = "90%"
p95 = "95%"
pciDss = "PCI DSS"
quarantineDocument = "Sæt dokument i karantæne"
routeToBucket = "Route til bucket"
s3Bucket = "S3 bucket"
sevenYears = "7 år"
sharePoint = "SharePoint"
soc2 = "SOC 2"
taxDocuments = "Skattedokumenter"
thirtyDays = "30 dage"
threeYears = "3 år"
webhook = "Webhook"
[portal]
comingSoon = "Kommer snart"
@@ -8050,6 +8067,10 @@ title = "Session udløbet"
[portal.users]
title = "Brugere"
[portal.users.roles]
subtitle = "Alle roller findes på alle abonnementer — hvad hver enkelt kan gøre, er fast på tværs af organisationen."
title = "Roller"
[portal.users.roles.admin]
label = "Admin (organisationsejer)"
summary = "Fuld styring over workspace, indstillinger og medlemmer."
@@ -356,7 +356,6 @@ text = "Diese Berechtigungen kontrollieren, was Benutzer mit der PDF machen kön
title = "Berechtigungen ändern"
[addStamp]
preview = "Stempelvorschau"
tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original"
[AddStampRequest]
@@ -1843,15 +1842,6 @@ export = "Export"
accessDenied = "Zugriff verweigert"
insufficientPermissions = "Sie haben keine Berechtigung, diese Aktion auszuführen."
[auth.callback]
completing = "Authentifizierung wird abgeschlossen"
invalidToken = "OAuth-Anmeldung fehlgeschlagen – ungültiges Token."
missingToken = "OAuth-Anmeldung fehlgeschlagen – kein Token empfangen."
oauthFailed = "OAuth-Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
pleaseWait = "Bitte warten Sie, während wir Ihre Anmeldung abschließen."
signedOut = "Sie wurden abgemeldet. Bitte melden Sie sich erneut an."
windowMayClose = "Sie können dieses Fenster schließen, sobald der Vorgang abgeschlossen ist."
[auth.displayName]
guest = "Gast"
user = "Benutzer"
@@ -2834,7 +2824,6 @@ next = "Weiter"
preview = "Vorschau"
previous = "Zurück"
refresh = "Aktualisieren"
remaining = "Verbleibend"
retry = "Wiederholen"
save = "Save"
@@ -4083,17 +4072,10 @@ discord = "Discord"
issues = "GitHub"
[formFill]
allSaved = "Alles gespeichert"
extractCsvError = "CSV konnte nicht extrahiert werden"
extractXlsxError = "XLSX konnte nicht extrahiert werden"
flattenAfterFilling = "Nach dem Ausfüllen ebnen"
requiredAbbreviation = "erf."
requiredFieldsError = "Bitte füllen Sie alle Pflichtfelder aus"
rescanFields = "Felder erneut scannen"
rescanFormFields = "Formularfelder erneut scannen"
save = "Speichern"
saveShortcut = "Strg+S zum Speichern"
unsavedChanges = "Nicht gespeicherte Änderungen"
[formFill.sidebar]
close = "Seitenleiste schließen"
@@ -5014,21 +4996,7 @@ titleWithOs = "Herunterladen für {{osLabel}}"
[onboarding.mfa]
authenticationCode = "Authentifizierungscode"
description = "Schützen Sie Ihr Konto, indem Sie eine Authenticator-App verknüpfen. Scannen Sie den QR-Code oder geben Sie den Einrichtungsschlüssel ein und bestätigen Sie anschließend den sechsstelligen Code."
enable = "MFA aktivieren"
enableError = "Die Multi-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfen Sie den Code und versuchen Sie es erneut."
enterCode = "Geben Sie den sechsstelligen Code aus Ihrer App ein."
enterCodeError = "Geben Sie den Authentifizierungscode ein, um fortzufahren."
logout = "Abmelden"
openAuthenticator = "Öffnen Sie Google Authenticator, Authy oder 1Password."
qrCodeLoading = "Ihr QR-Code wird erstellt…"
regenerateQrCode = "QR-Code neu erstellen"
scanQrCode = "Scannen Sie den QR-Code oder geben Sie den unten stehenden Einrichtungsschlüssel ein."
setupError = "Die Einrichtung der Zwei-Faktor-Authentifizierung konnte nicht gestartet werden. Bitte versuchen Sie es erneut."
setupKey = "Einrichtungsschlüssel (manuelle Eingabe)"
stepByStep = "Schritt für Schritt"
success = "MFA wurde aktiviert. Sie können jetzt fortfahren."
title = "Einrichtung der Multi-Faktor-Authentifizierung"
[onboarding.planOverview]
adminBodyLoginDisabled = "Sobald Sie den Login-Modus aktivieren, können Sie Nutzer verwalten, Einstellungen konfigurieren und die Servergesundheit überwachen. Die ersten <strong>{{freeTierLimit}}</strong> Personen auf Ihrem Server nutzen Stirling kostenlos."
@@ -6012,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "Verträge"
"Financial reports" = "Finanzberichte"
"HR records" = "Personalakten"
Insurance = "Versicherung"
Invoices = "Rechnungen"
"Legal filings" = "Rechtliche Einreichungen"
"Medical / PHI" = "Medizinisch / PHI"
"Tax documents" = "Steuerdokumente"
[policies.enforcement]
applying = "Wende {{names}} an"
applyingProgress = "Wende {{names}} an ({{done}} von {{total}})"
@@ -6047,6 +6025,51 @@ notify = "Bei Weiterleitung benachrichtigen"
onViolation = "Bei Nichteinhaltung"
webhookUrl = "Webhook URL"
[policies.fieldOption.archiveAfter]
"1 year" = "1 Jahr"
"30 days" = "30 Tage"
"90 days" = "90 Tage"
Never = "Nie"
[policies.fieldOption.belowThreshold]
"Flag for review" = "Zur Prüfung markieren"
Hold = "Zurückhalten"
"Route to bucket" = "In Bucket weiterleiten"
[policies.fieldOption.destination]
Documents = "Dokumente"
"S3 bucket" = "S3-Bucket"
SharePoint = "SharePoint"
Webhook = "Webhook"
[policies.fieldOption.frameworks]
FedRAMP = "FedRAMP"
GDPR = "GDPR"
HIPAA = "HIPAA"
"ISO 27001" = "ISO 27001"
"PCI DSS" = "PCI DSS"
"SOC 2" = "SOC 2"
[policies.fieldOption.keepFor]
"1 year" = "1 Jahr"
"3 years" = "3 Jahre"
"30 days" = "30 Tage"
"7 years" = "7 Jahre"
Indefinite = "Unbefristet"
[policies.fieldOption.minConfidence]
"60%" = "60%"
"70%" = "70%"
"80%" = "80%"
"90%" = "90%"
"95%" = "95%"
[policies.fieldOption.onViolation]
"Auto-redact PHI" = "PHI automatisch schwärzen"
"Block export" = "Export blockieren"
"Flag for review" = "Zur Prüfung markieren"
"Quarantine document" = "Dokument unter Quarantäne stellen"
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6180,44 +6203,6 @@ dismiss = "Overlay ausblenden"
enforcingTitle = "Richtlinie wird durchgesetzt..."
viewAnyway = "Datei anzeigen (Richtlinie wird noch durchgesetzt)"
[policyOption]
autoRedactPhi = "PHI automatisch schwärzen"
blockExport = "Export blockieren"
contracts = "Verträge"
documents = "Dokumente"
fedramp = "FedRAMP"
financialReports = "Finanzberichte"
flagForReview = "Zur Prüfung markieren"
gdpr = "GDPR"
hipaa = "HIPAA"
hold = "Zurückhalten"
hrRecords = "Personalakten"
indefinite = "Unbefristet"
insurance = "Versicherung"
invoices = "Rechnungen"
iso27001 = "ISO 27001"
legalFilings = "Rechtliche Einreichungen"
medicalPhi = "Medizinisch / PHI"
never = "Nie"
ninetyDays = "90 Tage"
oneYear = "1 Jahr"
p60 = "60%"
p70 = "70%"
p80 = "80%"
p90 = "90%"
p95 = "95%"
pciDss = "PCI DSS"
quarantineDocument = "Dokument unter Quarantäne stellen"
routeToBucket = "In Bucket weiterleiten"
s3Bucket = "S3-Bucket"
sevenYears = "7 Jahre"
sharePoint = "SharePoint"
soc2 = "SOC 2"
taxDocuments = "Steuerdokumente"
thirtyDays = "30 Tage"
threeYears = "3 Jahre"
webhook = "Webhook"
[portal]
comingSoon = "Demnächst"
@@ -8082,6 +8067,10 @@ title = "Sitzung abgelaufen"
[portal.users]
title = "Benutzer"
[portal.users.roles]
subtitle = "Jede Rolle existiert in jedem Plan — was jede Rolle tun kann, ist in der gesamten Organisation festgelegt."
title = "Rollen"
[portal.users.roles.admin]
label = "Admin (Org-Inhaber)"
summary = "Vollständige Steuerung über Workspace, Einstellungen und Mitglieder."
@@ -9142,11 +9131,6 @@ security = "Sicherheit"
telegram = "Telegram"
title = "Sicherheit & Authentifizierung"
[settings.signOut]
confirm = "Möchten Sie sich wirklich abmelden?"
submit = "Abmelden"
title = "Abmelden"
[settings.team]
title = "Team"
@@ -9763,9 +9747,6 @@ uploadButton = "Auf Server hochladen"
title = "Stirling-PDF Survey"
[swagger]
clickHere = "hier klicken"
fallback = "Falls die Seite nicht automatisch geöffnet wurde,"
opening = "Swagger UI wird in einem neuen Tab geöffnet..."
tags = "API,Dokumentation,Swagger,Endpunkte,Entwicklung"
title = "API-Dokumentation"
@@ -10321,11 +10302,9 @@ title = "Attachments"
[viewer.bookmarks]
bookmarkTitle = "Lesezeichentitel"
bookmarkTitleRequired = "Ein Lesezeichentitel ist erforderlich"
closeSidebar = "Seitenleiste für Lesezeichen schließen"
collapseAll = "Alle Lesezeichen einklappen"
expandAll = "Alle Lesezeichen ausklappen"
searchPlaceholder = "Lesezeichen durchsuchen"
[viewer.comments]
addComment = "Kommentar hinzufügen"
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.docType]
Contracts = "Συμβάσεις"
"Financial reports" = "Οικονομικές αναφορές"
"HR records" = "Αρχεία HR"
Insurance = "Ασφάλειες"
Invoices = "Τιμολόγια"
"Legal filings" = "Νομικές καταθέσεις"
"Medical / PHI" = "Ιατρικά / PHI"
"Tax documents" = "Φορολογικά έγγραφα"
[policies.enforcement]
applying = "Εφαρμογή {{names}}"
applyingProgress = "Εφαρμογή {{names}} ({{done}} από {{total}})"
@@ -6015,6 +6025,51 @@ notify = "Ειδοποίηση κατά τη δρομολόγηση"
onViolation = "Όταν δεν συμμορφώνεται"
webhookUrl = "Webhook URL"
[policies.fieldOption.archiveAfter]
"1 year" = "1 έτος"
"30 days" = "30 ημέρες"
"90 days" = "90 ημέρες"
Never = "Ποτέ"
[policies.fieldOption.belowThreshold]
"Flag for review" = "Επισήμανση για έλεγχο"
Hold = "Διατήρηση"
"Route to bucket" = "Δρομολόγηση σε bucket"
[policies.fieldOption.destination]
Documents = "Έγγραφα"
"S3 bucket" = "S3 bucket"
SharePoint = "SharePoint"
Webhook = "Webhook"
[policies.fieldOption.frameworks]
FedRAMP = "FedRAMP"
GDPR = "GDPR"
HIPAA = "HIPAA"
"ISO 27001" = "ISO 27001"
"PCI DSS" = "PCI DSS"
"SOC 2" = "SOC 2"
[policies.fieldOption.keepFor]
"1 year" = "1 έτος"
"3 years" = "3 έτη"
"30 days" = "30 ημέρες"
"7 years" = "7 έτη"
Indefinite = "Επ' αόριστον"
[policies.fieldOption.minConfidence]
"60%" = "60%"
"70%" = "70%"
"80%" = "80%"
"90%" = "90%"
"95%" = "95%"
[policies.fieldOption.onViolation]
"Auto-redact PHI" = "Αυτόματη απόκρυψη PHI"
"Block export" = "Αποκλεισμός εξαγωγής"
"Flag for review" = "Επισήμανση για έλεγχο"
"Quarantine document" = "Καραντίνα εγγράφου"
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6148,44 +6203,6 @@ dismiss = "Απόρριψη επικάλυψης"
enforcingTitle = "Επιβολή πολιτικής..."
viewAnyway = "Προβολή αρχείου (η πολιτική εξακολουθεί να επιβάλλεται)"
[policyOption]
autoRedactPhi = "Αυτόματη απόκρυψη PHI"
blockExport = "Αποκλεισμός εξαγωγής"
contracts = "Συμβάσεις"
documents = "Έγγραφα"
fedramp = "FedRAMP"
financialReports = "Οικονομικές αναφορές"
flagForReview = "Επισήμανση για έλεγχο"
gdpr = "GDPR"
hipaa = "HIPAA"
hold = "Διατήρηση"
hrRecords = "Αρχεία HR"
indefinite = "Επ' αόριστον"
insurance = "Ασφάλειες"
invoices = "Τιμολόγια"
iso27001 = "ISO 27001"
legalFilings = "Νομικές καταθέσεις"
medicalPhi = "Ιατρικά / PHI"
never = "Ποτέ"
ninetyDays = "90 ημέρες"
oneYear = "1 έτος"
p60 = "60%"
p70 = "70%"
p80 = "80%"
p90 = "90%"
p95 = "95%"
pciDss = "PCI DSS"
quarantineDocument = "Καραντίνα εγγράφου"
routeToBucket = "Δρομολόγηση σε bucket"
s3Bucket = "S3 bucket"
sevenYears = "7 έτη"
sharePoint = "SharePoint"
soc2 = "SOC 2"
taxDocuments = "Φορολογικά έγγραφα"
thirtyDays = "30 ημέρες"
threeYears = "3 έτη"
webhook = "Webhook"
[portal]
comingSoon = "Προσεχώς"
@@ -8050,6 +8067,10 @@ title = "Η συνεδρία έληξε"
[portal.users]
title = "Χρήστες"
[portal.users.roles]
subtitle = "Κάθε ρόλος υπάρχει σε κάθε πλάνο — τι μπορεί να κάνει ο καθένας είναι σταθερό σε όλο τον οργανισμό."
title = "Ρόλοι"
[portal.users.roles.admin]
label = "Διαχειριστής (Κάτοχος οργανισμού)"
summary = "Πλήρης διακυβέρνηση του χώρου εργασίας, των ρυθμίσεων και των μελών."

Some files were not shown because too many files have changed in this diff Show More