Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bef83d80a1 | ||
|
|
8ed1ff152b | ||
|
|
d2c8bbfcb2 | ||
|
|
da1be650ea | ||
|
|
5cb5a866ca | ||
|
|
83e5319661 | ||
|
|
ed58d90ab8 | ||
|
|
4ee54243f3 | ||
|
|
350c0b796e | ||
|
|
663bb32b2c | ||
|
|
b86e963c9d | ||
|
|
7f01bcdc44 | ||
|
|
0570c4c4d9 | ||
|
|
776749277c | ||
|
|
41b1b89fcb | ||
|
|
4d4e994562 | ||
|
|
2b118556f3 | ||
|
|
fbaff56d1c | ||
|
|
a1b15e0570 | ||
|
|
76549288a9 | ||
|
|
b5d0c4a5ed | ||
|
|
8bfcf6eb7e | ||
|
|
b019f9b570 | ||
|
|
a84b375f5d | ||
|
|
52358c5bf9 | ||
|
|
c1e68c27c5 | ||
|
|
d4edff9059 | ||
|
|
80febc9993 | ||
|
|
c500c2fae7 | ||
|
|
0a1b4ec173 | ||
|
|
b8d8f028c9 | ||
|
|
cd56367295 | ||
|
|
40a2d2844f | ||
|
|
f79968f336 | ||
|
|
df43e09eca | ||
|
|
cb0f335e8a | ||
|
|
38d06d3104 | ||
|
|
fe33378333 | ||
|
|
5944cd106b | ||
|
|
fd81bf4cf8 | ||
|
|
d23318cfa6 | ||
|
|
142544c9af | ||
|
|
99a5f2a1bc | ||
|
|
863cad22bd | ||
|
|
c06657c8f9 | ||
|
|
532a80211f | ||
|
|
d06a367b87 | ||
|
|
ce6abe6e23 | ||
|
|
ece3562dc9 | ||
|
|
84d4455682 | ||
|
|
b9a7f2083b | ||
|
|
b36f3e0875 | ||
|
|
e4379184b5 | ||
|
|
5ccb56da2d | ||
|
|
16f589448d | ||
|
|
75ea3c9a1f | ||
|
|
7529190587 | ||
|
|
b9f9f84907 | ||
|
|
68ec176719 | ||
|
|
d17c3f4fec | ||
|
|
783a51950f | ||
|
|
38ccea074c | ||
|
|
a7307ff393 | ||
|
|
72729e99c1 | ||
|
|
5fba2720f0 | ||
|
|
01a1ef8c44 | ||
|
|
8535c7e9ac | ||
|
|
cca3f42623 | ||
|
|
1b7ffcdbac | ||
|
|
67a0ca6110 | ||
|
|
8e4b2e2fc6 | ||
|
|
3c93457021 |
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.1
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.1
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
# CI routing infra. Editing the top-level router (build.yml) or this filter
|
||||
# config re-runs every area's jobs, so every job-gating filter below includes
|
||||
# *ci. That makes a change to how jobs are dispatched actually exercise those
|
||||
# jobs (self-testing), instead of a router edit only matching the project filter.
|
||||
ci: &ci
|
||||
- .github/workflows/build.yml
|
||||
- .github/config/.files.yaml
|
||||
|
||||
build: &build
|
||||
- *ci
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- app/(common|core|proprietary|saas)/build.gradle
|
||||
- Taskfile.yml
|
||||
- .taskfiles/backend.yml
|
||||
- .github/workflows/check-licence.yml
|
||||
|
||||
openapi: &openapi
|
||||
- *ci
|
||||
- *build
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- app/(common|core|proprietary|saas)/src/main/java/**
|
||||
- .github/workflows/check-openapi.yml
|
||||
|
||||
docker-base: &docker-base
|
||||
- docker/base/Dockerfile
|
||||
|
||||
# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
|
||||
# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
|
||||
# Dockerfile itself changes, not on every code PR.
|
||||
dockerfiles: &dockerfiles
|
||||
- docker/**/Dockerfile*
|
||||
|
||||
docker: &docker
|
||||
- docker/embedded/Dockerfile
|
||||
- docker/embedded/Dockerfile.fat
|
||||
@@ -23,13 +41,11 @@ docker: &docker
|
||||
- *docker-base
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- *ci
|
||||
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
|
||||
- *build
|
||||
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
- "testing/**/!(requirements*.txt|requirements*.in)*"
|
||||
- *docker
|
||||
- *docker-base
|
||||
- gradle.properties
|
||||
@@ -45,8 +61,11 @@ project: &project
|
||||
- .taskfiles/docker.yml
|
||||
- scripts/db-migration/**
|
||||
- .github/workflows/db-migration-test.yml
|
||||
- .github/workflows/docker-compose-tests.yml
|
||||
- .github/workflows/test-build-docker.yml
|
||||
|
||||
frontend: &frontend
|
||||
- *ci
|
||||
- frontend/**
|
||||
- .github/workflows/testdriver.yml
|
||||
- testing/**
|
||||
@@ -63,10 +82,14 @@ frontend: &frontend
|
||||
- Taskfile.yml
|
||||
- .taskfiles/frontend.yml
|
||||
- .taskfiles/e2e.yml
|
||||
- .github/workflows/frontend-validation.yml
|
||||
- .github/workflows/e2e-stubbed.yml
|
||||
- .github/workflows/e2e-live.yml
|
||||
|
||||
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
|
||||
# job on changes to any of these.
|
||||
tauri: &tauri
|
||||
- *ci
|
||||
- frontend/editor/src-tauri/**
|
||||
- frontend/editor/src/desktop/**
|
||||
- frontend/editor/tsconfig.desktop.vite.json
|
||||
@@ -81,8 +104,9 @@ tauri: &tauri
|
||||
# the engine validation job on changes to engine sources or to the Java
|
||||
# tool surfaces it generates models from.
|
||||
engine: &engine
|
||||
- *ci
|
||||
- engine/**
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- app/(common|core|proprietary|saas)/src/main/java/**
|
||||
- .github/workflows/ai-engine.yml
|
||||
- Taskfile.yml
|
||||
- .taskfiles/engine.yml
|
||||
@@ -93,6 +117,7 @@ engine: &engine
|
||||
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
|
||||
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
|
||||
generated-models: &generated-models
|
||||
- *ci
|
||||
- *openapi
|
||||
- frontend/editor/scripts/generate-tool-api-types.mts
|
||||
- frontend/editor/src/core/types/toolApiTypes.ts
|
||||
@@ -115,6 +140,7 @@ licenses-backend: &licenses-backend
|
||||
# Files that can affect premium / enterprise behaviour. Gate the enterprise
|
||||
# Playwright job on changes to any of these on PRs.
|
||||
proprietary: &proprietary
|
||||
- *ci
|
||||
- app/proprietary/**
|
||||
- frontend/editor/src/proprietary/**
|
||||
- frontend/editor/src/core/tests/enterprise/**
|
||||
|
||||
@@ -63,6 +63,7 @@ labels:
|
||||
files:
|
||||
- 'app/core/src/main/resources/static/.*'
|
||||
- 'app/proprietary/src/main/resources/static/.*'
|
||||
- 'app/saas/src/main/resources/static/.*'
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
@@ -83,6 +84,7 @@ 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:
|
||||
@@ -90,6 +92,9 @@ 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'
|
||||
@@ -153,6 +158,7 @@ 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'
|
||||
@@ -171,3 +177,4 @@ labels:
|
||||
- 'app/common/build.gradle'
|
||||
- 'app/proprietary/build.gradle'
|
||||
- 'app/core/build.gradle'
|
||||
- 'app/saas/build.gradle'
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
check-pr:
|
||||
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
should_deploy: ${{ steps.decide.outputs.should_deploy }}
|
||||
is_fork: ${{ steps.resolve.outputs.is_fork }}
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
deploy-v2-pr:
|
||||
needs: [pick, check-pr]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
|
||||
# Concurrency control - only one deployment per PR at a time
|
||||
concurrency:
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
# Single source of truth for whether this preview embeds the admin portal:
|
||||
# drives the image build-arg and the deployment comment.
|
||||
@@ -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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -297,7 +297,6 @@ jobs:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
POLICIES_ENABLED: "true"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
|
||||
@@ -476,7 +475,7 @@ jobs:
|
||||
cleanup-v2-deployment:
|
||||
if: github.event.action == 'closed'
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
check-comment:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
issues: write
|
||||
if: |
|
||||
@@ -180,13 +180,13 @@ jobs:
|
||||
|
||||
deploy-pr:
|
||||
needs: [pick, check-comment]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
@@ -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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
@@ -511,7 +511,7 @@ jobs:
|
||||
handle-label-commands:
|
||||
if: ${{ github.event.issue.pull_request != null }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -5,6 +5,11 @@ name: _runner-pick
|
||||
# can pick a runner class without each one duplicating the 200-char gate
|
||||
# expression in their own `runs-on:`.
|
||||
#
|
||||
# It also owns the single Depot kill-switch (use_depot). Depot is currently
|
||||
# disabled repo-wide; downstream jobs gate their Depot runner/build usage on
|
||||
# use_depot so nothing has to be deleted to turn Depot off. Flip DEPOT_ENABLED
|
||||
# in the decide step to switch Depot back on.
|
||||
#
|
||||
# Caller pattern:
|
||||
#
|
||||
# jobs:
|
||||
@@ -13,12 +18,15 @@ name: _runner-pick
|
||||
#
|
||||
# real-work:
|
||||
# needs: pick
|
||||
# runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
# runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
|
||||
# steps: [...]
|
||||
#
|
||||
# Output:
|
||||
# is_fork: "true" when the trigger is a pull_request from a fork or an
|
||||
# untrusted author_association, "false" otherwise.
|
||||
# Outputs:
|
||||
# is_fork: "true" when the trigger is a pull_request from a fork or an
|
||||
# untrusted author_association, "false" otherwise. Use this for
|
||||
# trust gating (skipping secret-dependent jobs on forks).
|
||||
# use_depot: "true" when downstream jobs should use Depot runners/builders.
|
||||
# Currently forced "false" (Depot disabled repo-wide).
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -26,6 +34,9 @@ on:
|
||||
is_fork:
|
||||
description: '"true" if the trigger is an untrusted fork PR.'
|
||||
value: ${{ jobs.pick.outputs.is_fork }}
|
||||
use_depot:
|
||||
description: '"true" when downstream jobs should use Depot. Currently forced off.'
|
||||
value: ${{ jobs.pick.outputs.use_depot }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -36,6 +47,7 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
outputs:
|
||||
is_fork: ${{ steps.decide.outputs.is_fork }}
|
||||
use_depot: ${{ steps.decide.outputs.use_depot }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -50,21 +62,33 @@ jobs:
|
||||
AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
# Depot kill-switch. Depot is disabled repo-wide: no job uses Depot
|
||||
# runners or the Depot build actions while this is false. All the
|
||||
# Depot wiring is left in place - set DEPOT_ENABLED=true to switch it
|
||||
# back on (it then activates on trusted, non-fork triggers as before).
|
||||
DEPOT_ENABLED=false
|
||||
|
||||
if [ -z "${PR_NUMBER:-}" ]; then
|
||||
# Not a pull_request event at all (push, schedule, workflow_dispatch,
|
||||
# workflow_call from a non-PR trigger) -> trusted by default.
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
is_fork=false
|
||||
elif [ "${HEAD_REPO_FORK}" = "true" ]; then
|
||||
is_fork=true
|
||||
else
|
||||
case "${AUTHOR_ASSOC}" in
|
||||
OWNER|MEMBER|COLLABORATOR) is_fork=false ;;
|
||||
*) is_fork=true ;;
|
||||
esac
|
||||
fi
|
||||
if [ "${HEAD_REPO_FORK}" = "true" ]; then
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
# Depot only ever ran on trusted triggers, so gate it on both the
|
||||
# kill-switch and is_fork.
|
||||
if [ "${DEPOT_ENABLED}" = "true" ] && [ "${is_fork}" = "false" ]; then
|
||||
use_depot=true
|
||||
else
|
||||
use_depot=false
|
||||
fi
|
||||
case "${AUTHOR_ASSOC}" in
|
||||
OWNER|MEMBER|COLLABORATOR)
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT"
|
||||
echo "use_depot=${use_depot}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
build:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
strategy:
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
|
||||
# header comment. GitHub reports the skipped reusable workflow as success.
|
||||
if: needs.pick.outputs.is_fork != 'true'
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
docker-base: ${{ steps.changes.outputs.docker-base }}
|
||||
dockerfiles: ${{ steps.changes.outputs.dockerfiles }}
|
||||
tauri: ${{ steps.changes.outputs.tauri }}
|
||||
engine: ${{ steps.changes.outputs.engine }}
|
||||
generated-models: ${{ steps.changes.outputs.generated-models }}
|
||||
@@ -153,6 +154,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
|
||||
dockerfiles-changed: ${{ needs.files-changed.outputs.dockerfiles }}
|
||||
|
||||
tauri-build:
|
||||
if: needs.files-changed.outputs.tauri == 'true'
|
||||
@@ -162,6 +164,13 @@ jobs:
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/tauri-build.yml
|
||||
secrets: inherit
|
||||
# PR smoke build: Linux only (fastest + cheapest to compile), unsigned,
|
||||
# deb-only, no AppImage. The full signed multi-OS matrix runs on release;
|
||||
# nightly still warms the Rust cache with all-OS defaults.
|
||||
with:
|
||||
platform: linux
|
||||
sign: false
|
||||
minimal: true
|
||||
|
||||
ai-engine:
|
||||
if: needs.files-changed.outputs.engine == 'true'
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
check-generate-openapi-docs:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
aggregate:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
migration-test:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
distribution: temurin
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
deploy-v2-on-push:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
concurrency:
|
||||
group: deploy-v2-push-V2
|
||||
cancel-in-progress: true
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
@@ -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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
docker-compose-tests:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
playwright-e2e-live:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
playwright-e2e:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
files-changed:
|
||||
name: detect what files changed
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: [pick, files-changed]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: [pick, files-changed]
|
||||
name: Generate Backend License Report
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
frontend-validation:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
determine-matrix:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
build-jars:
|
||||
needs: [pick, determine-matrix]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
strategy:
|
||||
@@ -510,6 +510,7 @@ jobs:
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
$allSigned = $true
|
||||
@@ -531,11 +532,26 @@ jobs:
|
||||
|
||||
# Extract MSI and verify the inner exe (the file that actually gets installed).
|
||||
# This is the critical check - AV flags the installed exe at runtime.
|
||||
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
|
||||
# _MSIExecute mutex and hangs forever on hosted runners when another
|
||||
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
|
||||
$msi = $msiFiles[0].FullName
|
||||
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
|
||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
choco install lessmsi -y --no-progress --limit-output | Out-Null
|
||||
|
||||
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
|
||||
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
|
||||
if (-not $proc.WaitForExit(120000)) {
|
||||
try { $proc.Kill() } catch {}
|
||||
Write-Host "[ERROR] MSI extraction timed out after 120s"
|
||||
$allSigned = $false
|
||||
} elseif ($proc.ExitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
|
||||
if ($innerExe) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
|
||||
@@ -548,9 +564,6 @@ jobs:
|
||||
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
|
||||
$allSigned = $false
|
||||
}
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
}
|
||||
|
||||
if (-not $allSigned) {
|
||||
@@ -626,7 +639,7 @@ jobs:
|
||||
|
||||
collect-and-release:
|
||||
needs: [pick, determine-matrix, build, build-jars]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -800,7 +813,11 @@ jobs:
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
# Don't regenerate/append notes on re-runs, and don't force this into the
|
||||
# "Latest" slot - leave the release body and latest marker as they are.
|
||||
generate_release_notes: false
|
||||
append_body: false
|
||||
make_latest: false
|
||||
fail_on_unmatched_files: true
|
||||
# Installers + updater payloads + manifest. .sig contents are embedded
|
||||
# in latest.json so the .sig files themselves are not uploaded.
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
playwright-all-browsers:
|
||||
name: Playwright (chromium + firefox + webkit)
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: build-push-base
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: docker/base
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
|
||||
@@ -21,6 +21,11 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
minimal:
|
||||
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass. Used by PR builds."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
@@ -38,6 +43,11 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
minimal:
|
||||
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -386,10 +396,10 @@ jobs:
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
tauriScript: npx tauri
|
||||
# Linux: build deb+rpm only here. AppImage runs in its own
|
||||
# continue-on-error step below so its persistent linuxdeploy
|
||||
# failure (#6127 onwards) does not tank deb/rpm uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
|
||||
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
|
||||
# AppImage runs in its own continue-on-error step below so its
|
||||
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
|
||||
|
||||
- name: Build Tauri app (unsigned)
|
||||
if: ${{ !inputs.sign }}
|
||||
@@ -406,15 +416,16 @@ jobs:
|
||||
with:
|
||||
projectPath: ./frontend/editor
|
||||
tauriScript: npx tauri
|
||||
# Linux: build deb+rpm only here. AppImage runs in its own
|
||||
# continue-on-error step below so its persistent linuxdeploy
|
||||
# failure (#6127 onwards) does not tank deb/rpm uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
|
||||
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
|
||||
# AppImage runs in its own continue-on-error step below so its
|
||||
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
|
||||
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
|
||||
|
||||
# AppImage is decoupled so its linuxdeploy run gets a fresh process
|
||||
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
|
||||
# Skipped on minimal smoke builds (flaky + slow, deb is enough to verify).
|
||||
- name: Build Tauri app (Linux AppImage)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
|
||||
continue-on-error: true
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
||||
env:
|
||||
|
||||
@@ -12,6 +12,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
dockerfiles-changed:
|
||||
description: "Whether any Dockerfile changed (forwarded from files-changed). Gates the slow arm64 build leg."
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
@@ -41,12 +46,12 @@ jobs:
|
||||
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
|
||||
test-build-docker-images:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' && inputs.docker-base-changed != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -95,7 +100,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -146,13 +151,22 @@ jobs:
|
||||
# GITHUB_EVENT_NAME is already provided by the runner.
|
||||
env:
|
||||
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
|
||||
DOCKERFILES_CHANGED: ${{ inputs.dockerfiles-changed }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then
|
||||
# Base Dockerfile changed: build against the locally-built base,
|
||||
# which only exists for amd64.
|
||||
echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
elif [ "$DOCKERFILES_CHANGED" = "true" ]; then
|
||||
# A Dockerfile changed: also verify the arm64 build (slow QEMU leg).
|
||||
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# No Dockerfile change: amd64 only. arm64 is exercised on the base
|
||||
# image publish and on release, not on every code PR.
|
||||
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Base-changed PRs build the embedded image with the local docker driver
|
||||
@@ -186,7 +200,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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -214,12 +228,12 @@ jobs:
|
||||
|
||||
test-build-unoserver-image:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' && inputs.docker-base-changed != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -259,7 +273,7 @@ jobs:
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
deploy:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Build and push test image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
if: always()
|
||||
name: detect what files changed
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
test:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [pick, deploy, files-changed]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
|
||||
cleanup:
|
||||
needs: [pick, deploy, test]
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"frontend/editor/src-tauri/icons/icon.png"
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,6 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start backend dev server in proprietary mode"
|
||||
@@ -41,13 +40,12 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
|
||||
@@ -80,6 +80,12 @@ 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}}
|
||||
|
||||
@@ -197,6 +203,19 @@ tasks:
|
||||
cmds:
|
||||
- task: lint:eslint
|
||||
- task: lint:dpdm
|
||||
- task: lint:colors
|
||||
|
||||
lint:colors:
|
||||
desc: "Enforce theme tokens — colours in core/theme route through the palette"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/lint/theme-lint.mjs
|
||||
|
||||
contrast:
|
||||
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/lint/theme-lint.mjs contrast
|
||||
|
||||
lint:eslint:
|
||||
desc: "Run ESLint linting"
|
||||
@@ -243,10 +262,8 @@ tasks:
|
||||
|
||||
typecheck:_run:
|
||||
internal: true
|
||||
env:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
cmds:
|
||||
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
|
||||
- 'npx tsc --noEmit --project {{.PROJECT}}'
|
||||
|
||||
typecheck:core:
|
||||
desc: "Typecheck core build variant"
|
||||
|
||||
@@ -155,6 +155,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
|
||||
|
||||
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
|
||||
@@ -90,7 +90,6 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
|
||||
@@ -208,6 +208,18 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The W3C License"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.re2j:re2j",
|
||||
"moduleLicense": "Go License"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.hubspot:algebra",
|
||||
"moduleLicense": null
|
||||
},
|
||||
{
|
||||
"moduleName": "com.hubspot.immutables:immutables-exceptions",
|
||||
"moduleLicense": null
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "UnRar License"
|
||||
|
||||
@@ -206,11 +206,6 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Policies {
|
||||
/**
|
||||
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* Absolute directories that policy folder input sources and output sinks may read from or
|
||||
* write to. Empty (the default) disables folder access entirely, so a policy can never be
|
||||
@@ -246,6 +241,14 @@ public class ApplicationProperties {
|
||||
* and paused runs are kept regardless of age.
|
||||
*/
|
||||
private int runExpiryMinutes = 30;
|
||||
|
||||
/**
|
||||
* Whether a policy S3 source's custom endpoint may resolve to a loopback, link-local, or
|
||||
* private address. Off by default so a user-supplied endpoint cannot be pointed at internal
|
||||
* services (e.g. the cloud metadata address); enable for a self-hosted MinIO or other
|
||||
* in-network object store.
|
||||
*/
|
||||
private boolean allowPrivateS3Endpoints = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
/**
|
||||
* Thread-scoped correlation id for one automation run — a single pipeline, policy, or AI-workflow
|
||||
* execution over its input file(s).
|
||||
*
|
||||
* <p>Automations dispatch each tool step as a separate internal loopback POST via {@link
|
||||
* InternalApiClient}. The orchestrator opens a run scope around its dispatch loop; {@code
|
||||
* InternalApiClient} reads {@link #current()} and stamps it on every sub-step request as {@link
|
||||
* #RUN_ID_HEADER}. The SaaS PAYG interceptor uses that header so all sub-steps of ONE run group
|
||||
* into a single charge, while two <em>separate</em> runs that happen to touch identical bytes stay
|
||||
* distinct charges (the old content+time-window grouping merged them).
|
||||
*
|
||||
* <p>Sub-steps dispatch synchronously on the orchestrator's own thread (loopback {@code
|
||||
* RestTemplate}), so this ThreadLocal is visible to {@code InternalApiClient}. The id then crosses
|
||||
* to the receiving request thread via the HTTP header — never via this ThreadLocal.
|
||||
*
|
||||
* <p>No-op when the id is absent (a standalone tool call): the interceptor treats a missing run id
|
||||
* as "its own charge", which is exactly what a one-off call should be.
|
||||
*/
|
||||
public final class AutomationRunContext {
|
||||
|
||||
/** Header carrying the run id on internal sub-step dispatches. */
|
||||
public static final String RUN_ID_HEADER = "X-Stirling-Run-Id";
|
||||
|
||||
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
|
||||
|
||||
private AutomationRunContext() {}
|
||||
|
||||
/**
|
||||
* Opens a run scope on the current thread. Returns an {@link AutoCloseable} that restores the
|
||||
* previously-active id (nesting-safe) — use in try-with-resources around the dispatch loop.
|
||||
*/
|
||||
public static Scope open(String runId) {
|
||||
String previous = CURRENT.get();
|
||||
CURRENT.set(runId);
|
||||
return () -> {
|
||||
if (previous == null) {
|
||||
CURRENT.remove();
|
||||
} else {
|
||||
CURRENT.set(previous);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** The run id active on this thread, or {@code null} when not inside a run scope. */
|
||||
public static String current() {
|
||||
return CURRENT.get();
|
||||
}
|
||||
|
||||
/** AutoCloseable whose {@link #close()} declares no checked exception. */
|
||||
public interface Scope extends AutoCloseable {
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -60,6 +61,17 @@ 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;
|
||||
@@ -111,6 +123,27 @@ public class InternalApiClient {
|
||||
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
|
||||
// every caller of this dispatcher is an automation surface by design.
|
||||
headers.add(AUTOMATION_HEADER, "true");
|
||||
// Propagate the current automation run id (set by the orchestrator around its dispatch
|
||||
// loop) so the PAYG interceptor groups every sub-step of this one run into a single charge,
|
||||
// and never merges two separate runs that happen to touch identical bytes. Absent → the
|
||||
// receiving call is treated as standalone. See AutomationRunContext.
|
||||
String runId = AutomationRunContext.current();
|
||||
if (runId != null && !runId.isEmpty()) {
|
||||
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
|
||||
|
||||
@@ -144,8 +144,10 @@ public class TempFileCleanupService {
|
||||
int directoriesDeletedCount = 0;
|
||||
for (Path directory : registry.getTempDirectories()) {
|
||||
try {
|
||||
if (Files.exists(directory)) {
|
||||
if (Files.exists(directory)
|
||||
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
directoriesDeletedCount++;
|
||||
log.debug("Cleaned up temporary directory: {}", directory);
|
||||
}
|
||||
@@ -275,6 +277,21 @@ 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 =
|
||||
|
||||
@@ -155,6 +155,7 @@ 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,6 +85,18 @@ 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,11 +176,13 @@ class TempFileCleanupServiceMoreTest {
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
@DisplayName("deletes stale 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);
|
||||
@@ -193,6 +195,22 @@ 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() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -27,6 +28,7 @@ import stirling.software.SPDF.model.PipelineConfig;
|
||||
import stirling.software.SPDF.model.PipelineOperation;
|
||||
import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.service.AutomationRunContext;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.ZipExtractionUtils;
|
||||
@@ -71,6 +73,17 @@ public class PipelineProcessor {
|
||||
|
||||
PipelineResult runPipelineAgainstFiles(List<Resource> outputFiles, PipelineConfig config)
|
||||
throws Exception {
|
||||
// One pipeline execution = one automation run. Scope a run id so every tool sub-step
|
||||
// dispatched via InternalApiClient groups into a single charge on the SaaS billing side
|
||||
// (see AutomationRunContext); pipeline steps run synchronously on this thread.
|
||||
try (AutomationRunContext.Scope ignored =
|
||||
AutomationRunContext.open(UUID.randomUUID().toString())) {
|
||||
return runPipelineAgainstFilesInternal(outputFiles, config);
|
||||
}
|
||||
}
|
||||
|
||||
private PipelineResult runPipelineAgainstFilesInternal(
|
||||
List<Resource> outputFiles, PipelineConfig config) throws Exception {
|
||||
PipelineResult result = new PipelineResult();
|
||||
|
||||
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
|
||||
|
||||
@@ -136,11 +136,17 @@ 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[] listOfText = request.getListOfText().split("\n");
|
||||
String rawListOfText = request.getListOfText();
|
||||
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
|
||||
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
|
||||
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()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -1,18 +1 @@
|
||||
<?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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,13 +1 @@
|
||||
<?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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -299,6 +299,18 @@ 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() {
|
||||
|
||||
@@ -66,6 +66,20 @@ dependencies {
|
||||
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
|
||||
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
|
||||
// version directly so it is selected consistently (root build.gradle pins are the fallback).
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
|
||||
|
||||
implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
|
||||
// Compile-time-only annotation artifacts (class-retention annotations, not needed at
|
||||
// runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
|
||||
exclude group: 'com.google.code.findbugs', module: 'annotations'
|
||||
exclude group: 'org.derive4j', module: 'derive4j-annotation'
|
||||
exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
|
||||
exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
|
||||
}
|
||||
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
|
||||
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
|
||||
|
||||
@@ -35,6 +35,7 @@ import stirling.software.proprietary.billing.ContentHasher;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
@@ -84,7 +85,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
instanceof ApiKeyAuthenticationToken;
|
||||
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
|
||||
request.setAttribute(ATTR_CATEGORY, category);
|
||||
decision = gate.evaluate(category != BillingCategory.BYPASSED);
|
||||
// A policy run kicks off billable automation, so block it up front when unentitled
|
||||
// rather than after its first tool. It carries no automation header itself (category
|
||||
// BYPASSED), so it's gated here but metered only via its dispatched sub-steps - keeping
|
||||
// the BYPASSED meter category avoids double-counting.
|
||||
boolean billable =
|
||||
category != BillingCategory.BYPASSED || PolicyRunRoutes.matches(request);
|
||||
decision = gate.evaluate(billable);
|
||||
} catch (RuntimeException e) {
|
||||
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
|
||||
// turn into a hard block on billable work.
|
||||
|
||||
@@ -142,6 +142,9 @@ 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;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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,6 +205,10 @@ 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(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a
|
||||
* fixed, built-in list bundled with the application ({@code
|
||||
* classification/classification-labels.json}) and shared by everyone — there is no per-team
|
||||
* customization or database. Loaded once at startup.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ClassificationLabelProvider {
|
||||
|
||||
private static final String RESOURCE = "classification/classification-labels.json";
|
||||
|
||||
private final List<ClassificationLabel> labels;
|
||||
|
||||
// Explicit @Autowired: the class has a second (private) constructor for tests, so Spring
|
||||
// can't infer which to use without it.
|
||||
@Autowired
|
||||
public ClassificationLabelProvider(ObjectMapper objectMapper) {
|
||||
this(load(objectMapper));
|
||||
}
|
||||
|
||||
private ClassificationLabelProvider(List<ClassificationLabel> labels) {
|
||||
this.labels = List.copyOf(labels);
|
||||
}
|
||||
|
||||
/** Build a provider with an explicit label set (tests). */
|
||||
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
|
||||
return new ClassificationLabelProvider(labels);
|
||||
}
|
||||
|
||||
/** The built-in vocabulary, in file order. */
|
||||
public List<ClassificationLabel> labels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
private static List<ClassificationLabel> load(ObjectMapper objectMapper) {
|
||||
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
|
||||
ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class);
|
||||
return parsed.labels();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load classification labels from {}", RESOURCE, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
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.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.model.LabelsValidator;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
|
||||
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
|
||||
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
|
||||
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
|
||||
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
|
||||
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
|
||||
* default on the backend or the engine — the label data lives only in the frontend).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/classification/labels")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class ClassificationLabelsController {
|
||||
|
||||
private final ClassificationLabelStore labelStore;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the team's classification labels",
|
||||
description =
|
||||
"Returns the caller's team label set, or 204 when the team has none (its"
|
||||
+ " documents are then not classified).")
|
||||
public ResponseEntity<ClassificationLabels> getTeamLabels() {
|
||||
return labelStore
|
||||
.findByTeam(currentTeamId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Save the team's classification labels",
|
||||
description =
|
||||
"Validates and stores the label set for the caller's team, shared by everyone"
|
||||
+ " on the team. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<ClassificationLabels> saveTeamLabels(
|
||||
@RequestBody ClassificationLabels labels) {
|
||||
requireEditingAllowed();
|
||||
validate(labels);
|
||||
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Reset the team's classification labels",
|
||||
description =
|
||||
"Removes the team's stored label set; its documents are then not classified"
|
||||
+ " until labels are saved again. Requires the policy-editor role for the"
|
||||
+ " team.")
|
||||
public ResponseEntity<Void> resetTeamLabels() {
|
||||
requireEditingAllowed();
|
||||
labelStore.deleteByTeam(currentTeamId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static void validate(ClassificationLabels labels) {
|
||||
try {
|
||||
LabelsValidator.validate(labels);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the team labels requires the editor role for the caller's team — the same gate
|
||||
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
|
||||
* disabled) have no such role, so they trust the local operator.
|
||||
*/
|
||||
private void requireEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"The team classification labels may only be changed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's team key. With login disabled the single operator owns the {@link
|
||||
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
|
||||
* is an error rather than being dropped into the shared sentinel bucket (which would let
|
||||
* unteamed users read and overwrite each other's "team" labels).
|
||||
*/
|
||||
private Long currentTeamId() {
|
||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||
if (teamId != null) {
|
||||
return teamId;
|
||||
}
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return TeamLabelsEntity.NO_TEAM;
|
||||
}
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
return userService == null ? null : userService.getCurrentUsername();
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
|
||||
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
|
||||
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
|
||||
* neither the backend nor the engine holds a default of its own.
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned. The
|
||||
* classifier runs against these label names. The vocabulary is a fixed, built-in set shared by
|
||||
* everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider});
|
||||
* this record is the JSON parse target for that bundled resource.
|
||||
*/
|
||||
public record ClassificationLabels(List<ClassificationLabel> labels) {
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
|
||||
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
|
||||
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
|
||||
*/
|
||||
public final class LabelsValidator {
|
||||
|
||||
private LabelsValidator() {}
|
||||
|
||||
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
|
||||
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
|
||||
// exhaust memory on deserialize.
|
||||
static final int MAX_LABELS = 500;
|
||||
static final int MAX_TEXT_LENGTH = 128;
|
||||
|
||||
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
|
||||
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
|
||||
// arbitrary
|
||||
// text that would render as garbage (or worse) in every teammate's sidebar.
|
||||
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
|
||||
*/
|
||||
public static void validate(ClassificationLabels labels) {
|
||||
if (labels == null || labels.labels() == null) {
|
||||
throw new IllegalArgumentException("Labels are required");
|
||||
}
|
||||
if (labels.labels().size() > MAX_LABELS) {
|
||||
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ClassificationLabel label : labels.labels()) {
|
||||
requireText(label.id(), "Label id");
|
||||
requireText(label.name(), "Label name");
|
||||
if (label.icon() != null && !label.icon().isEmpty()) {
|
||||
if (label.icon().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
if (!ICON_KEY.matcher(label.icon()).matches()) {
|
||||
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
|
||||
}
|
||||
}
|
||||
if (!ids.add(label.id().trim())) {
|
||||
throw new IllegalArgumentException("Duplicate label id: " + label.id());
|
||||
}
|
||||
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException("Duplicate label name: " + label.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
if (value.trim().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
|
||||
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
|
||||
* team.
|
||||
*/
|
||||
public interface ClassificationLabelStore {
|
||||
|
||||
/** The team's stored labels, or empty when it has none (callers then skip classification). */
|
||||
Optional<ClassificationLabels> findByTeam(Long teamId);
|
||||
|
||||
/** Create or replace the team's labels. Returns the stored value. */
|
||||
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
|
||||
|
||||
/** Remove the team's labels (reset to default). Returns whether a set existed. */
|
||||
boolean deleteByTeam(Long teamId);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
|
||||
* JpaClassificationLabelStore} is the runtime bean.
|
||||
*/
|
||||
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return Optional.ofNullable(byTeam.get(key(teamId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
byTeam.put(key(teamId), labels);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
return byTeam.remove(key(teamId)) != null;
|
||||
}
|
||||
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
|
||||
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
|
||||
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
|
||||
* TeamLabelsEntity}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final TeamLabelsRepository teamRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return teamRepository
|
||||
.findById(key(teamId))
|
||||
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
TeamLabelsEntity entity = new TeamLabelsEntity();
|
||||
entity.setTeamId(key(teamId));
|
||||
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
|
||||
entity.setUpdatedAt(Instant.now());
|
||||
entity.setUpdatedBy(updatedBy);
|
||||
teamRepository.save(entity);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
long id = key(teamId);
|
||||
if (!teamRepository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
teamRepository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Optional<ClassificationLabels> parse(String json, String owner) {
|
||||
try {
|
||||
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
|
||||
} catch (JacksonException e) {
|
||||
// A stored label set that no longer parses (corruption / manual DB edit) must not break
|
||||
// classification: drop it so the caller treats the team as having no labels (and skips
|
||||
// classification) rather than surfacing a 500 on every upload.
|
||||
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a team's classification labels — one row per team. The label set lives as JSON in
|
||||
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
|
||||
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
|
||||
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
|
||||
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
|
||||
* not a foreign key — so classification can be enabled or disabled without touching them.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "classification_labels")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class TeamLabelsEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
|
||||
public static final long NO_TEAM = 0L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private long teamId;
|
||||
|
||||
@Column(name = "labels_json", columnDefinition = "text")
|
||||
private String labelsJson;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "updated_by")
|
||||
private String updatedBy;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
|
||||
@@ -133,29 +133,45 @@ public final class S3Clients {
|
||||
* storage.s3.allow-private-endpoints=true}.
|
||||
*/
|
||||
static void validateEndpointHost(URI endpoint, boolean allowPrivate) {
|
||||
validateEndpointHost(
|
||||
endpoint,
|
||||
allowPrivate,
|
||||
"storage.s3.endpoint",
|
||||
"set storage.s3.allow-private-endpoints=true to opt in"
|
||||
+ " (e.g. for MinIO or in-cluster S3).");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same private-address guard for S3 endpoints configured outside the {@code storage.s3.*}
|
||||
* block (e.g. per-source policy config), with the setting named in messages supplied by the
|
||||
* caller.
|
||||
*/
|
||||
public static void validateEndpointHost(
|
||||
URI endpoint, boolean allowPrivate, String settingName, String optInHint) {
|
||||
if (allowPrivate) {
|
||||
return;
|
||||
}
|
||||
String host = endpoint.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
throw new IllegalStateException("storage.s3.endpoint must include a host: " + endpoint);
|
||||
throw new IllegalStateException(settingName + " must include a host: " + endpoint);
|
||||
}
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalStateException(
|
||||
"Unable to resolve storage.s3.endpoint host '" + host + "'", e);
|
||||
"Unable to resolve " + settingName + " host '" + host + "'", e);
|
||||
}
|
||||
for (InetAddress address : addresses) {
|
||||
if (isPrivateOrLocal(address)) {
|
||||
throw new IllegalStateException(
|
||||
"storage.s3.endpoint host '"
|
||||
settingName
|
||||
+ " host '"
|
||||
+ host
|
||||
+ "' resolves to private/link-local address "
|
||||
+ address.getHostAddress()
|
||||
+ "; set storage.s3.allow-private-endpoints=true to opt in"
|
||||
+ " (e.g. for MinIO or in-cluster S3).");
|
||||
+ "; "
|
||||
+ optInHint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,9 @@ import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
@@ -46,7 +45,7 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
|
||||
*
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document against the caller's team label set, and stores the engine's JSON
|
||||
* engine to classify the document against the built-in label set, and stores the engine's JSON
|
||||
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
|
||||
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
|
||||
* client use.
|
||||
@@ -72,13 +71,9 @@ public class ClassifyLabelController {
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
|
||||
* team authority are gated on it. Null otherwise, in which case there are no team labels to
|
||||
* classify against and the document is passed through unlabelled.
|
||||
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
|
||||
*/
|
||||
private final ClassificationLabelStore labelStore;
|
||||
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ClassificationLabelProvider labelProvider;
|
||||
|
||||
public ClassifyLabelController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@@ -87,18 +82,16 @@ public class ClassifyLabelController {
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
ObjectMapper objectMapper,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) ClassificationLabelStore labelStore,
|
||||
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
|
||||
ClassificationLabelProvider labelProvider,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.labelProvider = labelProvider;
|
||||
this.userService = userService;
|
||||
this.labelStore = labelStore;
|
||||
this.policyManagementAuthority = policyManagementAuthority;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -116,9 +109,9 @@ public class ClassifyLabelController {
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// No vocabulary to classify against (the team stored no labels): pass the file
|
||||
// through unlabelled rather than ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
|
||||
// No vocabulary to classify against: pass the file through unlabelled rather than
|
||||
// ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no labels; skipping", fileName);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
@@ -175,24 +168,13 @@ public class ClassifyLabelController {
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
|
||||
* The engine shows the model the names and returns the ids (icons are presentational and never
|
||||
* sent). Returns an empty list — the caller then skips classification — when the policy
|
||||
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
|
||||
* default vocabulary of its own, so a team's stored labels are the only source.
|
||||
* The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows
|
||||
* the model the names and returns the ids (icons are presentational and never sent). The engine
|
||||
* holds no default vocabulary of its own, so this bundled set is the only source.
|
||||
*/
|
||||
private List<EngineLabel> resolveAllowedLabels() {
|
||||
if (labelStore == null) {
|
||||
return List.of();
|
||||
}
|
||||
Long teamId =
|
||||
policyManagementAuthority == null
|
||||
? null
|
||||
: policyManagementAuthority.currentUserTeamId();
|
||||
|
||||
Map<String, EngineLabel> byId = new LinkedHashMap<>();
|
||||
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
|
||||
|
||||
collectLabels(labelProvider.labels(), byId);
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
@@ -24,18 +26,24 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
|
||||
* Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint.
|
||||
*
|
||||
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
|
||||
* template so sanitization is intentionally skipped.
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as
|
||||
* structured fields; the HTML is built here from a fixed template.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@@ -48,6 +56,9 @@ public class CreatePdfAgentController {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiDocumentHtmlRenderer htmlRenderer;
|
||||
|
||||
/**
|
||||
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||
@@ -74,32 +85,42 @@ public class CreatePdfAgentController {
|
||||
value = "/create-pdf-from-html-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert AI-generated HTML to a PDF",
|
||||
summary = "Convert an AI-generated document to a PDF",
|
||||
description =
|
||||
"Accepts an HTML document as a plain-text parameter and returns a PDF."
|
||||
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
|
||||
+ " plan step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdfFromHtml(
|
||||
@RequestParam("htmlContent") String htmlContent,
|
||||
@RequestParam("filename") String filename)
|
||||
"Accepts a structured document as a JSON parameter and returns a PDF. This"
|
||||
+ " endpoint is dispatched by the AI workflow orchestrator as a plan"
|
||||
+ " step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdf(
|
||||
@RequestParam("document") String document, @RequestParam("filename") String filename)
|
||||
throws Exception {
|
||||
|
||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
AiDocument model;
|
||||
try {
|
||||
model = objectMapper.readValue(document, AiDocument.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
String html = htmlRenderer.render(model);
|
||||
|
||||
log.info(
|
||||
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
|
||||
htmlContent.length());
|
||||
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
|
||||
html.length());
|
||||
|
||||
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
|
||||
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
|
||||
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getWeasyPrintPath());
|
||||
command.add("-e");
|
||||
command.add("utf-8");
|
||||
command.add("-v");
|
||||
// SSRF: the HTML is self-contained and the engine validates style colours, so no
|
||||
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
|
||||
command.add(htmlFile.getAbsolutePath());
|
||||
command.add(pdfFile.getAbsolutePath());
|
||||
|
||||
@@ -126,8 +147,8 @@ public class CreatePdfAgentController {
|
||||
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
|
||||
// which matters for large generated documents.
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
document.save(tempOut.getPath().toFile());
|
||||
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
pdDocument.save(tempOut.getPath().toFile());
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -38,6 +39,9 @@ import stirling.software.proprietary.security.database.repository.UserRepository
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
// Self-hosted only: counts are server-wide. On SaaS this endpoint is owned by the team-scoped
|
||||
// SaasFleetUsageController (@Profile("saas")) so one backend can't leak another tenant's usage.
|
||||
@Profile("!saas")
|
||||
public class FleetUsageController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
/**
|
||||
* For columns that were once whole-blob encrypted but no longer hold secrets.
|
||||
*
|
||||
* <p>Writes plaintext, so the value is readable by any instance regardless of the per-installation
|
||||
* encryption key. Any value that isn't our ciphertext (already-plaintext JSON, or ciphertext from a
|
||||
* key we don't hold) is returned as-is; the latter is the caller's to reject.
|
||||
*/
|
||||
@Converter
|
||||
public class LegacyDecryptStringConverter implements AttributeConverter<String, String> {
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(String attribute) {
|
||||
return attribute;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToEntityAttribute(String dbData) {
|
||||
// Plaintext JSON can never be our Base64 ciphertext ('{' is not in the Base64 alphabet), so
|
||||
// skip the decrypt attempt for it.
|
||||
if (dbData == null || dbData.stripLeading().startsWith("{")) {
|
||||
return dbData;
|
||||
}
|
||||
try {
|
||||
return CredentialEncryption.decrypt(dbData);
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
// Legacy ciphertext we can't read (key we don't hold) - the caller's to reject.
|
||||
return dbData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ 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 ----
|
||||
|
||||
@@ -66,13 +70,21 @@ 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,
|
||||
request.ownerTeamId(),
|
||||
ownerTeamId,
|
||||
currentUser,
|
||||
() -> lockedServerExists(cfg.getIntegrationType()));
|
||||
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
|
||||
Map<String, Object> config = secretMasker.sanitize(request.config());
|
||||
validateConfig(cfg.getIntegrationType(), config);
|
||||
cfg.setConfig(writeJson(config));
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@@ -101,8 +113,10 @@ public class IntegrationConfigService {
|
||||
cfg.setDefaultAccess(request.defaultAccess());
|
||||
}
|
||||
if (request.config() != null) {
|
||||
cfg.setConfig(
|
||||
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
|
||||
Map<String, Object> merged =
|
||||
secretMasker.merge(readJson(cfg.getConfig()), request.config());
|
||||
validateConfig(cfg.getIntegrationType(), merged);
|
||||
cfg.setConfig(writeJson(merged));
|
||||
}
|
||||
return repository.save(cfg);
|
||||
}
|
||||
@@ -113,6 +127,15 @@ 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);
|
||||
@@ -188,6 +211,19 @@ 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()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package stirling.software.proprietary.model.api.ai.create;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AiDocument {
|
||||
|
||||
private String title;
|
||||
private String subtitle;
|
||||
private String referenceNumber;
|
||||
private Style style;
|
||||
private List<Section> sections;
|
||||
|
||||
@Data
|
||||
public static class Style {
|
||||
private String primaryColor;
|
||||
private String backgroundColor;
|
||||
private String bodyTextColor;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Section {
|
||||
private String type;
|
||||
private String heading;
|
||||
private String body;
|
||||
private List<List<String>> pairs;
|
||||
private List<String> columns;
|
||||
private List<List<String>> rows;
|
||||
private List<String> totalRow;
|
||||
private List<String> items;
|
||||
private List<String> signatories;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ 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;
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -30,7 +29,6 @@ import stirling.software.proprietary.policy.source.SourceStore;
|
||||
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class FolderAccessGuard {
|
||||
|
||||
public static final String FOLDER_TYPE = "folder";
|
||||
|
||||
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.config;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyAccessGuard {
|
||||
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@@ -7,7 +7,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -23,6 +22,8 @@ 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;
|
||||
@@ -32,6 +33,7 @@ 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;
|
||||
@@ -42,14 +44,18 @@ 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;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||
import stirling.software.proprietary.policy.engine.PolicyValidator;
|
||||
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;
|
||||
@@ -58,12 +64,15 @@ import stirling.software.proprietary.policy.model.PolicyRunView;
|
||||
import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
|
||||
import stirling.software.proprietary.policy.overview.PolicyOverviewService;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.EditorSource;
|
||||
import stirling.software.proprietary.policy.source.SourceAccessGuard;
|
||||
import stirling.software.proprietary.policy.source.SourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.policy.trigger.TriggerInfo;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
|
||||
@@ -75,7 +84,6 @@ import stirling.software.proprietary.policy.trigger.TriggerInfo;
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyController {
|
||||
|
||||
private final PolicyRunner policyRunner;
|
||||
@@ -83,6 +91,7 @@ public class PolicyController {
|
||||
private final PolicyStore policyStore;
|
||||
private final SourceStore sourceStore;
|
||||
private final SourceAccessGuard sourceAccessGuard;
|
||||
private final SourceDocCounter docCounter;
|
||||
private final PolicyValidator policyValidator;
|
||||
private final PolicyAccessGuard policyAccessGuard;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
@@ -107,11 +116,14 @@ public class PolicyController {
|
||||
@RequestPart("json") PipelineDefinition definition,
|
||||
@Valid @ModelAttribute PolicyRunFiles files)
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
String runId =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
PolicyRunHandle handle =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
|
||||
recordEditorDocs(inputs);
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, handle.runId(), null));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -125,7 +137,9 @@ 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 =
|
||||
@@ -133,6 +147,7 @@ public class PolicyController {
|
||||
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
|
||||
|
||||
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
|
||||
recordEditorDocs(inputs);
|
||||
// whenComplete runs on the worker thread after the run finishes, so the terminal event
|
||||
// never races the step events.
|
||||
handle.completion()
|
||||
@@ -202,7 +217,7 @@ public class PolicyController {
|
||||
+ " assigned; returns the stored policy with its id.")
|
||||
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
|
||||
requirePolicyEditingAllowed();
|
||||
Policy owned = resolveOwnership(policy);
|
||||
Policy owned = withStoredOutputSecrets(resolveOwnership(policy));
|
||||
requireAccessibleSources(owned);
|
||||
try {
|
||||
policyValidator.validate(owned);
|
||||
@@ -213,7 +228,7 @@ public class PolicyController {
|
||||
// Re-sync trigger registrations now so a new/changed folder-watch policy starts being
|
||||
// watched immediately instead of after the next reconcile sweep.
|
||||
policyTriggerManager.notifyPoliciesChanged();
|
||||
return ResponseEntity.ok(saved);
|
||||
return ResponseEntity.ok(withMaskedOutputSecrets(saved));
|
||||
}
|
||||
|
||||
@PutMapping("/order")
|
||||
@@ -282,6 +297,50 @@ public class PolicyController {
|
||||
teamId);
|
||||
}
|
||||
|
||||
/** Output secrets never leave the server: reads return the redaction sentinel instead. */
|
||||
private static Policy withMaskedOutputSecrets(Policy policy) {
|
||||
return withOutput(
|
||||
policy,
|
||||
new OutputSpec(
|
||||
policy.output().type(), SecretMasker.mask(policy.output().options())));
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit that round-trips a masked read sends output secrets back as the sentinel; restore
|
||||
* them from the stored policy so saving without re-typing keeps them (validation then runs
|
||||
* against the real values).
|
||||
*/
|
||||
private Policy withStoredOutputSecrets(Policy incoming) {
|
||||
if (incoming.id() == null || incoming.id().isBlank()) {
|
||||
return incoming;
|
||||
}
|
||||
return policyStore
|
||||
.get(incoming.id())
|
||||
.map(
|
||||
existing ->
|
||||
withOutput(
|
||||
incoming,
|
||||
new OutputSpec(
|
||||
incoming.output().type(),
|
||||
SecretMasker.restoreRedacted(
|
||||
incoming.output().options(),
|
||||
existing.output().options()))))
|
||||
.orElse(incoming);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating, editing, pausing/resuming, and deleting policies requires the editor role for the
|
||||
* caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global
|
||||
@@ -306,9 +365,14 @@ public class PolicyController {
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List policies",
|
||||
description = "Lists the policies belonging to the caller's team.")
|
||||
description =
|
||||
"Lists the policies belonging to the caller's team. Secret-bearing output"
|
||||
+ " options are returned as a redaction sentinel, never their stored"
|
||||
+ " values.")
|
||||
public List<Policy> listPolicies() {
|
||||
return policyAccessGuard.visibleFrom(policyStore);
|
||||
return policyAccessGuard.visibleFrom(policyStore).stream()
|
||||
.map(PolicyController::withMaskedOutputSecrets)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
@@ -337,11 +401,17 @@ public class PolicyController {
|
||||
}
|
||||
|
||||
@GetMapping("/{policyId}")
|
||||
@Operation(summary = "Get a policy by id")
|
||||
@Operation(
|
||||
summary = "Get a policy by id",
|
||||
description =
|
||||
"Secret-bearing output options are returned as a redaction sentinel, never"
|
||||
+ " their stored values; an edit that sends the sentinel back keeps"
|
||||
+ " them.")
|
||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||
return policyStore
|
||||
.get(policyId)
|
||||
.filter(policyAccessGuard::canAccess)
|
||||
.map(PolicyController::withMaskedOutputSecrets)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
@@ -401,6 +471,7 @@ 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));
|
||||
@@ -412,9 +483,10 @@ public class PolicyController {
|
||||
description =
|
||||
"Pulls the policy's configured sources and runs the pipeline now, regardless of"
|
||||
+ " the enabled flag (which only gates automatic triggering). Returns"
|
||||
+ " the ids of the runs started; poll the run-status endpoint for each."
|
||||
+ " Empty when the sources yielded no work to do.")
|
||||
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
|
||||
+ " the ids of the runs started (poll the run-status endpoint for each)"
|
||||
+ " plus what the sweep skipped - already-processed, parked-by-failure,"
|
||||
+ " and in-flight counts - so an empty result explains itself.")
|
||||
public ResponseEntity<SweepOutcome> trigger(@PathVariable String policyId) {
|
||||
Policy policy =
|
||||
policyStore
|
||||
.get(policyId)
|
||||
@@ -433,6 +505,60 @@ 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
|
||||
* team. A run with no primary documents (generator pipeline) records nothing.
|
||||
*/
|
||||
private void recordEditorDocs(PolicyInputs inputs) {
|
||||
docCounter.record(
|
||||
EditorSource.counterKey(sourceAccessGuard.currentTeamId()),
|
||||
inputs.primary().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the
|
||||
* named supporting-file store, where each asset's {@code key} is the name a step references
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Policy execute-route namespace under {@code /api/v1/policies} - the paths that actually run an
|
||||
* automation ({@code /run}, {@code /run/stream}, {@code /{id}/run}, {@code /{id}/trigger}).
|
||||
*
|
||||
* <p>Single source of truth for both PAYG entitlement gates, so a caller without billing is blocked
|
||||
* at the start of a run rather than partway through: the saas {@code EntitlementGuard} gates these
|
||||
* on {@code FeatureGate.AUTOMATION}, and the self-hosted account-link {@code
|
||||
* InstanceEntitlementInterceptor} treats them as billable. Read/list policy endpoints are
|
||||
* deliberately excluded so the UI can still show policies and prompt on use.
|
||||
*
|
||||
* <p>This is the sole gate between an unentitled caller and a billable run, so the match is exact
|
||||
* (not a loose suffix) and segment-anchored. {@code PolicyRunRoutesTest} asserts it against every
|
||||
* mapping on {@code PolicyController}, so a new execute route that isn't classified here fails the
|
||||
* build rather than silently running for free.
|
||||
*/
|
||||
public final class PolicyRunRoutes {
|
||||
|
||||
private static final String BASE = "/api/v1/policies";
|
||||
|
||||
private PolicyRunRoutes() {}
|
||||
|
||||
/**
|
||||
* True when the request resolved to a policy execute endpoint. Prefers the matched route
|
||||
* pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI.
|
||||
*/
|
||||
public static boolean matches(HttpServletRequest request) {
|
||||
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||
String path = pattern instanceof String s ? s : request.getRequestURI();
|
||||
String rel = relativeToBase(path);
|
||||
return rel != null && isExecuteRoute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path relative to {@code /api/v1/policies}, or null if the request isn't under that base.
|
||||
* Segment-anchored (the char after the base must be {@code /} or end-of-string) so a sibling
|
||||
* like {@code /api/v1/policies-x/...} never matches; tolerates a leading context path.
|
||||
*/
|
||||
private static String relativeToBase(String path) {
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
int base = path.indexOf(BASE);
|
||||
if (base < 0) {
|
||||
return null;
|
||||
}
|
||||
int end = base + BASE.length();
|
||||
if (end < path.length() && path.charAt(end) != '/') {
|
||||
return null;
|
||||
}
|
||||
return path.substring(end);
|
||||
}
|
||||
|
||||
/**
|
||||
* The execute routes only: {@code /run}, {@code /run/stream}, and the single-segment {@code
|
||||
* /{id}/run} / {@code /{id}/trigger} (template or concrete id). Read/list/CRUD routes - {@code
|
||||
* /run/{runId}}, {@code /runs}, {@code /overview}, {@code /triggers}, {@code /{id}}, {@code
|
||||
* /order}, {@code /{id}/processed-history}, the base list/create - are all excluded.
|
||||
*/
|
||||
private static boolean isExecuteRoute(String rel) {
|
||||
return rel.equals("/run")
|
||||
|| rel.equals("/run/stream")
|
||||
|| isSingleIdRoute(rel, "run")
|
||||
|| isSingleIdRoute(rel, "trigger");
|
||||
}
|
||||
|
||||
/**
|
||||
* True for exactly {@code /{oneSegment}/<verb>} (the id being a template or a concrete value).
|
||||
*/
|
||||
private static boolean isSingleIdRoute(String rel, String verb) {
|
||||
String suffix = "/" + verb;
|
||||
if (!rel.endsWith(suffix)) {
|
||||
return false;
|
||||
}
|
||||
String idSegment = rel.substring(0, rel.length() - suffix.length());
|
||||
return idSegment.length() > 1
|
||||
&& idSegment.charAt(0) == '/'
|
||||
&& idSegment.indexOf('/', 1) < 0;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -21,7 +20,9 @@ import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
@@ -55,7 +56,6 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyEngine {
|
||||
|
||||
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
|
||||
@@ -148,6 +148,7 @@ 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
|
||||
@@ -199,63 +200,78 @@ public class PolicyEngine {
|
||||
PolicyProgressListener listener,
|
||||
CompletableFuture<PolicyRun> completion) {
|
||||
String runId = run.getRunId();
|
||||
try {
|
||||
run.markRunning();
|
||||
PolicyExecutionResult result =
|
||||
stepExecutor.execute(run.getDefinition(), inputs, listener);
|
||||
OutputSpec output = run.getDefinition().output();
|
||||
List<ResultFile> outputs =
|
||||
sinkFor(output)
|
||||
.deliver(
|
||||
new OutputDelivery(runId, run.getPolicyId()),
|
||||
result.files(),
|
||||
output);
|
||||
taskManager.setMultipleFileResults(runId, outputs);
|
||||
taskManager.setComplete(runId);
|
||||
run.complete(outputs);
|
||||
} catch (PolicyInputRequiredException e) {
|
||||
// Expected path: suspend rather than fail. Persist intermediates as fileIds so the run
|
||||
// can resume after this worker thread is gone.
|
||||
WaitState wait = suspend(e);
|
||||
run.waitForInput(wait);
|
||||
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
String message = toolTimeoutMessage(e);
|
||||
log.error(
|
||||
"Policy run {} timed out on {}: {}",
|
||||
runId,
|
||||
e.getEndpointPath(),
|
||||
e.getMessage());
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} catch (RestClientResponseException e) {
|
||||
// A downstream tool call returned an error status. When it's a structured entitlement
|
||||
// response (401/402 with a JSON `error` sentinel), surface that code onto the run so
|
||||
// the
|
||||
// client can react — e.g. pop the usage-limit modal — instead of only seeing a generic
|
||||
// failure. We don't interpret the code here (that would couple this module to the saas
|
||||
// billing layer); we just pass it through for the client to map. Other statuses fall
|
||||
// through to the generic failure below.
|
||||
String code = DownstreamEntitlementError.extractCode(e);
|
||||
if (code != null) {
|
||||
log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code);
|
||||
String message = "Usage limit reached";
|
||||
run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e));
|
||||
taskManager.setError(runId, message);
|
||||
} else {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
|
||||
// One policy run = one automation run. Scope the run id on this worker thread (the async
|
||||
// hop already happened) so every tool sub-step dispatched via InternalApiClient groups into
|
||||
// a single charge, and two separate policy runs on the same document stay distinct charges.
|
||||
try (AutomationRunContext.Scope runScope = AutomationRunContext.open(runId)) {
|
||||
try {
|
||||
run.markRunning();
|
||||
PolicyExecutionResult result =
|
||||
stepExecutor.execute(run.getDefinition(), inputs, listener);
|
||||
OutputSpec output = run.getDefinition().output();
|
||||
List<ResultFile> outputs =
|
||||
sinkFor(output)
|
||||
.deliver(
|
||||
new OutputDelivery(runId, run.getPolicyId()),
|
||||
result.files(),
|
||||
output);
|
||||
taskManager.setMultipleFileResults(runId, outputs);
|
||||
taskManager.setComplete(runId);
|
||||
run.complete(outputs);
|
||||
} catch (PolicyInputRequiredException e) {
|
||||
// Expected path: suspend rather than fail. Persist intermediates as fileIds so the
|
||||
// run
|
||||
// can resume after this worker thread is gone.
|
||||
WaitState wait = suspend(e);
|
||||
run.waitForInput(wait);
|
||||
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
String message = toolTimeoutMessage(e);
|
||||
log.error(
|
||||
"Policy run {} timed out on {}: {}",
|
||||
runId,
|
||||
e.getEndpointPath(),
|
||||
e.getMessage());
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} catch (RestClientResponseException e) {
|
||||
// A downstream tool call returned an error status. When it's a structured
|
||||
// entitlement
|
||||
// response (401/402 with a JSON `error` sentinel), surface that code onto the run
|
||||
// so
|
||||
// the
|
||||
// client can react — e.g. pop the usage-limit modal — instead of only seeing a
|
||||
// generic
|
||||
// failure. We don't interpret the code here (that would couple this module to the
|
||||
// saas
|
||||
// billing layer); we just pass it through for the client to map. Other statuses
|
||||
// fall
|
||||
// through to the generic failure below.
|
||||
String code = DownstreamEntitlementError.extractCode(e);
|
||||
if (code != null) {
|
||||
log.info(
|
||||
"Policy run {} blocked by downstream entitlement gate ({})",
|
||||
runId,
|
||||
code);
|
||||
String message = "Usage limit reached";
|
||||
run.failWithCode(
|
||||
message, code, DownstreamEntitlementError.extractSubscribed(e));
|
||||
taskManager.setError(runId, message);
|
||||
} else {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed (downstream HTTP error)", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} finally {
|
||||
// Always resolve so stream/await callers unblock.
|
||||
completion.complete(run);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String message = "Policy run failed: " + e.getMessage();
|
||||
log.error("Policy run {} failed", runId, e);
|
||||
run.fail(message);
|
||||
taskManager.setError(runId, message);
|
||||
} finally {
|
||||
// Always resolve so stream/await callers unblock.
|
||||
completion.complete(run);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,18 +377,26 @@ 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, Runnable body) {
|
||||
private static void runAsPrincipal(
|
||||
String billingPrincipal, String fileOwner, String policyName, 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).
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
@@ -384,6 +408,11 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 jakarta.annotation.PreDestroy;
|
||||
@@ -29,7 +28,6 @@ import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyRunRegistry {
|
||||
|
||||
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -21,6 +20,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.EditorSource;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
@@ -34,7 +34,6 @@ import stirling.software.proprietary.policy.source.SourceStore;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyRunner {
|
||||
|
||||
private final PolicyEngine policyEngine;
|
||||
@@ -44,7 +43,7 @@ public class PolicyRunner {
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
/** Full-listing sweep: resolve every source, then reconcile the ledger. */
|
||||
public List<String> run(Policy policy) {
|
||||
public SweepOutcome run(Policy policy) {
|
||||
return run(policy, SweepKind.FULL);
|
||||
}
|
||||
|
||||
@@ -52,10 +51,10 @@ public class PolicyRunner {
|
||||
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
|
||||
* one failure does not affect the others. No sources means one run with no input (generator
|
||||
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
|
||||
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
|
||||
* manual trigger can report back which runs to follow.
|
||||
* rest. Returns the ids of the runs it started plus what the sweep skipped, so a manual trigger
|
||||
* can report which runs to follow or why nothing ran.
|
||||
*/
|
||||
public List<String> run(Policy policy, SweepKind sweep) {
|
||||
public SweepOutcome run(Policy policy, SweepKind sweep) {
|
||||
long sweepStart = System.currentTimeMillis();
|
||||
PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger);
|
||||
List<String> runIds = new ArrayList<>();
|
||||
@@ -95,13 +94,19 @@ public class PolicyRunner {
|
||||
policy.id());
|
||||
}
|
||||
}
|
||||
return runIds;
|
||||
return context.outcome(runIds);
|
||||
}
|
||||
|
||||
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
|
||||
/**
|
||||
* Run a stored policy on caller-supplied files (e.g. an editor upload), bypassing its sources.
|
||||
* The supplied documents are still counted against the virtual {@link EditorSource}, scoped to
|
||||
* the policy's team, so the Sources overview reports the whole team's editor throughput.
|
||||
*/
|
||||
public PolicyRunHandle runWith(
|
||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||
return policyEngine.runPolicy(policy, inputs, listener);
|
||||
PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, listener);
|
||||
docCounter.record(EditorSource.counterKey(policy.teamId()), inputs.primary().size());
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** Run an ad-hoc pipeline with no stored policy (AI/Automate one-offs). */
|
||||
|
||||
@@ -86,4 +86,32 @@ final class PolicySweep implements ResolveContext {
|
||||
synchronized Set<String> presentIdentities() {
|
||||
return Set.copyOf(present);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise the sweep from state already in hand (no extra ledger reads): the prefetched rows
|
||||
* were loaded before claiming, and successful claims flipped their entries to PROCESSING, so
|
||||
* what remains DONE or ERROR is exactly what this sweep skipped.
|
||||
*/
|
||||
synchronized SweepOutcome outcome(List<String> runIds) {
|
||||
int alreadyProcessed = 0;
|
||||
int parked = 0;
|
||||
int processing = 0;
|
||||
for (String identity : present) {
|
||||
ClaimState state = prefetched.get(identity);
|
||||
if (state == null) {
|
||||
continue;
|
||||
}
|
||||
switch (state.status()) {
|
||||
case DONE -> alreadyProcessed++;
|
||||
case ERROR -> parked++;
|
||||
case PROCESSING, INTERRUPTED -> processing++;
|
||||
}
|
||||
}
|
||||
return new SweepOutcome(
|
||||
runIds,
|
||||
present.size(),
|
||||
alreadyProcessed,
|
||||
parked,
|
||||
Math.max(0, processing - runIds.size()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -25,7 +24,6 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyValidator {
|
||||
|
||||
private final List<PolicyTrigger> triggers;
|
||||
@@ -52,7 +50,19 @@ public class PolicyValidator {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
inputSourceFor(spec).validate(spec);
|
||||
}
|
||||
outputSinkFor(policy.output()).validate(policy.output());
|
||||
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);
|
||||
}
|
||||
|
||||
private PolicyTrigger triggerFor(TriggerConfig config) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What one policy sweep found and started, so a manual trigger can explain an empty result instead
|
||||
* of a blanket "nothing to do": how many files the sources listed, how many were skipped because
|
||||
* they are already processed at their current version, how many are parked by a failed run (not
|
||||
* retried until they change or history is cleared), and how many are still in flight from an
|
||||
* earlier sweep. Counts are zero for {@link SweepKind#LIGHT} sweeps, which do not take a full
|
||||
* listing.
|
||||
*/
|
||||
public record SweepOutcome(
|
||||
List<String> runIds, int filesListed, int alreadyProcessed, int parked, int inFlight) {
|
||||
|
||||
public SweepOutcome {
|
||||
runIds = runIds == null ? List.of() : List.copyOf(runIds);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
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;
|
||||
@@ -42,7 +41,6 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class FolderInputSource implements InputSource {
|
||||
|
||||
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.io.AbstractResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
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;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
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.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class S3InputSource implements InputSource {
|
||||
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(InputSpec spec) {
|
||||
return spec != null && TYPE.equals(spec.type());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Override
|
||||
public void validate(InputSpec spec) {
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
try {
|
||||
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
|
||||
} catch (SdkException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"cannot access s3://"
|
||||
+ config.bucket()
|
||||
+ "/"
|
||||
+ config.prefix()
|
||||
+ ": "
|
||||
+ e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
|
||||
S3Config config = connectionResolver.resolve(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".
|
||||
List<S3Object> objects = listObjects(client, config);
|
||||
|
||||
if (config.snapshot()) {
|
||||
return objects.stream()
|
||||
.map(
|
||||
object ->
|
||||
ResolvedInput.of(
|
||||
PolicyInputs.of(
|
||||
List.of(
|
||||
objectResource(
|
||||
client, config, object)))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
ctx.reportPresent(
|
||||
objects.stream()
|
||||
.map(object -> S3Identities.identity(config.bucket(), object.key()))
|
||||
.toList());
|
||||
|
||||
List<ResolvedInput> work = new ArrayList<>();
|
||||
for (S3Object object : objects) {
|
||||
String identity = S3Identities.identity(config.bucket(), object.key());
|
||||
String gate = S3Identities.gate(object.eTag(), object.size(), object.lastModified());
|
||||
if (!ctx.claim(identity, gate, null)) {
|
||||
continue;
|
||||
}
|
||||
work.add(
|
||||
new ResolvedInput(
|
||||
PolicyInputs.of(List.of(objectResource(client, config, object))),
|
||||
success ->
|
||||
completeConsumed(
|
||||
ctx,
|
||||
client,
|
||||
config,
|
||||
object.key(),
|
||||
identity,
|
||||
gate,
|
||||
success)));
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle at the version this run claimed, then remove the object only when it still carries
|
||||
* that version and every policy that claimed it has settled DONE, mirroring the folder source's
|
||||
* consensus delete. A failed run settles ERROR and never deletes; the DONE row of an object
|
||||
* that could not be deleted still stops reprocessing.
|
||||
*/
|
||||
private void completeConsumed(
|
||||
ResolveContext ctx,
|
||||
S3Client client,
|
||||
S3Config config,
|
||||
String key,
|
||||
String identity,
|
||||
String claimGate,
|
||||
boolean success) {
|
||||
ctx.settle(identity, claimGate, null, success);
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
HeadObjectResponse head =
|
||||
client.headObject(
|
||||
HeadObjectRequest.builder().bucket(config.bucket()).key(key).build());
|
||||
String currentGate =
|
||||
S3Identities.gate(head.eTag(), head.contentLength(), head.lastModified());
|
||||
if (currentGate.equals(claimGate) && ctx.allSettledDone(identity)) {
|
||||
client.deleteObject(
|
||||
DeleteObjectRequest.builder().bucket(config.bucket()).key(key).build());
|
||||
}
|
||||
} catch (NoSuchKeyException alreadyGone) {
|
||||
// Removed by the user or a co-watching policy's own consensus delete: nothing to do.
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return;
|
||||
}
|
||||
log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage());
|
||||
} catch (SdkException e) {
|
||||
log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Every ingestible object under the configured prefix, across all listing pages. */
|
||||
private static List<S3Object> listObjects(S3Client client, S3Config config) {
|
||||
List<S3Object> objects = new ArrayList<>();
|
||||
String continuationToken = null;
|
||||
do {
|
||||
ListObjectsV2Request.Builder request = listRequest(config);
|
||||
if (continuationToken != null) {
|
||||
request.continuationToken(continuationToken);
|
||||
}
|
||||
ListObjectsV2Response page = client.listObjectsV2(request.build());
|
||||
for (S3Object object : page.contents()) {
|
||||
if (ingestible(object)) {
|
||||
objects.add(object);
|
||||
}
|
||||
}
|
||||
continuationToken = page.nextContinuationToken();
|
||||
} while (continuationToken != null);
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static ListObjectsV2Request.Builder listRequest(S3Config config) {
|
||||
ListObjectsV2Request.Builder request =
|
||||
ListObjectsV2Request.builder().bucket(config.bucket());
|
||||
if (!config.prefix().isEmpty()) {
|
||||
request.prefix(config.prefix());
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder-placeholder keys (ending "/") and keys with a dot-prefixed segment are skipped, so a
|
||||
* hidden convention (e.g. a future output sink's staging prefix) is never re-ingested.
|
||||
*/
|
||||
private static boolean ingestible(S3Object object) {
|
||||
String key = object.key();
|
||||
if (key.isEmpty() || key.endsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
for (String segment : key.split("/")) {
|
||||
if (segment.startsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Resource objectResource(S3Client client, S3Config config, S3Object object) {
|
||||
return new S3ObjectResource(client, config.bucket(), object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the object on demand, pinned to the ETag observed at listing time so a run never
|
||||
* reads a different version than the sweep claimed (a swapped object fails the read with a
|
||||
* precondition error and the new version is claimed by a later sweep).
|
||||
*/
|
||||
private static final class S3ObjectResource extends AbstractResource {
|
||||
|
||||
private final S3Client client;
|
||||
private final String bucket;
|
||||
private final String key;
|
||||
private final String eTag;
|
||||
private final Long size;
|
||||
|
||||
private S3ObjectResource(S3Client client, String bucket, S3Object object) {
|
||||
this.client = client;
|
||||
this.bucket = bucket;
|
||||
this.key = object.key();
|
||||
this.eTag = object.eTag();
|
||||
this.size = object.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
GetObjectRequest.Builder request = GetObjectRequest.builder().bucket(bucket).key(key);
|
||||
if (eTag != null && !eTag.isBlank()) {
|
||||
request.ifMatch(eTag);
|
||||
}
|
||||
try {
|
||||
return client.getObject(request.build());
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new FileNotFoundException(getDescription() + " no longer exists");
|
||||
} catch (SdkException e) {
|
||||
throw new IOException(
|
||||
"Could not read " + getDescription() + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Listed just now; readers get a precise error from {@link #getInputStream} instead. */
|
||||
@Override
|
||||
public boolean exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() {
|
||||
return size == null ? -1 : size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return key.substring(key.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "S3 object " + S3Identities.identity(bucket, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
@@ -24,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaProcessedLedger implements ProcessedLedger {
|
||||
|
||||
private static final int STAMP_CHUNK = 500;
|
||||
|
||||
@@ -15,8 +15,6 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
@@ -42,7 +40,6 @@ import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class FolderOutputSink implements PolicyOutputSink {
|
||||
|
||||
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
||||
@@ -82,7 +79,7 @@ public class FolderOutputSink implements PolicyOutputSink {
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
Resource resource = outputs.get(i);
|
||||
String name = safeName(resource.getFilename(), i);
|
||||
String name = OutputNames.safeName(resource.getFilename(), i);
|
||||
Path staged = tmpDir.resolve(UUID.randomUUID().toString());
|
||||
String contentHash = stage(resource, staged, delivery.policyId() != null);
|
||||
long size = Files.size(staged);
|
||||
@@ -198,29 +195,14 @@ public class FolderOutputSink implements PolicyOutputSink {
|
||||
return Path.of(directory.toString());
|
||||
}
|
||||
|
||||
// Strip any directory component / "../" so a crafted output name cannot escape targetDir.
|
||||
private static String safeName(String filename, int index) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "output-" + index;
|
||||
}
|
||||
String name = FilenameUtils.getName(filename);
|
||||
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
|
||||
return "output-" + index;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// Non-colliding path, appending " (n)" before the extension.
|
||||
private static Path uniqueTarget(Path dir, String filename) {
|
||||
Path candidate = dir.resolve(filename);
|
||||
if (!Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
String base = FilenameUtils.getBaseName(filename);
|
||||
String ext = FilenameUtils.getExtension(filename);
|
||||
String suffix = ext.isEmpty() ? "" : "." + ext;
|
||||
for (int n = 1; ; n++) {
|
||||
Path next = dir.resolve(base + " (" + n + ")" + suffix);
|
||||
Path next = dir.resolve(OutputNames.numbered(filename, n));
|
||||
if (!Files.exists(next)) {
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
@@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class InlineOutputSink implements PolicyOutputSink {
|
||||
|
||||
private static final String TYPE = "inline";
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
|
||||
/** Output file naming shared by the sinks: sanitised base names and collision suffixes. */
|
||||
final class OutputNames {
|
||||
|
||||
private OutputNames() {}
|
||||
|
||||
/** Strip any directory component / "../" so a crafted output name cannot escape the target. */
|
||||
static String safeName(String filename, int index) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "output-" + index;
|
||||
}
|
||||
String name = FilenameUtils.getName(filename);
|
||||
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
|
||||
return "output-" + index;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** The nth alternative for a taken name, appending " (n)" before the extension. */
|
||||
static String numbered(String filename, int n) {
|
||||
String base = FilenameUtils.getBaseName(filename);
|
||||
String ext = FilenameUtils.getExtension(filename);
|
||||
String suffix = ext.isEmpty() ? "" : "." + ext;
|
||||
return base + " (" + n + ")" + suffix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
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;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/**
|
||||
* Uploads a run's outputs to the bucket and key prefix given in the {@link OutputSpec} (same
|
||||
* connection options as the S3 input source; "prefix" is the destination folder). The
|
||||
* record-before-visible obligation is met without a rename step: a single-part PUT's ETag is the
|
||||
* MD5 of its content on plain and SSE-S3 buckets, so the ledger row is recorded at that predicted
|
||||
* gate BEFORE the upload, and the object is claimed under exactly the gate the next listing
|
||||
* returns. Stores where the returned ETag differs (e.g. SSE-KMS) are re-recorded at the actual gate
|
||||
* immediately after the PUT - a narrow race those buckets accept rather than a broken loop. Names
|
||||
* never overwrite: uploads are conditional on the key not existing ({@code If-None-Match: *}),
|
||||
* re-picking "name (n).ext" on collision exactly like the folder sink; stores without
|
||||
* conditional-write support fall back to an existence check per candidate.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class S3OutputSink implements PolicyOutputSink {
|
||||
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(OutputSpec spec) {
|
||||
return spec != null && TYPE.equals(spec.type());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Override
|
||||
public void validate(OutputSpec spec) {
|
||||
connectionPool.clientFor(connectionResolver.resolve(spec.options()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResultFile> deliver(
|
||||
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
Resource resource = outputs.get(i);
|
||||
String name = OutputNames.safeName(resource.getFilename(), i);
|
||||
Path staged = Files.createTempFile("s3-output-", ".tmp");
|
||||
try {
|
||||
String predictedGate = stage(resource, staged, delivery.policyId() != null);
|
||||
long size = Files.size(staged);
|
||||
String key = upload(delivery, client, config, name, staged, predictedGate);
|
||||
String contentType =
|
||||
MediaTypeFactory.getMediaType(name)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.toString();
|
||||
results.add(
|
||||
ResultFile.builder()
|
||||
.fileId(UUID.randomUUID().toString())
|
||||
.fileName(S3Identities.identity(config.bucket(), key))
|
||||
.contentType(contentType)
|
||||
.fileSize(size)
|
||||
.build());
|
||||
log.debug(
|
||||
"Wrote policy run {} output to {}",
|
||||
delivery.runId(),
|
||||
S3Identities.identity(config.bucket(), key));
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(staged);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not remove S3 staging file {}: {}", staged, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spool the output to a local staging file (S3 needs a known content length, and the body must
|
||||
* be re-readable across collision retries). For a recorded delivery the MD5 - the predicted
|
||||
* single-part ETag - is digested in the same pass; ad-hoc runs record nothing and skip it.
|
||||
*/
|
||||
private static String stage(Resource resource, Path staged, boolean recorded)
|
||||
throws IOException {
|
||||
if (!recorded) {
|
||||
try (InputStream is = resource.getInputStream();
|
||||
OutputStream out = Files.newOutputStream(staged)) {
|
||||
is.transferTo(out);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
MessageDigest digest = newMd5();
|
||||
try (InputStream is = resource.getInputStream();
|
||||
DigestOutputStream out =
|
||||
new DigestOutputStream(Files.newOutputStream(staged), digest)) {
|
||||
is.transferTo(out);
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
/**
|
||||
* The S3 shape of the folder sink's record-then-rename loop. The ledger row must exist before
|
||||
* the object is visible, so it is recorded at the predicted gate before the PUT; losing the
|
||||
* chosen key to a concurrent writer (the conditional PUT fails) forgets the just-recorded row -
|
||||
* whatever object actually owns that key must stay claimable at any version - then re-picks. A
|
||||
* PUT that never made the object visible also forgets its row.
|
||||
*/
|
||||
private String upload(
|
||||
OutputDelivery delivery,
|
||||
S3Client client,
|
||||
S3Config config,
|
||||
String name,
|
||||
Path staged,
|
||||
String predictedGate)
|
||||
throws IOException {
|
||||
String keyPrefix = keyPrefix(config);
|
||||
boolean conditionalPuts = true;
|
||||
for (int attempt = 0; ; attempt++) {
|
||||
String key = keyPrefix + (attempt == 0 ? name : OutputNames.numbered(name, attempt));
|
||||
String identity = S3Identities.identity(config.bucket(), key);
|
||||
if (!conditionalPuts && exists(client, config.bucket(), key)) {
|
||||
continue;
|
||||
}
|
||||
if (delivery.policyId() != null) {
|
||||
processedLedger.recordOutput(delivery.policyId(), identity, predictedGate, null);
|
||||
}
|
||||
PutObjectRequest.Builder put =
|
||||
PutObjectRequest.builder().bucket(config.bucket()).key(key);
|
||||
if (conditionalPuts) {
|
||||
put.ifNoneMatch("*");
|
||||
}
|
||||
try {
|
||||
PutObjectResponse response =
|
||||
client.putObject(put.build(), RequestBody.fromFile(staged));
|
||||
reRecordIfGateDiffers(delivery, identity, predictedGate, response);
|
||||
return key;
|
||||
} catch (S3Exception e) {
|
||||
forgetRecorded(delivery, identity, predictedGate);
|
||||
if (conditionalPuts && e.statusCode() == 412) {
|
||||
// Known edge: if our own PUT succeeded server-side but the response was lost
|
||||
// and the SDK retried, that retry 412s here too - we then upload under the
|
||||
// next name, leaving the first object row-less (claimable, single duplicate).
|
||||
// Requires a response-lost network flake at exactly this moment; accepted.
|
||||
log.debug("Output key {} taken concurrently; re-picking", identity);
|
||||
continue;
|
||||
}
|
||||
if (conditionalPuts && e.statusCode() == 501) {
|
||||
// Store without conditional-write support: retry this candidate with a plain
|
||||
// existence check instead.
|
||||
log.debug(
|
||||
"Conditional PUT unsupported by {}; falling back to existence checks",
|
||||
config.bucket());
|
||||
conditionalPuts = false;
|
||||
attempt--;
|
||||
continue;
|
||||
}
|
||||
throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e);
|
||||
} catch (SdkException e) {
|
||||
forgetRecorded(delivery, identity, predictedGate);
|
||||
throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On buckets where a PUT's ETag is not the content MD5 (e.g. SSE-KMS), re-record at the gate
|
||||
* listings will actually return. The row is briefly at the wrong gate while the object is
|
||||
* already visible - the narrow race such stores trade for a working self-output skip.
|
||||
*/
|
||||
private void reRecordIfGateDiffers(
|
||||
OutputDelivery delivery,
|
||||
String identity,
|
||||
String predictedGate,
|
||||
PutObjectResponse response) {
|
||||
if (delivery.policyId() == null) {
|
||||
return;
|
||||
}
|
||||
String actualGate = S3Identities.gate(response.eTag(), null, null);
|
||||
if (!actualGate.equals(predictedGate)) {
|
||||
log.debug(
|
||||
"PUT ETag for {} differs from content MD5 (encrypted bucket?); re-recording",
|
||||
identity);
|
||||
processedLedger.recordOutput(delivery.policyId(), identity, actualGate, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void forgetRecorded(OutputDelivery delivery, String identity, String predictedGate) {
|
||||
if (delivery.policyId() != null) {
|
||||
processedLedger.forgetOutput(delivery.policyId(), identity, predictedGate);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean exists(S3Client client, String bucket, String key) {
|
||||
try {
|
||||
client.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
|
||||
return true;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return false;
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** The configured prefix as a key-path prefix: "processed" and "processed/" mean the same. */
|
||||
private static String keyPrefix(S3Config config) {
|
||||
String prefix = config.prefix();
|
||||
if (prefix.isEmpty() || prefix.endsWith("/")) {
|
||||
return prefix;
|
||||
}
|
||||
return prefix + "/";
|
||||
}
|
||||
|
||||
private static MessageDigest newMd5() {
|
||||
try {
|
||||
return MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("MD5 unavailable", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -29,7 +28,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyOverviewService {
|
||||
|
||||
private final PolicyStore policyStore;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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 - it parses and rewrites the
|
||||
* option JSON (and decrypts any legacy ciphertext row on read), which no SQL migration can do.
|
||||
*
|
||||
* <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
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
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.
|
||||
*/
|
||||
public record S3Config(
|
||||
String bucket,
|
||||
String region,
|
||||
String prefix,
|
||||
String endpoint,
|
||||
String accessKeyId,
|
||||
String secretAccessKey,
|
||||
boolean snapshot) {
|
||||
|
||||
private static final String BUCKET_OPTION = "bucket";
|
||||
private static final String REGION_OPTION = "region";
|
||||
private static final String PREFIX_OPTION = "prefix";
|
||||
private static final String ENDPOINT_OPTION = "endpoint";
|
||||
private static final String ACCESS_KEY_ID_OPTION = "accessKeyId";
|
||||
private static final String SECRET_ACCESS_KEY_OPTION = "secretAccessKey";
|
||||
private static final String MODE_OPTION = "mode";
|
||||
private static final String MODE_CONSUME = "consume";
|
||||
private static final String MODE_SNAPSHOT = "snapshot";
|
||||
|
||||
public static S3Config from(Map<String, Object> options) {
|
||||
String bucket = trimmed(options.get(BUCKET_OPTION));
|
||||
if (bucket == null) {
|
||||
throw new IllegalArgumentException("s3 config requires a 'bucket' option");
|
||||
}
|
||||
String region = trimmed(options.get(REGION_OPTION));
|
||||
String prefix = trimmed(options.get(PREFIX_OPTION));
|
||||
if (prefix != null && prefix.startsWith("/")) {
|
||||
prefix = prefix.substring(1);
|
||||
}
|
||||
String endpoint = validEndpoint(trimmed(options.get(ENDPOINT_OPTION)));
|
||||
String accessKeyId = trimmed(options.get(ACCESS_KEY_ID_OPTION));
|
||||
String secretAccessKey = trimmed(options.get(SECRET_ACCESS_KEY_OPTION));
|
||||
if (accessKeyId == null || secretAccessKey == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 config requires an 'accessKeyId' and 'secretAccessKey'");
|
||||
}
|
||||
String mode = trimmed(options.get(MODE_OPTION));
|
||||
if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) {
|
||||
throw new IllegalArgumentException("s3 config 'mode' must be 'consume' or 'snapshot'");
|
||||
}
|
||||
return new S3Config(
|
||||
bucket,
|
||||
region == null ? "us-east-1" : region,
|
||||
prefix == null ? "" : prefix,
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
MODE_SNAPSHOT.equals(mode));
|
||||
}
|
||||
|
||||
private static String validEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(endpoint);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException("s3 config 'endpoint' is not a valid URL", e);
|
||||
}
|
||||
if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 config 'endpoint' must be an http(s) URL, e.g. https://s3.example.com");
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
private static String trimmed(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/** Never prints the credentials, so an accidental log line cannot leak them. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return "S3Config[bucket="
|
||||
+ bucket
|
||||
+ ", region="
|
||||
+ region
|
||||
+ ", prefix="
|
||||
+ prefix
|
||||
+ ", endpoint="
|
||||
+ endpoint
|
||||
+ ", snapshot="
|
||||
+ snapshot
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
|
||||
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.S3ClientBuilder;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
|
||||
/**
|
||||
* Long-lived {@link S3Client}s for policy S3 sources and sinks, one per distinct {@link S3Config},
|
||||
* closed at shutdown. An edited spec simply maps to a new entry, and a stale entry costs nothing
|
||||
* (the URL-connection HTTP client holds no pooled sockets or threads). Clients sign exclusively
|
||||
* with the spec's own credentials - there is deliberately no fallback to the server's AWS
|
||||
* credential chain, so user-supplied config can never borrow the host's identity. Endpoints are
|
||||
* guarded against private addresses before a client is ever built, since they come from portal
|
||||
* users rather than the operator.
|
||||
*/
|
||||
@Service
|
||||
public class S3ConnectionPool {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final Function<S3Config, S3Client> clientFactory;
|
||||
private final Map<S3Config, S3Client> clients = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
public S3ConnectionPool(ApplicationProperties applicationProperties) {
|
||||
this(applicationProperties, S3ConnectionPool::buildClient);
|
||||
}
|
||||
|
||||
/** Factory-injecting constructor for tests. */
|
||||
public S3ConnectionPool(
|
||||
ApplicationProperties applicationProperties,
|
||||
Function<S3Config, S3Client> clientFactory) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.clientFactory = clientFactory;
|
||||
}
|
||||
|
||||
public S3Client clientFor(S3Config config) {
|
||||
return clients.computeIfAbsent(
|
||||
config,
|
||||
c -> {
|
||||
requirePermittedEndpoint(c);
|
||||
return clientFactory.apply(c);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-supplied endpoint must not reach loopback, link-local, or private addresses unless the
|
||||
* operator has opted in via {@code policies.allowPrivateS3Endpoints}.
|
||||
*/
|
||||
private void requirePermittedEndpoint(S3Config config) {
|
||||
if (config.endpoint() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create(config.endpoint()),
|
||||
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
|
||||
"S3 source endpoint",
|
||||
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
|
||||
+ " MinIO).");
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static S3Client buildClient(S3Config config) {
|
||||
S3ClientBuilder builder =
|
||||
S3Client.builder()
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.of(config.region()))
|
||||
// Path-style addressing whenever a custom endpoint is set: S3-compatible
|
||||
// stores rarely support virtual-hosted bucket DNS.
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(config.endpoint() != null)
|
||||
.build())
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(
|
||||
config.accessKeyId(), config.secretAccessKey())));
|
||||
if (config.endpoint() != null) {
|
||||
builder.endpointOverride(URI.create(config.endpoint()));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void closeClients() {
|
||||
clients.values().forEach(S3Client::close);
|
||||
clients.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
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)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The S3 backend's identity and version scheme, shared by {@code S3InputSource} and {@code
|
||||
* S3OutputSink} so outputs are recorded under exactly the identity and gate the next listing
|
||||
* derives. Identity is {@code s3://bucket/key}; the gate is the ETag every listing returns for free
|
||||
* (multipart ETags are not content hashes, so any ETag change simply reads as a new version).
|
||||
*/
|
||||
public final class S3Identities {
|
||||
|
||||
private S3Identities() {}
|
||||
|
||||
public static String identity(String bucket, String key) {
|
||||
return "s3://" + bucket + "/" + key;
|
||||
}
|
||||
|
||||
/** ETag stripped of its quotes; falls back to size:lastModified for stores that omit it. */
|
||||
public static String gate(String eTag, Long size, Instant lastModified) {
|
||||
if (eTag != null && !eTag.isBlank()) {
|
||||
return eTag.replace("\"", "");
|
||||
}
|
||||
return (size == null ? -1 : size)
|
||||
+ ":"
|
||||
+ (lastModified == null ? 0 : lastModified.toEpochMilli());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
|
||||
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
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
/**
|
||||
* The Editor as a virtual, always-present source. Unlike a persisted {@link Source} it is neither
|
||||
* stored nor configurable: it stands for the documents a team processes by running policies from
|
||||
* the in-app editor (the {@code POST /api/v1/policies/{id}/run} path). Its throughput is tracked
|
||||
* through {@link SourceDocCounter} under a synthetic, team-scoped key, so each team sees only its
|
||||
* own editor activity and the client is only ever handed the opaque {@link #ID}, never a team.
|
||||
*/
|
||||
public final class EditorSource {
|
||||
|
||||
/** The single, stable id and type the client sees for the editor row. */
|
||||
public static final String ID = "editor";
|
||||
|
||||
public static final String TYPE = "editor";
|
||||
|
||||
private EditorSource() {}
|
||||
|
||||
/**
|
||||
* The per-team {@link SourceDocCounter} key. A {@code null} team (login disabled / self-hosted
|
||||
* single user) shares one global bucket; otherwise counts are partitioned by team so a team's
|
||||
* total aggregates every member's editor runs and no other team's.
|
||||
*/
|
||||
public static String counterKey(Long teamId) {
|
||||
return teamId == null ? ID : ID + ":" + teamId;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import java.util.function.IntSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -24,7 +23,6 @@ import org.springframework.stereotype.Service;
|
||||
* table stays bounded (~one row per source per active hour, for at most 30 days).
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaSourceDocCounter implements SourceDocCounter {
|
||||
|
||||
private final SourceDocCountRepository countRepository;
|
||||
|
||||
@@ -4,10 +4,10 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@@ -15,9 +15,9 @@ import tools.jackson.databind.ObjectMapper;
|
||||
* Durable {@link SourceStore} backed by JPA; the runtime store. Sources are persisted as JSON via
|
||||
* {@link SourceEntity}, with scalar columns kept in sync for querying.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaSourceStore implements SourceStore {
|
||||
|
||||
private final SourceRepository repository;
|
||||
@@ -53,17 +53,20 @@ public class JpaSourceStore implements SourceStore {
|
||||
|
||||
@Override
|
||||
public Optional<Source> get(String id) {
|
||||
return repository.findById(id).map(this::toSource);
|
||||
return repository.findById(id).flatMap(this::toSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Source> all() {
|
||||
return repository.findAll().stream().map(this::toSource).toList();
|
||||
return repository.findAll().stream().map(this::toSource).flatMap(Optional::stream).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Source> findByTeam(Long teamId) {
|
||||
return repository.findByTeam(teamId).stream().map(this::toSource).toList();
|
||||
return repository.findByTeam(teamId).stream()
|
||||
.map(this::toSource)
|
||||
.flatMap(Optional::stream)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -75,7 +78,20 @@ public class JpaSourceStore implements SourceStore {
|
||||
return true;
|
||||
}
|
||||
|
||||
private Source toSource(SourceEntity entity) {
|
||||
return objectMapper.readValue(entity.getSourceJson(), Source.class);
|
||||
// Skip (don't fail) rows whose JSON can't be read - e.g. written by another app version/key.
|
||||
// One unreadable row must never abort a bulk read or crash startup.
|
||||
private Optional<Source> toSource(SourceEntity entity) {
|
||||
try {
|
||||
return Optional.of(objectMapper.readValue(entity.getSourceJson(), Source.class));
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Skipping unreadable policy source id={} name={}: stored JSON could not be"
|
||||
+ " parsed ({}). Likely written by a different app version or"
|
||||
+ " encryption key.",
|
||||
entity.getId(),
|
||||
entity.getName(),
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||