mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
@@ -0,0 +1,136 @@
|
|||||||
|
name: Docker Compose Cucumber tests (saas / PAYG)
|
||||||
|
|
||||||
|
# Self-contained CI job for the PAYG shadow-mode cucumber scenarios.
|
||||||
|
# Triggers only on PAYG-relevant paths so we don't add CI minutes to every PR
|
||||||
|
# that doesn't touch the saas flavour.
|
||||||
|
#
|
||||||
|
# Companion to `docker-compose-tests.yml` (which runs against the
|
||||||
|
# proprietary-flavour stack and skips features/payg via behave.ini's
|
||||||
|
# exclude_re). Kept as a separate workflow so the saas matrix can fail and
|
||||||
|
# succeed independently without touching the main cucumber harness.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "app/saas/**"
|
||||||
|
- "testing/cucumber/features/payg/**"
|
||||||
|
- "testing/cucumber/features/steps/payg_step_definitions.py"
|
||||||
|
- "testing/cucumber/requirements.txt"
|
||||||
|
- "testing/compose/docker-compose-saas.yml"
|
||||||
|
- "testing/compose/payg/**"
|
||||||
|
- "testing/test-payg.sh"
|
||||||
|
- ".github/workflows/docker-compose-tests-saas.yml"
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "app/saas/**"
|
||||||
|
- "testing/cucumber/features/payg/**"
|
||||||
|
- "testing/cucumber/features/steps/payg_step_definitions.py"
|
||||||
|
- "testing/cucumber/requirements.txt"
|
||||||
|
- "testing/compose/docker-compose-saas.yml"
|
||||||
|
- "testing/compose/payg/**"
|
||||||
|
- "testing/test-payg.sh"
|
||||||
|
- ".github/workflows/docker-compose-tests-saas.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pick:
|
||||||
|
uses: ./.github/workflows/_runner-pick.yml
|
||||||
|
|
||||||
|
docker-compose-tests-saas:
|
||||||
|
needs: pick
|
||||||
|
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
|
checks: write
|
||||||
|
env:
|
||||||
|
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Harden Runner
|
||||||
|
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Set up JDK 25
|
||||||
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||||
|
with:
|
||||||
|
java-version: "25"
|
||||||
|
distribution: "temurin"
|
||||||
|
|
||||||
|
- name: Cache Gradle dependency artifacts
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/wrapper
|
||||||
|
~/.gradle/caches/modules-2/files-2.1
|
||||||
|
~/.gradle/caches/modules-2/metadata-2.*
|
||||||
|
key: gradle-deps-saas-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||||
|
with:
|
||||||
|
gradle-version: 9.3.1
|
||||||
|
cache-disabled: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
|
- name: Expose GitHub runtime for Buildx cache
|
||||||
|
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||||
|
|
||||||
|
# No "Install Docker Compose" step: Ubuntu runners ship with `docker compose`
|
||||||
|
# v2 (built into the Docker CLI). test-payg.sh uses the v2 form throughout
|
||||||
|
# (`docker compose …`, no hyphen), so the legacy v1 `docker-compose` binary
|
||||||
|
# isn't needed. Avoids a `curl | sudo install` without checksum verification
|
||||||
|
# (Aikido flagged this when copy-pasted from docker-compose-tests.yml).
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: "pip"
|
||||||
|
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||||
|
|
||||||
|
- name: Pip requirements
|
||||||
|
run: |
|
||||||
|
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
|
||||||
|
|
||||||
|
- name: Run PAYG Cucumber Tests
|
||||||
|
env:
|
||||||
|
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||||
|
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||||
|
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||||
|
run: |
|
||||||
|
chmod +x ./testing/test-payg.sh
|
||||||
|
./testing/test-payg.sh
|
||||||
|
|
||||||
|
- name: Dump saas container logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 500 stirling-pdf-saas || true
|
||||||
|
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 200 postgres-saas || true
|
||||||
|
|
||||||
|
- name: Upload PAYG Cucumber Report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: payg-cucumber-report
|
||||||
|
path: testing/cucumber/report-payg.html
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: PAYG Cucumber Test Report
|
||||||
|
if: always()
|
||||||
|
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||||
|
with:
|
||||||
|
name: PAYG Cucumber Tests
|
||||||
|
path: testing/cucumber/junit-payg/*.xml
|
||||||
|
reporter: java-junit
|
||||||
|
fail-on-error: false
|
||||||
@@ -188,30 +188,10 @@ jobs:
|
|||||||
name: backend-log-live-${{ github.run_id }}
|
name: backend-log-live-${{ github.run_id }}
|
||||||
path: .test-state/playwright/backend.log
|
path: .test-state/playwright/backend.log
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: List Playwright output locations (debug)
|
- name: Upload Playwright report
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "::group::Playwright output dirs"
|
|
||||||
# Playwright anchors its default outputDir + HTML report to the
|
|
||||||
# nearest package.json, which is frontend/ (frontend/editor has
|
|
||||||
# none), so artifacts land under frontend/, not frontend/editor/.
|
|
||||||
ls -la frontend/playwright-report 2>/dev/null \
|
|
||||||
|| echo "no playwright-report at frontend/"
|
|
||||||
ls -la frontend/test-results 2>/dev/null \
|
|
||||||
|| echo "no test-results at frontend/"
|
|
||||||
find . -name node_modules -prune -o -name 'trace.zip' -print 2>/dev/null || true
|
|
||||||
echo "::endgroup::"
|
|
||||||
- name: Upload Playwright report + traces
|
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: playwright-report-live-${{ github.run_id }}
|
name: playwright-report-live-${{ github.run_id }}
|
||||||
# test-results/ holds the per-test trace.zip (with browser console
|
path: frontend/editor/playwright-report/
|
||||||
# logs) + screenshots/video; playwright-report/ is the HTML report.
|
|
||||||
# Both live under frontend/ (Playwright anchors them to the nearest
|
|
||||||
# package.json, which is frontend/; frontend/editor has none).
|
|
||||||
path: |
|
|
||||||
frontend/playwright-report/
|
|
||||||
frontend/test-results/
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
if-no-files-found: warn
|
|
||||||
|
|||||||
@@ -18,15 +18,6 @@ version: '3'
|
|||||||
tasks:
|
tasks:
|
||||||
dev:
|
dev:
|
||||||
desc: "Start backend dev server"
|
desc: "Start backend dev server"
|
||||||
cmds:
|
|
||||||
- task: dev:proprietary
|
|
||||||
vars:
|
|
||||||
PORT: '{{.PORT}}'
|
|
||||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
|
||||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
|
||||||
|
|
||||||
dev:proprietary:
|
|
||||||
desc: "Start backend dev server in proprietary mode"
|
|
||||||
ignore_error: true
|
ignore_error: true
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORT | default "8080"}}'
|
PORT: '{{.PORT | default "8080"}}'
|
||||||
@@ -59,14 +50,9 @@ tasks:
|
|||||||
PORT: '{{.PORT | default "8080"}}'
|
PORT: '{{.PORT | default "8080"}}'
|
||||||
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
|
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
|
||||||
PROFILES: '{{.PROFILES | default "dev"}}'
|
PROFILES: '{{.PROFILES | default "dev"}}'
|
||||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
|
||||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
|
||||||
env:
|
env:
|
||||||
SERVER_PORT: '{{.PORT}}'
|
SERVER_PORT: '{{.PORT}}'
|
||||||
STIRLING_FLAVOR: saas
|
STIRLING_FLAVOR: saas
|
||||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
|
||||||
AIENGINE_ENABLED: '{{if .AIENGINE_URL}}true{{else}}false{{end}}'
|
|
||||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
|
||||||
cmds:
|
cmds:
|
||||||
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
|
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
|
||||||
platforms: [windows]
|
platforms: [windows]
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite .
|
- docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite .
|
||||||
|
|
||||||
build:backend:
|
|
||||||
desc: "Build backend-only Docker image (no embedded frontend)"
|
|
||||||
cmds:
|
|
||||||
- docker build -t stirling-pdf-backend -f docker/backend/Dockerfile .
|
|
||||||
|
|
||||||
build:frontend:
|
build:frontend:
|
||||||
desc: "Build frontend-only Docker image"
|
desc: "Build frontend-only Docker image"
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
+16
-3
@@ -1,5 +1,12 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_*
|
||||||
|
# vars (Task merges included-file vars into the global scope).
|
||||||
|
# Paths are relative to the engine/ include dir.
|
||||||
|
ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh"
|
||||||
|
ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
install:
|
install:
|
||||||
desc: "Install engine dependencies"
|
desc: "Install engine dependencies"
|
||||||
@@ -29,11 +36,14 @@ tasks:
|
|||||||
ignore_error: true
|
ignore_error: true
|
||||||
dir: src
|
dir: src
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORT | default "5001"}}'
|
# When PORT is provided (e.g. from dev:all), use it directly.
|
||||||
|
# When running standalone, probe for a free port starting at 5001.
|
||||||
|
PORT:
|
||||||
|
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
|
||||||
env:
|
env:
|
||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
cmds:
|
cmds:
|
||||||
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
|
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}}
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
desc: "Start engine dev server with hot reload"
|
desc: "Start engine dev server with hot reload"
|
||||||
@@ -41,7 +51,10 @@ tasks:
|
|||||||
ignore_error: true
|
ignore_error: true
|
||||||
dir: src
|
dir: src
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORT | default "5001"}}'
|
# When PORT is provided (e.g. from dev:all), use it directly.
|
||||||
|
# When running standalone, probe for a free port starting at 5001.
|
||||||
|
PORT:
|
||||||
|
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
|
||||||
env:
|
env:
|
||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
+16
-12
@@ -60,20 +60,24 @@ tasks:
|
|||||||
|
|
||||||
dev:saas:
|
dev:saas:
|
||||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||||
cmds:
|
vars:
|
||||||
- task: dev:_all
|
PORTS:
|
||||||
vars: { FRONTEND: saas, BACKEND: saas }
|
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||||
|
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||||
|
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||||
|
deps:
|
||||||
|
- task: backend:dev:saas
|
||||||
|
vars:
|
||||||
|
PORT: '{{.BACKEND_PORT}}'
|
||||||
|
- task: frontend:dev:saas
|
||||||
|
vars:
|
||||||
|
PORT: '{{.FRONTEND_PORT}}'
|
||||||
|
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||||
|
OPEN: "true"
|
||||||
|
|
||||||
dev:all:
|
dev:all:
|
||||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||||
cmds:
|
|
||||||
- task: dev:_all
|
|
||||||
|
|
||||||
dev:_all:
|
|
||||||
internal: true
|
|
||||||
vars:
|
vars:
|
||||||
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
|
|
||||||
BACKEND: '{{.BACKEND | default "proprietary"}}'
|
|
||||||
PORTS:
|
PORTS:
|
||||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
|
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
|
||||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||||
@@ -83,11 +87,11 @@ tasks:
|
|||||||
- task: engine:dev
|
- task: engine:dev
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.ENGINE_PORT}}'
|
PORT: '{{.ENGINE_PORT}}'
|
||||||
- task: 'backend:dev:{{.BACKEND}}'
|
- task: backend:dev
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.BACKEND_PORT}}'
|
PORT: '{{.BACKEND_PORT}}'
|
||||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||||
- task: 'frontend:dev:{{.FRONTEND}}'
|
- task: frontend:dev
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.FRONTEND_PORT}}'
|
PORT: '{{.FRONTEND_PORT}}'
|
||||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||||
|
|||||||
@@ -367,15 +367,6 @@ public class ApplicationProperties {
|
|||||||
*/
|
*/
|
||||||
private String resourceId = "";
|
private String resourceId = "";
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}.
|
|
||||||
* Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint
|
|
||||||
* resource-specific audiences - e.g. Supabase's OAuth server always issues {@code
|
|
||||||
* aud=authenticated} - so operators list the audience their IdP actually emits here
|
|
||||||
* (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated).
|
|
||||||
*/
|
|
||||||
private List<String> acceptedAudiences = new ArrayList<>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
|
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
|
||||||
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
|
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
|
||||||
@@ -1005,10 +996,6 @@ public class ApplicationProperties {
|
|||||||
@Data
|
@Data
|
||||||
public static class Signing {
|
public static class Signing {
|
||||||
private boolean enabled = false;
|
private boolean enabled = false;
|
||||||
|
|
||||||
// Signing user-picker scope: 'org' (default) = whole instance, anything else =
|
|
||||||
// caller's team only (fail-closed). The saas profile pins 'team'.
|
|
||||||
private String userListScope = "org";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,16 +50,6 @@ public class InternalApiClient {
|
|||||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||||
|
|
||||||
/**
|
|
||||||
* Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies
|
|
||||||
* the call as {@code BillingCategory.AUTOMATION}. By construction every {@link
|
|
||||||
* InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow,
|
|
||||||
* policy runner) running a child tool inside a parent automation flow — see the saas {@code
|
|
||||||
* PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any
|
|
||||||
* per-tool {@code @RequiresFeature} annotation.
|
|
||||||
*/
|
|
||||||
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
|
|
||||||
|
|
||||||
private final ServletContext servletContext;
|
private final ServletContext servletContext;
|
||||||
private final UserServiceInterface userService;
|
private final UserServiceInterface userService;
|
||||||
private final TempFileManager tempFileManager;
|
private final TempFileManager tempFileManager;
|
||||||
@@ -106,11 +96,6 @@ public class InternalApiClient {
|
|||||||
if (apiKey != null && !apiKey.isEmpty()) {
|
if (apiKey != null && !apiKey.isEmpty()) {
|
||||||
headers.add("X-API-KEY", apiKey);
|
headers.add("X-API-KEY", apiKey);
|
||||||
}
|
}
|
||||||
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
|
|
||||||
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
|
|
||||||
// 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");
|
|
||||||
|
|
||||||
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
|
// 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
|
// without this RestTemplate would use urlencoded instead of the multipart the controller
|
||||||
|
|||||||
-16
@@ -31,22 +31,6 @@ class ApplicationPropertiesLogicTest {
|
|||||||
assertTrue(sys.isAnalyticsEnabled());
|
assertTrue(sys.isAnalyticsEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void storageSigning_userListScope_defaultsToOrg_andIsSettable() {
|
|
||||||
// Self-host backward-compat: scope must default to "org" (saas profile pins "team").
|
|
||||||
ApplicationProperties.Storage.Signing signing = new ApplicationProperties.Storage.Signing();
|
|
||||||
|
|
||||||
assertFalse(signing.isEnabled());
|
|
||||||
assertEquals("org", signing.getUserListScope());
|
|
||||||
|
|
||||||
signing.setUserListScope("team");
|
|
||||||
assertEquals("team", signing.getUserListScope());
|
|
||||||
|
|
||||||
// Reachable from the full tree as storage.signing.userListScope.
|
|
||||||
assertEquals(
|
|
||||||
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void tempFileManagement_defaults_and_overrides() {
|
void tempFileManagement_defaults_and_overrides() {
|
||||||
Function<String, String> normalize = s -> Path.of(s).normalize().toString();
|
Function<String, String> normalize = s -> Path.of(s).normalize().toString();
|
||||||
|
|||||||
@@ -59,53 +59,6 @@ class InternalApiClientTest {
|
|||||||
servletContext, userService, tempFileManager, environment, applicationProperties);
|
servletContext, userService, tempFileManager, environment, applicationProperties);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void postTagsRequestAsAutomation() throws Exception {
|
|
||||||
// Every InternalApiClient.post() caller is a parent automation flow dispatching a child
|
|
||||||
// tool (pipeline executor, AI workflow, policy runner). Tagging the sub-step here means
|
|
||||||
// the saas PaygChargeInterceptor classifies it as BillingCategory.AUTOMATION regardless of
|
|
||||||
// the dispatched controller's @RequiresFeature — so an AI-OCR step inside a policy run
|
|
||||||
// bills as AUTOMATION, not AI. The header value is the literal string "true" because the
|
|
||||||
// interceptor compares case-insensitively-trimmed against that token.
|
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
|
||||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
|
||||||
|
|
||||||
Path tempPath = Files.createTempFile("internal-api-automation-test", ".tmp");
|
|
||||||
TempFile tempFile = mock(TempFile.class);
|
|
||||||
when(tempFile.getPath()).thenReturn(tempPath);
|
|
||||||
when(tempFile.getFile()).thenReturn(tempPath.toFile());
|
|
||||||
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
|
|
||||||
|
|
||||||
HttpHeaders[] captured = {null};
|
|
||||||
|
|
||||||
try (var ignored =
|
|
||||||
mockConstruction(
|
|
||||||
RestTemplate.class,
|
|
||||||
(rt, ctx) -> {
|
|
||||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
|
||||||
.thenAnswer(
|
|
||||||
inv -> {
|
|
||||||
HttpEntity<?> entity = inv.getArgument(0);
|
|
||||||
captured[0] = entity.getHeaders();
|
|
||||||
return (RequestCallback) req -> {};
|
|
||||||
});
|
|
||||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
|
||||||
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
|
|
||||||
})) {
|
|
||||||
|
|
||||||
InternalApiClient mockedClient = newClient();
|
|
||||||
mockedClient.post("/api/v1/general/merge-pdfs", body);
|
|
||||||
|
|
||||||
assertNotNull(captured[0]);
|
|
||||||
assertEquals(
|
|
||||||
"true",
|
|
||||||
captured[0].getFirst(InternalApiClient.AUTOMATION_HEADER),
|
|
||||||
"Sub-step dispatch must carry the automation marker header");
|
|
||||||
} finally {
|
|
||||||
Files.deleteIfExists(tempPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void postDoesNotForceContentType() throws Exception {
|
void postDoesNotForceContentType() throws Exception {
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
|||||||
@@ -290,7 +290,6 @@ storage:
|
|||||||
linkExpirationDays: 3 # Number of days before share links expire
|
linkExpirationDays: 3 # Number of days before share links expire
|
||||||
signing:
|
signing:
|
||||||
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
|
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
|
||||||
userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only.
|
|
||||||
autoPipeline:
|
autoPipeline:
|
||||||
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
|
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
|
||||||
fileReadiness:
|
fileReadiness:
|
||||||
@@ -391,9 +390,6 @@ mcp:
|
|||||||
jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration.
|
jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration.
|
||||||
resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp).
|
resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp).
|
||||||
# Required: tokens must list this id in `aud` or the request is rejected.
|
# Required: tokens must list this id in `aud` or the request is rejected.
|
||||||
acceptedAudiences: [] # Extra `aud` values accepted on top of resourceId. Empty = strict RFC 8707.
|
|
||||||
# For IdPs that cannot mint resource audiences (Supabase OAuth server always
|
|
||||||
# issues aud=authenticated) list that audience here, e.g. ['authenticated'].
|
|
||||||
usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username')
|
usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username')
|
||||||
requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended)
|
requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended)
|
||||||
engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine
|
engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine
|
||||||
|
|||||||
+8
-27
@@ -1,9 +1,6 @@
|
|||||||
package stirling.software.proprietary.mcp.security;
|
package stirling.software.proprietary.mcp.security;
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||||
@@ -11,35 +8,20 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
|||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id (or one
|
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
|
||||||
* of the explicitly accepted additional audiences) in its {@code aud} claim. The additional list
|
* {@code aud} claim. Fails closed when the resource id is unset.
|
||||||
* exists for IdPs that cannot mint resource-specific audiences - e.g. Supabase's OAuth server
|
|
||||||
* always issues {@code aud=authenticated}. Fails closed when nothing is configured.
|
|
||||||
*/
|
*/
|
||||||
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||||
|
|
||||||
private final Set<String> acceptedAudiences;
|
private final String expectedResourceId;
|
||||||
|
|
||||||
public McpAudienceValidator(String expectedResourceId) {
|
public McpAudienceValidator(String expectedResourceId) {
|
||||||
this(expectedResourceId, List.of());
|
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
|
||||||
}
|
|
||||||
|
|
||||||
public McpAudienceValidator(String expectedResourceId, Collection<String> additionalAudiences) {
|
|
||||||
Set<String> accepted = new LinkedHashSet<>();
|
|
||||||
if (expectedResourceId != null && !expectedResourceId.isBlank()) {
|
|
||||||
accepted.add(expectedResourceId);
|
|
||||||
}
|
|
||||||
if (additionalAudiences != null) {
|
|
||||||
additionalAudiences.stream()
|
|
||||||
.filter(a -> a != null && !a.isBlank())
|
|
||||||
.forEach(accepted::add);
|
|
||||||
}
|
|
||||||
this.acceptedAudiences = accepted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public OAuth2TokenValidatorResult validate(Jwt token) {
|
public OAuth2TokenValidatorResult validate(Jwt token) {
|
||||||
if (acceptedAudiences.isEmpty()) {
|
if (expectedResourceId.isBlank()) {
|
||||||
return OAuth2TokenValidatorResult.failure(
|
return OAuth2TokenValidatorResult.failure(
|
||||||
new OAuth2Error(
|
new OAuth2Error(
|
||||||
"invalid_token",
|
"invalid_token",
|
||||||
@@ -48,13 +30,12 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
|
|||||||
null));
|
null));
|
||||||
}
|
}
|
||||||
List<String> aud = token.getAudience();
|
List<String> aud = token.getAudience();
|
||||||
if (aud == null || aud.stream().noneMatch(acceptedAudiences::contains)) {
|
if (aud == null || !aud.contains(expectedResourceId)) {
|
||||||
return OAuth2TokenValidatorResult.failure(
|
return OAuth2TokenValidatorResult.failure(
|
||||||
new OAuth2Error(
|
new OAuth2Error(
|
||||||
"invalid_token",
|
"invalid_token",
|
||||||
"Token audience does not include this server's resource id or an"
|
"Token audience does not include this server's resource id ("
|
||||||
+ " accepted audience ("
|
+ expectedResourceId
|
||||||
+ String.join(", ", acceptedAudiences)
|
|
||||||
+ ").",
|
+ ").",
|
||||||
null));
|
null));
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-38
@@ -24,7 +24,6 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
|||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata;
|
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
||||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||||
@@ -158,11 +157,7 @@ public class McpSecurityConfig {
|
|||||||
throws Exception {
|
throws Exception {
|
||||||
String metadataPath = "/.well-known/oauth-protected-resource";
|
String metadataPath = "/.well-known/oauth-protected-resource";
|
||||||
applyCors(http);
|
applyCors(http);
|
||||||
// RFC 9728 section 3.1: clients derive the metadata URL by inserting the well-known
|
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
|
||||||
// segment before the resource path, so /mcp is discovered at {metadataPath}/mcp. Claim
|
|
||||||
// the subpaths too; otherwise they fall through to another filter chain whose default
|
|
||||||
// Spring Security metadata filter serves a document without authorization_servers.
|
|
||||||
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath, metadataPath + "/**")
|
|
||||||
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
|
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
|
||||||
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
|
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
|
||||||
// session, no form submissions; CSRF requires browser-attached ambient credentials
|
// session, no form submissions; CSRF requires browser-attached ambient credentials
|
||||||
@@ -173,8 +168,7 @@ public class McpSecurityConfig {
|
|||||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(
|
.authorizeHttpRequests(
|
||||||
a ->
|
a ->
|
||||||
a.requestMatchers(
|
a.requestMatchers(HttpMethod.GET, metadataPath)
|
||||||
HttpMethod.GET, metadataPath, metadataPath + "/**")
|
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
.authenticated())
|
.authenticated())
|
||||||
@@ -193,18 +187,28 @@ public class McpSecurityConfig {
|
|||||||
.oauth2ResourceServer(
|
.oauth2ResourceServer(
|
||||||
oauth2 ->
|
oauth2 ->
|
||||||
oauth2.authenticationEntryPoint(
|
oauth2.authenticationEntryPoint(
|
||||||
// Advertise the path-inserted form; RFC 9728 makes
|
new McpAuthenticationEntryPoint(metadataPath))
|
||||||
// it the canonical location for a resource with a
|
|
||||||
// path component.
|
|
||||||
new McpAuthenticationEntryPoint(
|
|
||||||
metadataPath + BASE_PATH))
|
|
||||||
// RFC 9728 protected-resource metadata for OAuth discovery.
|
// RFC 9728 protected-resource metadata for OAuth discovery.
|
||||||
.protectedResourceMetadata(
|
.protectedResourceMetadata(
|
||||||
prm ->
|
prm ->
|
||||||
prm.protectedResourceMetadataCustomizer(
|
prm.protectedResourceMetadataCustomizer(
|
||||||
builder ->
|
builder -> {
|
||||||
buildResourceMetadata(
|
if (!auth.getResourceId()
|
||||||
builder, auth)))
|
.isBlank()) {
|
||||||
|
builder.resource(
|
||||||
|
auth
|
||||||
|
.getResourceId());
|
||||||
|
}
|
||||||
|
if (!auth.getIssuerUri()
|
||||||
|
.isBlank()) {
|
||||||
|
builder.authorizationServer(
|
||||||
|
auth
|
||||||
|
.getIssuerUri());
|
||||||
|
}
|
||||||
|
builder.scope("mcp.tools.read");
|
||||||
|
builder.scope(
|
||||||
|
"mcp.tools.write");
|
||||||
|
}))
|
||||||
.jwt(
|
.jwt(
|
||||||
jwt ->
|
jwt ->
|
||||||
jwt.decoder(mcpJwtDecoder)
|
jwt.decoder(mcpJwtDecoder)
|
||||||
@@ -213,25 +217,6 @@ public class McpSecurityConfig {
|
|||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Populate the RFC 9728 protected-resource metadata document from the configured auth. */
|
|
||||||
private void buildResourceMetadata(
|
|
||||||
OAuth2ProtectedResourceMetadata.Builder builder, ApplicationProperties.Mcp.Auth auth) {
|
|
||||||
if (!auth.getResourceId().isBlank()) {
|
|
||||||
builder.resource(auth.getResourceId());
|
|
||||||
}
|
|
||||||
if (!auth.getIssuerUri().isBlank()) {
|
|
||||||
builder.authorizationServer(auth.getIssuerUri());
|
|
||||||
}
|
|
||||||
// Only advertise the granular tool scopes when we actually enforce them. When scopes are
|
|
||||||
// disabled (e.g. the IdP only mints coarse tokens, like Supabase), advertising scopes the
|
|
||||||
// authorization server can't issue makes spec-compliant clients request them and get
|
|
||||||
// rejected with invalid_request.
|
|
||||||
if (applicationProperties.getMcp().isScopesEnabled()) {
|
|
||||||
builder.scope("mcp.tools.read");
|
|
||||||
builder.scope("mcp.tools.write");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
JwtDecoder mcpJwtDecoder() {
|
JwtDecoder mcpJwtDecoder() {
|
||||||
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
|
ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth();
|
||||||
@@ -251,9 +236,7 @@ public class McpSecurityConfig {
|
|||||||
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
|
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
|
||||||
OAuth2TokenValidator<Jwt> combined =
|
OAuth2TokenValidator<Jwt> combined =
|
||||||
new DelegatingOAuth2TokenValidator<>(
|
new DelegatingOAuth2TokenValidator<>(
|
||||||
defaultValidators,
|
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
|
||||||
new McpAudienceValidator(
|
|
||||||
auth.getResourceId(), auth.getAcceptedAudiences()));
|
|
||||||
decoder.setJwtValidator(combined);
|
decoder.setJwtValidator(combined);
|
||||||
return decoder;
|
return decoder;
|
||||||
}
|
}
|
||||||
|
|||||||
-15
@@ -105,19 +105,4 @@ public class AiWorkflowResponse {
|
|||||||
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
|
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
|
||||||
+ " that produce only a file.")
|
+ " that produce only a file.")
|
||||||
private JsonNode report;
|
private JsonNode report;
|
||||||
|
|
||||||
@Schema(
|
|
||||||
description =
|
|
||||||
"Structured error code when a downstream tool call was blocked (e.g."
|
|
||||||
+ " PAYG_LIMIT_REACHED). Lets the client react — such as opening the"
|
|
||||||
+ " usage-limit modal — instead of only seeing a generic failure. Null"
|
|
||||||
+ " for ordinary outcomes.")
|
|
||||||
private String errorCode;
|
|
||||||
|
|
||||||
@Schema(
|
|
||||||
description =
|
|
||||||
"Whether the team is subscribed, carried from a downstream usage-limit response."
|
|
||||||
+ " Selects which limit modal the client shows (free → subscribe,"
|
|
||||||
+ " subscribed → raise cap). Null when the downstream body omitted it.")
|
|
||||||
private Boolean errorSubscribed;
|
|
||||||
}
|
}
|
||||||
|
|||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
package stirling.software.proprietary.policy.config;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.model.Team;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default (non-SaaS) policy context: a global admin may edit policies; scoping uses the current
|
|
||||||
* user's team (typically a single shared team self-hosted). SaaS overrides this with a team-leader
|
|
||||||
* check (see the {@code saas}-profiled implementation).
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@Profile("!saas")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AdminPolicyManagementAuthority implements PolicyManagementAuthority {
|
|
||||||
|
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean canEditPolicies() {
|
|
||||||
return userService.isCurrentUserAdmin();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Long currentUserTeamId() {
|
|
||||||
String username = userService.getCurrentUsername();
|
|
||||||
if (username == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return userService
|
|
||||||
.findByUsername(username)
|
|
||||||
.map(User::getTeam)
|
|
||||||
.map(Team::getId)
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+26
-12
@@ -13,18 +13,26 @@ import stirling.software.common.model.ApplicationProperties;
|
|||||||
import stirling.software.proprietary.policy.model.Policy;
|
import stirling.software.proprietary.policy.model.Policy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authority on which filesystem locations a policy may read/write. Checked at save time and again
|
* The single authority on which filesystem locations a policy may read from or write to. Folder
|
||||||
* at run time, fail-closed in order:
|
* sources and sinks take a configured directory, so without this a user who can save a policy could
|
||||||
|
* point one at Stirling's own config/secrets directory and exfiltrate (or overwrite) it. Every
|
||||||
|
* folder source and sink runs its directory through {@link #requirePermitted(Path)} at save time
|
||||||
|
* and again at run time.
|
||||||
*
|
*
|
||||||
* <ol>
|
* <p>Enforced fail-closed, in order:
|
||||||
* <li>denied entirely under the {@code saas} profile;
|
|
||||||
* <li>Stirling's own config dir always rejected, even if an allowed root were misconfigured to
|
|
||||||
* contain it;
|
|
||||||
* <li>must resolve within {@code policies.allowedFolderRoots}; none configured means all denied.
|
|
||||||
* </ol>
|
|
||||||
*
|
*
|
||||||
* <p>Compared after normalisation so {@code ..} cannot escape a root. Symlink escape is not
|
* <ul>
|
||||||
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
|
* <li><b>Disabled in SaaS</b> - folder access is never allowed when the {@code saas} profile is
|
||||||
|
* active; a tenant must not reach the host filesystem at all.
|
||||||
|
* <li><b>Protected paths</b> - Stirling's own config directory (settings, database, keys,
|
||||||
|
* backups) is always rejected, even if an allowed root were misconfigured to contain it.
|
||||||
|
* <li><b>Allowlist</b> - the directory must resolve within one of {@code
|
||||||
|
* policies.allowedFolderRoots}; with none configured, all folder access is refused.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Paths are compared after normalisation, so {@code ..} segments cannot walk out of an allowed
|
||||||
|
* root. (Symlink escape is not defended here; an operator who configures an allowed root containing
|
||||||
|
* a symlink to a sensitive location is trusted.)
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class FolderAccessGuard {
|
public class FolderAccessGuard {
|
||||||
@@ -42,7 +50,13 @@ public class FolderAccessGuard {
|
|||||||
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
|
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the normalised absolute path; throws if not permitted. */
|
/**
|
||||||
|
* Check that {@code dir} is a permitted folder location, returning its normalised absolute
|
||||||
|
* form.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if folder access is disabled (SaaS or no roots configured),
|
||||||
|
* the path is inside a protected directory, or it falls outside every allowed root
|
||||||
|
*/
|
||||||
public Path requirePermitted(Path dir) {
|
public Path requirePermitted(Path dir) {
|
||||||
if (saasActive) {
|
if (saasActive) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
@@ -67,7 +81,7 @@ public class FolderAccessGuard {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this policy touches a folder source/sink, and so is subject to these rules. */
|
/** Whether this policy reads from or writes to a folder, and so is subject to these rules. */
|
||||||
public boolean usesFolderAccess(Policy policy) {
|
public boolean usesFolderAccess(Policy policy) {
|
||||||
boolean readsFolder =
|
boolean readsFolder =
|
||||||
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
|
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
|
||||||
|
|||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
package stirling.software.proprietary.policy.config;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
|
||||||
import stirling.software.common.service.UserServiceInterface;
|
|
||||||
import stirling.software.proprietary.policy.model.Policy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging
|
|
||||||
* to their own team (the team a policy is stamped with at creation). This binds everyone — admins
|
|
||||||
* included — so no one sees or touches another team's policies. <em>Whether</em> a user may edit
|
|
||||||
* (vs only view/run) is a separate check gated at the controller ({@code
|
|
||||||
* PolicyController#requirePolicyEditingAllowed} → team leader). Enforced only when login is
|
|
||||||
* enabled; single-user deployments (login disabled) pass every check.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class PolicyAccessGuard {
|
|
||||||
|
|
||||||
private final UserServiceInterface userService;
|
|
||||||
private final ApplicationProperties applicationProperties;
|
|
||||||
private final PolicyManagementAuthority policyManagementAuthority;
|
|
||||||
|
|
||||||
/** Owner for a new policy: the current user, or {@code null} when login is disabled. */
|
|
||||||
public String ownerForNewPolicy() {
|
|
||||||
return enforced() ? userService.getCurrentUsername() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Team a new policy is stamped with — the creator's team. {@code null} when login disabled. */
|
|
||||||
public Long teamForNewPolicy() {
|
|
||||||
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the policy belongs to the current user's team (so they may view/run/edit it). */
|
|
||||||
public boolean canAccess(Policy policy) {
|
|
||||||
if (!enforced()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The subset of {@code policies} scoped to the current user's team. */
|
|
||||||
public List<Policy> visible(List<Policy> policies) {
|
|
||||||
if (!enforced()) {
|
|
||||||
return policies;
|
|
||||||
}
|
|
||||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
|
||||||
return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean enforced() {
|
|
||||||
return applicationProperties.getSecurity().isEnableLogin();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
package stirling.software.proprietary.policy.config;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current user's policy context, pluggable per deployment so the policy layer (proprietary)
|
|
||||||
* needn't know the team model. SaaS: a user may edit policies only if they lead their team, and
|
|
||||||
* every user is scoped to their own team. Self-hosted: a global admin may edit, scoped to their
|
|
||||||
* (typically single) team. Policies are isolated per team — nobody, admins included, sees or edits
|
|
||||||
* another team's policies.
|
|
||||||
*/
|
|
||||||
public interface PolicyManagementAuthority {
|
|
||||||
|
|
||||||
/** Whether the current user may create, edit, or delete policies (for their own team). */
|
|
||||||
boolean canEditPolicies();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The team that scopes the current user's policies — the team a new policy is stamped with and
|
|
||||||
* the only team whose policies the user may see/run/edit. {@code null} when it can't be
|
|
||||||
* resolved (e.g. login disabled / no team), in which case access falls back to the unteamed
|
|
||||||
* ({@code null}-team) policies.
|
|
||||||
*/
|
|
||||||
Long currentUserTeamId();
|
|
||||||
}
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
package stirling.software.proprietary.policy.controller;
|
|
||||||
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A supporting file paired with the asset key a pipeline step references from its {@code
|
|
||||||
* fileParameters}. The same key may appear on more than one asset to supply multiple files.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Schema(description = "A supporting file bound to the asset key a pipeline step references")
|
|
||||||
public class NamedAsset {
|
|
||||||
|
|
||||||
@NotBlank
|
|
||||||
@Schema(
|
|
||||||
description = "Asset key referenced by a step's fileParameters",
|
|
||||||
example = "company-logo")
|
|
||||||
private String key;
|
|
||||||
|
|
||||||
@NotNull
|
|
||||||
@Schema(description = "The supporting file", format = "binary")
|
|
||||||
private MultipartFile file;
|
|
||||||
}
|
|
||||||
+97
-123
@@ -11,16 +11,17 @@ import org.springframework.core.io.Resource;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestPart;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
@@ -29,17 +30,15 @@ import io.swagger.v3.oas.annotations.Hidden;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.job.JobResponse;
|
import stirling.software.common.model.job.JobResponse;
|
||||||
|
import stirling.software.common.service.UserServiceInterface;
|
||||||
import stirling.software.common.util.TempFile;
|
import stirling.software.common.util.TempFile;
|
||||||
import stirling.software.common.util.TempFileManager;
|
import stirling.software.common.util.TempFileManager;
|
||||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
import stirling.software.proprietary.policy.config.FolderAccessGuard;
|
||||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
|
||||||
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
||||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||||
@@ -52,15 +51,25 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
|||||||
import stirling.software.proprietary.policy.model.PolicyRunView;
|
import stirling.software.proprietary.policy.model.PolicyRunView;
|
||||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||||
|
import stirling.software.proprietary.security.config.PremiumEndpoint;
|
||||||
|
|
||||||
|
import tools.jackson.core.JacksonException;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
|
* Manages policies and runs pipelines. The premium backend entry point: CRUD for stored {@code
|
||||||
* GET /run/{runId}} for status, download outputs via {@code GET /api/v1/general/files/{fileId}}.
|
* Policy} objects, running a stored policy by id, and running an ad-hoc pipeline (for AI/Automate
|
||||||
|
* one-offs).
|
||||||
|
*
|
||||||
|
* <p>Runs execute asynchronously and return a run id immediately. Poll {@code GET /run/{runId}} for
|
||||||
|
* status, and download outputs via the existing {@code GET /api/v1/general/files/{fileId}} using
|
||||||
|
* the file ids in the run view.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/policies")
|
@RequestMapping("/api/v1/policies")
|
||||||
@Hidden
|
@Hidden
|
||||||
|
@PremiumEndpoint
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
|
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
|
||||||
public class PolicyController {
|
public class PolicyController {
|
||||||
@@ -69,9 +78,10 @@ public class PolicyController {
|
|||||||
private final PolicyRunRegistry runRegistry;
|
private final PolicyRunRegistry runRegistry;
|
||||||
private final PolicyStore policyStore;
|
private final PolicyStore policyStore;
|
||||||
private final PolicyValidator policyValidator;
|
private final PolicyValidator policyValidator;
|
||||||
private final PolicyAccessGuard policyAccessGuard;
|
private final FolderAccessGuard folderAccessGuard;
|
||||||
private final PolicyManagementAuthority policyManagementAuthority;
|
private final UserServiceInterface userService;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
private final TempFileManager tempFileManager;
|
private final TempFileManager tempFileManager;
|
||||||
|
|
||||||
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@@ -79,16 +89,15 @@ public class PolicyController {
|
|||||||
summary = "Run a tool pipeline",
|
summary = "Run a tool pipeline",
|
||||||
description =
|
description =
|
||||||
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
|
"Accepts the documents to process (multipart field 'fileInput'), any supporting"
|
||||||
+ " files (under 'assets[i].key' / 'assets[i].file'), and the pipeline"
|
+ " files (each under a multipart field named as its asset key, e.g."
|
||||||
+ " definition as an application/json part named 'json'. Runs the steps"
|
+ " 'company-logo'), and a JSON pipeline definition ('json'). Runs the"
|
||||||
+ " in order asynchronously and returns a run id. Poll the run status"
|
+ " steps in order asynchronously and returns a run id. Poll the run"
|
||||||
+ " endpoint and download outputs via /api/v1/general/files/{id}.")
|
+ " status endpoint and download outputs via /api/v1/general/files/{id}.")
|
||||||
public ResponseEntity<JobResponse<Void>> run(
|
public ResponseEntity<JobResponse<Void>> run(
|
||||||
@RequestPart("json") PipelineDefinition definition,
|
@RequestParam("json") String json, MultipartHttpServletRequest request)
|
||||||
@Valid @ModelAttribute PolicyRunFiles files)
|
|
||||||
throws IOException {
|
throws IOException {
|
||||||
requireRunnable(definition);
|
PipelineDefinition definition = parseDefinition(json);
|
||||||
PolicyInputs inputs = toInputs(files);
|
PolicyInputs inputs = collectInputs(request);
|
||||||
String runId =
|
String runId =
|
||||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
|
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
|
||||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||||
@@ -102,19 +111,18 @@ public class PolicyController {
|
|||||||
+ " starts and completes, then a terminal 'completed', 'failed',"
|
+ " starts and completes, then a terminal 'completed', 'failed',"
|
||||||
+ " 'cancelled', or 'waiting' event carrying the final run view.")
|
+ " 'cancelled', or 'waiting' event carrying the final run view.")
|
||||||
public SseEmitter runStream(
|
public SseEmitter runStream(
|
||||||
@RequestPart("json") PipelineDefinition definition,
|
@RequestParam("json") String json, MultipartHttpServletRequest request)
|
||||||
@Valid @ModelAttribute PolicyRunFiles files)
|
|
||||||
throws IOException {
|
throws IOException {
|
||||||
requireRunnable(definition);
|
PipelineDefinition definition = parseDefinition(json);
|
||||||
PolicyInputs inputs = toInputs(files);
|
PolicyInputs inputs = collectInputs(request);
|
||||||
|
|
||||||
SseEmitter emitter =
|
SseEmitter emitter =
|
||||||
new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs());
|
new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs());
|
||||||
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
|
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
|
||||||
|
|
||||||
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
|
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
|
||||||
// whenComplete runs on the worker thread after the run finishes, so the terminal event
|
// Close the stream with a terminal event once the run finishes. whenComplete runs on the
|
||||||
// never races the step events.
|
// engine's worker thread after the run is done, so this never races the step events.
|
||||||
handle.completion()
|
handle.completion()
|
||||||
.whenComplete(
|
.whenComplete(
|
||||||
(run, throwable) -> {
|
(run, throwable) -> {
|
||||||
@@ -151,80 +159,41 @@ public class PolicyController {
|
|||||||
description =
|
description =
|
||||||
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
|
"Stores a policy (trigger config + steps + output + metadata). A blank id is"
|
||||||
+ " assigned; returns the stored policy with its id.")
|
+ " assigned; returns the stored policy with its id.")
|
||||||
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
|
public ResponseEntity<Policy> savePolicy(@RequestBody String json) {
|
||||||
requirePolicyEditingAllowed();
|
Policy policy = parsePolicy(json);
|
||||||
Policy owned = resolveOwnership(policy);
|
requireAuthorizedForFolderAccess(policy);
|
||||||
try {
|
try {
|
||||||
policyValidator.validate(owned);
|
policyValidator.validate(policy);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(policyStore.save(owned));
|
return ResponseEntity.ok(policyStore.save(policy));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign owner + owning team server-side. Create stamps the current user and their team; update
|
* A policy that reads from or writes to a server folder grants whoever saves it access to that
|
||||||
* preserves the existing owner and team after verifying the policy belongs to the caller's team
|
* path, so restrict it to administrators on multi-user deployments. Single-user deployments
|
||||||
* — so the client can neither forge ownership/team on create nor reach across teams on update
|
* (login disabled, e.g. desktop) trust the local operator. The {@link FolderAccessGuard} still
|
||||||
* (a policy in another team reads as not-found).
|
* enforces SaaS-off and the path allowlist during validation regardless of who saves.
|
||||||
*/
|
*/
|
||||||
private Policy resolveOwnership(Policy incoming) {
|
private void requireAuthorizedForFolderAccess(Policy policy) {
|
||||||
String id = incoming.id();
|
if (!folderAccessGuard.usesFolderAccess(policy)) {
|
||||||
if (id != null && !id.isBlank()) {
|
return;
|
||||||
Policy existing = policyStore.get(id).orElse(null);
|
|
||||||
if (existing != null) {
|
|
||||||
if (!policyAccessGuard.canAccess(existing)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No policy: " + id);
|
|
||||||
}
|
}
|
||||||
return withOwnerAndTeam(incoming, existing.owner(), existing.teamId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return withOwnerAndTeam(
|
|
||||||
incoming,
|
|
||||||
policyAccessGuard.ownerForNewPolicy(),
|
|
||||||
policyAccessGuard.teamForNewPolicy());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Policy withOwnerAndTeam(Policy policy, String owner, Long teamId) {
|
|
||||||
return new Policy(
|
|
||||||
policy.id(),
|
|
||||||
policy.name(),
|
|
||||||
owner,
|
|
||||||
policy.enabled(),
|
|
||||||
policy.trigger(),
|
|
||||||
policy.sources(),
|
|
||||||
policy.steps(),
|
|
||||||
policy.output(),
|
|
||||||
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
|
|
||||||
* admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by
|
|
||||||
* {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume
|
|
||||||
* re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two
|
|
||||||
* covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login
|
|
||||||
* disabled) have no such role, so they trust the local operator. The path allowlist for folder
|
|
||||||
* sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
|
|
||||||
*/
|
|
||||||
private void requirePolicyEditingAllowed() {
|
|
||||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!policyManagementAuthority.canEditPolicies()) {
|
if (!userService.isCurrentUserAdmin()) {
|
||||||
throw new ResponseStatusException(
|
throw new ResponseStatusException(
|
||||||
HttpStatus.FORBIDDEN,
|
HttpStatus.FORBIDDEN,
|
||||||
"Policies may only be created or modified by a team leader");
|
"Folder sources and outputs may only be configured by an administrator");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(
|
@Operation(summary = "List policies")
|
||||||
summary = "List policies",
|
|
||||||
description = "Lists the policies belonging to the caller's team.")
|
|
||||||
public List<Policy> listPolicies() {
|
public List<Policy> listPolicies() {
|
||||||
return policyAccessGuard.visible(policyStore.all());
|
return policyStore.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{policyId}")
|
@GetMapping("/{policyId}")
|
||||||
@@ -232,7 +201,6 @@ public class PolicyController {
|
|||||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||||
return policyStore
|
return policyStore
|
||||||
.get(policyId)
|
.get(policyId)
|
||||||
.filter(policyAccessGuard::canAccess)
|
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
@@ -240,14 +208,9 @@ public class PolicyController {
|
|||||||
@DeleteMapping("/{policyId}")
|
@DeleteMapping("/{policyId}")
|
||||||
@Operation(summary = "Delete a policy by id")
|
@Operation(summary = "Delete a policy by id")
|
||||||
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
|
public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
|
||||||
requirePolicyEditingAllowed();
|
return policyStore.delete(policyId)
|
||||||
// Scope to the caller's team: a policy in another team reads as not-found.
|
? ResponseEntity.noContent().build()
|
||||||
boolean accessible =
|
: ResponseEntity.notFound().build();
|
||||||
policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent();
|
|
||||||
if (accessible && policyStore.delete(policyId)) {
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@@ -255,52 +218,70 @@ public class PolicyController {
|
|||||||
summary = "Run a stored policy",
|
summary = "Run a stored policy",
|
||||||
description =
|
description =
|
||||||
"Runs the stored policy's pipeline on the supplied files (primary documents"
|
"Runs the stored policy's pipeline on the supplied files (primary documents"
|
||||||
+ " under 'fileInput', supporting files under 'assets[i].key' /"
|
+ " under 'fileInput', supporting files under their asset-key fields)."
|
||||||
+ " 'assets[i].file'). Runs regardless of the policy's enabled flag,"
|
+ " Runs regardless of the policy's enabled flag, which only gates"
|
||||||
+ " which only gates automatic triggering. Returns a run id.")
|
+ " automatic triggering. Returns a run id.")
|
||||||
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
|
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
|
||||||
@PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files)
|
@PathVariable String policyId, MultipartHttpServletRequest request) throws IOException {
|
||||||
throws IOException {
|
|
||||||
Policy policy =
|
Policy policy =
|
||||||
policyStore
|
policyStore
|
||||||
.get(policyId)
|
.get(policyId)
|
||||||
.filter(policyAccessGuard::canAccess)
|
|
||||||
.orElseThrow(
|
.orElseThrow(
|
||||||
() ->
|
() ->
|
||||||
new ResponseStatusException(
|
new ResponseStatusException(
|
||||||
HttpStatus.NOT_FOUND, "No policy: " + policyId));
|
HttpStatus.NOT_FOUND, "No policy: " + policyId));
|
||||||
PolicyInputs inputs = toInputs(files);
|
PolicyInputs inputs = collectInputs(request);
|
||||||
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
|
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
|
||||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void requireRunnable(PipelineDefinition definition) {
|
private Policy parsePolicy(String json) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, Policy.class);
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PipelineDefinition parseDefinition(String json) {
|
||||||
|
PipelineDefinition definition;
|
||||||
|
try {
|
||||||
|
definition = objectMapper.readValue(json, PipelineDefinition.class);
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON");
|
||||||
|
}
|
||||||
if (definition.steps().isEmpty()) {
|
if (definition.steps().isEmpty()) {
|
||||||
throw new ResponseStatusException(
|
throw new ResponseStatusException(
|
||||||
HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
|
HttpStatus.BAD_REQUEST, "Pipeline definition has no steps");
|
||||||
}
|
}
|
||||||
|
return definition;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the
|
* Split the multipart file parts into the primary document stream ("fileInput") and the named
|
||||||
* named supporting-file store, where each asset's {@code key} is the name a step references
|
* supporting-file store: every other file field becomes an asset keyed by its field name, which
|
||||||
* from its {@code fileParameters}. Assets sharing a key are grouped, so a key may carry several
|
* a step references from {@code fileParameters}.
|
||||||
* files.
|
|
||||||
*/
|
*/
|
||||||
private PolicyInputs toInputs(PolicyRunFiles files) throws IOException {
|
private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException {
|
||||||
List<Resource> primary = toResources(files.getFileInput());
|
MultiValueMap<String, MultipartFile> fileMap = request.getMultiFileMap();
|
||||||
|
List<Resource> primary = toResources(fileMap.get("fileInput"));
|
||||||
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
|
Map<String, List<Resource>> supportingFiles = new LinkedHashMap<>();
|
||||||
for (NamedAsset asset : files.getAssets()) {
|
for (Map.Entry<String, List<MultipartFile>> entry : fileMap.entrySet()) {
|
||||||
Resource resource = toResource(asset.getFile());
|
if ("fileInput".equals(entry.getKey())) {
|
||||||
if (resource != null) {
|
continue;
|
||||||
supportingFiles
|
}
|
||||||
.computeIfAbsent(asset.getKey(), key -> new ArrayList<>())
|
List<Resource> assets = toResources(entry.getValue());
|
||||||
.add(resource);
|
if (!assets.isEmpty()) {
|
||||||
|
supportingFiles.put(entry.getKey(), assets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new PolicyInputs(primary, supportingFiles);
|
return new PolicyInputs(primary, supportingFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A progress listener that forwards each step transition to the SSE stream as a "step" event.
|
||||||
|
*/
|
||||||
private PolicyProgressListener streamListener(SseEmitter emitter) {
|
private PolicyProgressListener streamListener(SseEmitter emitter) {
|
||||||
return new PolicyProgressListener() {
|
return new PolicyProgressListener() {
|
||||||
@Override
|
@Override
|
||||||
@@ -339,8 +320,8 @@ public class PolicyController {
|
|||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
|
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
|
||||||
} catch (IOException | IllegalStateException e) {
|
} catch (IOException | IllegalStateException e) {
|
||||||
// Client gone or emitter closed. The run continues and outputs stay downloadable via
|
// Client disconnected or the emitter already closed. The run continues and its results
|
||||||
// the job endpoints.
|
// remain downloadable via the job endpoints; nothing useful left to stream.
|
||||||
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
|
log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,27 +332,20 @@ public class PolicyController {
|
|||||||
return resources;
|
return resources;
|
||||||
}
|
}
|
||||||
for (MultipartFile file : files) {
|
for (MultipartFile file : files) {
|
||||||
Resource resource = toResource(file);
|
|
||||||
if (resource != null) {
|
|
||||||
resources.add(resource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resources;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Spool a single uploaded file to a managed temp file, preserving its name; null if empty. */
|
|
||||||
private Resource toResource(MultipartFile file) throws IOException {
|
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
return null;
|
continue;
|
||||||
}
|
}
|
||||||
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
|
TempFile tempFile = tempFileManager.createManagedTempFile("policy-run");
|
||||||
file.transferTo(tempFile.getPath());
|
file.transferTo(tempFile.getPath());
|
||||||
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
|
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||||
return new FileSystemResource(tempFile.getFile()) {
|
resources.add(
|
||||||
|
new FileSystemResource(tempFile.getFile()) {
|
||||||
@Override
|
@Override
|
||||||
public String getFilename() {
|
public String getFilename() {
|
||||||
return originalName;
|
return originalName;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
package stirling.software.proprietary.policy.controller;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The files supplied to a policy run: the primary documents and any keyed supporting assets. Bound
|
|
||||||
* from the multipart request via {@code @ModelAttribute}; the pipeline definition itself travels as
|
|
||||||
* a separate typed {@code json} part.
|
|
||||||
*
|
|
||||||
* <p>Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code
|
|
||||||
* assets[i].file} for each supporting asset.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Schema(description = "Files for a policy run: primary documents plus keyed supporting assets")
|
|
||||||
public class PolicyRunFiles {
|
|
||||||
|
|
||||||
@Schema(description = "Primary input documents", format = "binary")
|
|
||||||
private List<MultipartFile> fileInput = new ArrayList<>();
|
|
||||||
|
|
||||||
@Valid
|
|
||||||
@Schema(description = "Supporting files, each bound to the asset key its step references")
|
|
||||||
private List<NamedAsset> assets = new ArrayList<>();
|
|
||||||
}
|
|
||||||
+49
-150
@@ -8,13 +8,9 @@ import java.util.UUID;
|
|||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
|
||||||
import org.slf4j.MDC;
|
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.RestClientResponseException;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -27,7 +23,6 @@ import stirling.software.common.service.JobQueue;
|
|||||||
import stirling.software.common.service.ResourceMonitor;
|
import stirling.software.common.service.ResourceMonitor;
|
||||||
import stirling.software.common.service.TaskManager;
|
import stirling.software.common.service.TaskManager;
|
||||||
import stirling.software.common.util.ExecutorFactory;
|
import stirling.software.common.util.ExecutorFactory;
|
||||||
import stirling.software.common.util.JobContext;
|
|
||||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||||
import stirling.software.proprietary.policy.model.Policy;
|
import stirling.software.proprietary.policy.model.Policy;
|
||||||
@@ -36,27 +31,33 @@ import stirling.software.proprietary.policy.model.PolicyRun;
|
|||||||
import stirling.software.proprietary.policy.model.WaitState;
|
import stirling.software.proprietary.policy.model.WaitState;
|
||||||
import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
||||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||||
import stirling.software.proprietary.service.DownstreamEntitlementError;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs pipelines asynchronously as tracked jobs. {@link #submit} returns a run id immediately; the
|
* Runs pipelines asynchronously as tracked jobs.
|
||||||
* pipeline runs on a virtual thread (so a step blocked on a slow tool does not hold a platform
|
|
||||||
* thread). Drives {@link PolicyExecutor} for the step loop, projects status/outputs into {@link
|
|
||||||
* TaskManager} (existing job endpoints work unchanged), and keeps live state in {@link
|
|
||||||
* PolicyRunRegistry}.
|
|
||||||
*
|
*
|
||||||
* <p>Manages its own virtual-thread execution rather than {@code JobExecutorService}, which
|
* <p>Each run is the unit of async work: {@link #submit} returns a run id immediately and the
|
||||||
* force-completes a job once its work returns: incompatible with a run that suspends in {@code
|
* pipeline executes on a virtual thread, so a step blocking on a slow tool does not tie up a
|
||||||
* WAITING_FOR_INPUT}. Still applies the shared {@link ResourceMonitor}/{@link JobQueue} admission
|
* platform thread. The run drives {@link PolicyExecutor} for the actual step loop, registers its
|
||||||
* control so heavy runs queue under load.
|
* outputs and progress with {@link TaskManager} (so the existing job status/download endpoints work
|
||||||
|
* unchanged), and keeps rich state in {@link PolicyRunRegistry}.
|
||||||
|
*
|
||||||
|
* <p>The engine deliberately manages its own virtual-thread execution rather than routing through
|
||||||
|
* {@code JobExecutorService}: that path force-completes a job once its work returns, which is
|
||||||
|
* incompatible with a run that suspends in {@code WAITING_FOR_INPUT}. It still applies the shared
|
||||||
|
* {@link ResourceMonitor}/{@link JobQueue} admission control, so heavy runs queue under load
|
||||||
|
* instead of oversubscribing.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PolicyEngine {
|
public class PolicyEngine {
|
||||||
|
|
||||||
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
|
/**
|
||||||
// files. See ResourceMonitor#shouldQueueJob(int).
|
* Resource weight of a pipeline run for admission control. A run chains many tools and holds
|
||||||
|
* intermediate files, so it is weighted as heavy work: the shared {@link ResourceMonitor}
|
||||||
|
* should let it start while the system is healthy but hold it back under memory/CPU pressure.
|
||||||
|
* See {@link ResourceMonitor#shouldQueueJob(int)} for how a weight maps to that decision.
|
||||||
|
*/
|
||||||
private static final int RUN_RESOURCE_WEIGHT = 50;
|
private static final int RUN_RESOURCE_WEIGHT = 50;
|
||||||
|
|
||||||
private final PolicyExecutor stepExecutor;
|
private final PolicyExecutor stepExecutor;
|
||||||
@@ -71,65 +72,27 @@ public class PolicyEngine {
|
|||||||
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
|
private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job
|
* Submit a pipeline to run asynchronously. The returned handle's run id scopes a job in {@link
|
||||||
* (status/notes/results observable via the job endpoints); its future resolves when the run
|
* TaskManager}, so progress (notes), status, and result files are observable via the existing
|
||||||
* reaches a terminal or paused state.
|
* job endpoints as well as via {@link #getRun(String)}; its completion future resolves when the
|
||||||
|
* run reaches a terminal or paused state.
|
||||||
*/
|
*/
|
||||||
public PolicyRunHandle submit(
|
public PolicyRunHandle submit(
|
||||||
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
|
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||||
// Ad-hoc run (no stored policy): bill whoever kicked it off and own the outputs as them
|
// Scope the run id to the current user (on this request thread) so the file-download
|
||||||
// too.
|
// ownership check passes; NoOpJobOwnershipService returns the id unchanged when security
|
||||||
// Capture the principal on this (request) thread — it does not survive the hop onto the
|
// is off.
|
||||||
// async
|
|
||||||
// worker.
|
|
||||||
String principal = currentActingPrincipal();
|
|
||||||
return submitForPrincipal(principal, principal, definition, inputs, listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
|
|
||||||
public PolicyRunHandle runPolicy(
|
|
||||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
|
||||||
// Bill the policy owner: trigger-fired runs have no security context, and the async worker
|
|
||||||
// doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable
|
|
||||||
// billing identity — and for org-wide policies the org/owner is meant to pay. But own the
|
|
||||||
// OUTPUT files as the user who triggered the run (captured here on the request thread) so
|
|
||||||
// they can download their enforced file; otherwise an org-wide policy's output is owned by
|
|
||||||
// the admin and the triggering user is denied it. Trigger-fired runs have no such user, so
|
|
||||||
// the owner owns those outputs.
|
|
||||||
String triggeringUser = currentActingPrincipal();
|
|
||||||
String fileOwner = triggeringUser != null ? triggeringUser : policy.owner();
|
|
||||||
return submitForPrincipal(
|
|
||||||
policy.owner(), fileOwner, policy.toDefinition(), inputs, listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PolicyRunHandle submitForPrincipal(
|
|
||||||
String billingPrincipal,
|
|
||||||
String fileOwner,
|
|
||||||
PipelineDefinition definition,
|
|
||||||
PolicyInputs inputs,
|
|
||||||
PolicyProgressListener listener) {
|
|
||||||
// Scope the run id to the current user (this request thread) so the file-download
|
|
||||||
// ownership check passes. No-op when security is off.
|
|
||||||
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
|
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
|
||||||
taskManager.createTask(runId);
|
taskManager.createTask(runId);
|
||||||
PolicyRun run = new PolicyRun(runId, definition);
|
PolicyRun run = new PolicyRun(runId, definition);
|
||||||
registry.register(run);
|
registry.register(run);
|
||||||
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
|
||||||
PolicyProgressListener tracking = trackingListener(runId, run, listener);
|
PolicyProgressListener tracking = trackingListener(runId, run, listener);
|
||||||
// Re-establish the acting principal as the audit principal on the worker thread. Each tool
|
Runnable task = () -> runToCompletion(run, inputs, tracking, completion);
|
||||||
// step dispatches via InternalApiClient, which resolves the caller from
|
|
||||||
// UserService.getCurrentUsername() — that has an MDC `auditPrincipal` fallback for async
|
|
||||||
// threads. Without this the worker has no identity, tool calls fall back to the
|
|
||||||
// INTERNAL_API_USER, and PAYG charges that system account instead of the owner's team.
|
|
||||||
Runnable task =
|
|
||||||
() ->
|
|
||||||
runAsPrincipal(
|
|
||||||
billingPrincipal,
|
|
||||||
fileOwner,
|
|
||||||
() -> runToCompletion(run, inputs, tracking, completion));
|
|
||||||
|
|
||||||
// One admission unit per run; steps run synchronously within it, so this gates heavy work
|
// Each run is one admission unit; steps run synchronously within it, so this gates heavy
|
||||||
// without the pool-within-pool risk of queueing each tool call.
|
// work under load without the pool-within-pool risk of queueing each tool call. Under
|
||||||
|
// resource pressure the run waits in the shared JobQueue; otherwise it starts immediately.
|
||||||
if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) {
|
if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) {
|
||||||
log.debug("Queueing policy run {} under resource pressure", runId);
|
log.debug("Queueing policy run {} under resource pressure", runId);
|
||||||
jobQueue.queueJob(
|
jobQueue.queueJob(
|
||||||
@@ -147,12 +110,22 @@ public class PolicyEngine {
|
|||||||
return new PolicyRunHandle(runId, completion);
|
return new PolicyRunHandle(runId, completion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a stored policy on demand. Builds the policy's pipeline and submits it. {@code enabled}
|
||||||
|
* gates automatic triggering, not explicit runs, so this runs regardless of that flag.
|
||||||
|
*/
|
||||||
|
public PolicyRunHandle runPolicy(
|
||||||
|
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||||
|
return submit(policy.toDefinition(), inputs, listener);
|
||||||
|
}
|
||||||
|
|
||||||
public PolicyRun getRun(String runId) {
|
public PolicyRun getRun(String runId) {
|
||||||
return registry.get(runId);
|
return registry.get(runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a run cancelled if not already finished. Does not yet interrupt an in-flight tool call.
|
* Request cancellation of a run. Stage 1 marks the run cancelled in the registry if it has not
|
||||||
|
* already finished; interrupting an in-flight tool call lands in a later stage.
|
||||||
*/
|
*/
|
||||||
public boolean cancel(String runId) {
|
public boolean cancel(String runId) {
|
||||||
PolicyRun run = registry.get(runId);
|
PolicyRun run = registry.get(runId);
|
||||||
@@ -166,7 +139,10 @@ public class PolicyEngine {
|
|||||||
return cancelled;
|
return cancelled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented. */
|
/**
|
||||||
|
* Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented; the run shape and
|
||||||
|
* {@link WaitState} snapshot are in place so this can be added without reworking the engine.
|
||||||
|
*/
|
||||||
public String resume(String runId, List<Resource> additionalInputs) {
|
public String resume(String runId, List<Resource> additionalInputs) {
|
||||||
throw new UnsupportedOperationException("Pause/resume is not yet implemented");
|
throw new UnsupportedOperationException("Pause/resume is not yet implemented");
|
||||||
}
|
}
|
||||||
@@ -187,8 +163,8 @@ public class PolicyEngine {
|
|||||||
taskManager.setComplete(runId);
|
taskManager.setComplete(runId);
|
||||||
run.complete(outputs);
|
run.complete(outputs);
|
||||||
} catch (PolicyInputRequiredException e) {
|
} catch (PolicyInputRequiredException e) {
|
||||||
// Expected path: suspend rather than fail. Persist intermediates as fileIds so the run
|
// Designed-for path: suspend the run rather than fail it. Persist intermediates as
|
||||||
// can resume after this worker thread is gone.
|
// fileIds so the run can resume after this worker thread is gone.
|
||||||
WaitState wait = suspend(e);
|
WaitState wait = suspend(e);
|
||||||
run.waitForInput(wait);
|
run.waitForInput(wait);
|
||||||
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
|
taskManager.addNote(runId, "Waiting for input: " + e.getMessage());
|
||||||
@@ -201,41 +177,21 @@ public class PolicyEngine {
|
|||||||
e.getMessage());
|
e.getMessage());
|
||||||
run.fail(message);
|
run.fail(message);
|
||||||
taskManager.setError(runId, 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) {
|
} catch (Exception e) {
|
||||||
String message = "Policy run failed: " + e.getMessage();
|
String message = "Policy run failed: " + e.getMessage();
|
||||||
log.error("Policy run {} failed", runId, e);
|
log.error("Policy run {} failed", runId, e);
|
||||||
run.fail(message);
|
run.fail(message);
|
||||||
taskManager.setError(runId, message);
|
taskManager.setError(runId, message);
|
||||||
} finally {
|
} finally {
|
||||||
// Always resolve so stream/await callers unblock.
|
// Always resolve the handle with the run's final state so stream/await callers unblock.
|
||||||
completion.complete(run);
|
completion.complete(run);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<?> failRejectedRun(
|
private ResponseEntity<?> failRejectedRun(
|
||||||
PolicyRun run, CompletableFuture<PolicyRun> completion, Throwable ex) {
|
PolicyRun run, CompletableFuture<PolicyRun> completion, Throwable ex) {
|
||||||
// Only reached if the run never started (e.g. queue full); a started run resolves its own
|
// Only reached if the run never started (e.g. the queue was full). A run that started
|
||||||
// completion in runToCompletion.
|
// always resolves its own completion in runToCompletion.
|
||||||
if (!completion.isDone()) {
|
if (!completion.isDone()) {
|
||||||
String message = "Policy run could not be queued: " + ex.getMessage();
|
String message = "Policy run could not be queued: " + ex.getMessage();
|
||||||
log.error("Policy run {} was not admitted: {}", run.getRunId(), ex.getMessage());
|
log.error("Policy run {} was not admitted: {}", run.getRunId(), ex.getMessage());
|
||||||
@@ -302,61 +258,4 @@ public class PolicyEngine {
|
|||||||
"The %s tool did not respond within %d seconds and was aborted.",
|
"The %s tool did not respond within %d seconds and was aborted.",
|
||||||
e.getEndpointPath(), e.getReadTimeout().toSeconds());
|
e.getEndpointPath(), e.getReadTimeout().toSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* MDC key {@code UserService.getCurrentUsername()} reads as its async fallback (stamped by the
|
|
||||||
* controller audit aspect on request threads). We reuse it to carry the billing identity onto
|
|
||||||
* the policy worker thread.
|
|
||||||
*/
|
|
||||||
private static final String AUDIT_PRINCIPAL_MDC_KEY = "auditPrincipal";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The username to bill an ad-hoc run to, captured on the submitting (request) thread. Prefers
|
|
||||||
* the audit principal the controller aspect already stamped; falls back to the security context
|
|
||||||
* name. {@code anonymousUser} (and no identity) resolve to null so we don't try to bill it.
|
|
||||||
*/
|
|
||||||
private static String currentActingPrincipal() {
|
|
||||||
String mdc = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
|
|
||||||
if (mdc != null && !mdc.isBlank()) {
|
|
||||||
return mdc;
|
|
||||||
}
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
if (auth == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String name = auth.getName();
|
|
||||||
return "anonymousUser".equals(name) ? null : name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run {@code body} with {@code principal} set as the audit principal in MDC, so async tool
|
|
||||||
* 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) {
|
|
||||||
// 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).
|
|
||||||
String previousPrincipal = MDC.get(AUDIT_PRINCIPAL_MDC_KEY);
|
|
||||||
String previousOwner = JobContext.getOwner();
|
|
||||||
if (billingPrincipal != null && !billingPrincipal.isBlank()) {
|
|
||||||
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, billingPrincipal);
|
|
||||||
}
|
|
||||||
if (fileOwner != null && !fileOwner.isBlank()) {
|
|
||||||
JobContext.setOwner(fileOwner);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
body.run();
|
|
||||||
} finally {
|
|
||||||
if (previousPrincipal != null) {
|
|
||||||
MDC.put(AUDIT_PRINCIPAL_MDC_KEY, previousPrincipal);
|
|
||||||
} else {
|
|
||||||
MDC.remove(AUDIT_PRINCIPAL_MDC_KEY);
|
|
||||||
}
|
|
||||||
JobContext.setOwner(previousOwner);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -7,8 +7,11 @@ import org.springframework.core.io.Resource;
|
|||||||
import tools.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
|
* Result of running a pipeline through {@link PolicyExecutor}.
|
||||||
* {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
|
*
|
||||||
* null if no step produced one.
|
* <p>{@code files} are the final output resources (temp files, not yet stored to {@code
|
||||||
|
* FileStorage}). {@code report} is the structured metadata payload captured from the last step that
|
||||||
|
* produced one (a JSON body, or an {@code X-Stirling-Tool-Report} header), with {@code reportTool}
|
||||||
|
* naming the step it came from; both are null when no step produced a report.
|
||||||
*/
|
*/
|
||||||
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
|
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
|
||||||
|
|||||||
+55
-27
@@ -35,11 +35,15 @@ import tools.jackson.databind.JsonNode;
|
|||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs an ordered chain of tool steps, feeding each step's output files into the next.
|
* Runs an ordered chain of tool steps, chaining each step's output files into the next step's
|
||||||
|
* input.
|
||||||
*
|
*
|
||||||
* <p>Steps dispatch synchronously via {@link InternalApiClient} loopback HTTP (each tool runs in
|
* <p>This is the single execution loop for the proprietary surface (AI plans now;
|
||||||
* its own handler, returns its file inline). The caller controls threading. Files cross step
|
* manually-triggered runs and watched folders later). Each step is dispatched synchronously via
|
||||||
* boundaries as {@link Resource} temp files and are only persisted at the run boundaries by the
|
* {@link InternalApiClient} loopback HTTP: the tool runs in its own handler and returns its file
|
||||||
|
* inline. The caller decides how to run the executor itself (the AI turn loop calls it directly;
|
||||||
|
* the engine runs it on a virtual thread for async runs). Files cross step boundaries as {@link
|
||||||
|
* Resource} temp files; they are only persisted to durable storage at the run boundaries by the
|
||||||
* caller.
|
* caller.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -54,16 +58,25 @@ public class PolicyExecutor {
|
|||||||
private final TempFileManager tempFileManager;
|
private final TempFileManager tempFileManager;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
// files: result files (one, or many for ZIP-response tools). report: optional structured
|
/**
|
||||||
// payload the tool surfaced alongside or instead of a file.
|
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
|
||||||
|
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
|
||||||
|
* payload the tool chose to surface alongside (or instead of) a file.
|
||||||
|
*/
|
||||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run every step in order, feeding each step's output into the next. Supporting files in {@code
|
* Execute every step in {@code definition} in order, feeding each step's output into the next.
|
||||||
* inputs} bind to named file fields and never enter the document stream.
|
* Supporting files supplied in {@code inputs} are bound to steps' named file fields and never
|
||||||
|
* enter the document stream.
|
||||||
*
|
*
|
||||||
|
* @param definition the pipeline to run (must have at least one step)
|
||||||
|
* @param inputs the primary documents plus the named supporting-file store
|
||||||
|
* @param listener receives per-step progress
|
||||||
|
* @return the final output files plus the last structured report produced, if any
|
||||||
* @throws InternalApiTimeoutException if a tool does not respond within its read timeout
|
* @throws InternalApiTimeoutException if a tool does not respond within its read timeout
|
||||||
* @throws IOException on a non-OK tool response, a missing supporting file, or a read failure
|
* @throws IOException if a tool returns a non-OK response, references a missing supporting
|
||||||
|
* file, or a file cannot be read
|
||||||
*/
|
*/
|
||||||
public PolicyExecutionResult execute(
|
public PolicyExecutionResult execute(
|
||||||
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener)
|
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener)
|
||||||
@@ -75,7 +88,7 @@ public class PolicyExecutor {
|
|||||||
|
|
||||||
List<Resource> currentFiles = inputs.primary();
|
List<Resource> currentFiles = inputs.primary();
|
||||||
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
|
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
|
||||||
// Last non-null report wins: the terminal step defines the output.
|
// Propagate the *last* non-null report; the terminal step defines the output.
|
||||||
JsonNode lastReport = null;
|
JsonNode lastReport = null;
|
||||||
String lastReportTool = null;
|
String lastReportTool = null;
|
||||||
|
|
||||||
@@ -100,9 +113,13 @@ public class PolicyExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-input endpoints get all files in one call; others are called once per file. ZIP
|
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
|
||||||
* responses are unpacked so each inner file is its own result (e.g. split). For per-file
|
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
|
||||||
* dispatch the first non-null report wins.
|
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
|
||||||
|
*
|
||||||
|
* <p>A structured {@code report} may be returned alongside (or instead of) files; see {@link
|
||||||
|
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
|
||||||
|
* non-null report wins.
|
||||||
*/
|
*/
|
||||||
private ToolResult executeStep(
|
private ToolResult executeStep(
|
||||||
PipelineStep step,
|
PipelineStep step,
|
||||||
@@ -129,10 +146,17 @@ public class PolicyExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call an endpoint, returning result files and optional report. Response handling: JSON body is
|
* Call an endpoint and return its result files and optional report.
|
||||||
* the report with no file; a file body returns the file plus any {@link
|
*
|
||||||
* AiToolResponseHeaders#TOOL_REPORT} header report; ZIP responses (per tool metadata) are
|
* <ul>
|
||||||
* unpacked to a flat file list.
|
* <li>JSON body (Content-Type: application/json): the entire body is the report, no files are
|
||||||
|
* returned.
|
||||||
|
* <li>File body (PDF etc.): the file is returned; if an {@link
|
||||||
|
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
|
||||||
|
* parsed as the report.
|
||||||
|
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
|
||||||
|
* a flat list of result files.
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
private ToolResult callEndpoint(
|
private ToolResult callEndpoint(
|
||||||
PipelineStep step, List<Resource> files, Map<String, List<Resource>> supportingFiles)
|
PipelineStep step, List<Resource> files, Map<String, List<Resource>> supportingFiles)
|
||||||
@@ -142,8 +166,8 @@ public class PolicyExecutor {
|
|||||||
for (Resource file : files) {
|
for (Resource file : files) {
|
||||||
body.add("fileInput", file);
|
body.add("fileInput", file);
|
||||||
}
|
}
|
||||||
// Bind supporting files to named tool fields (e.g. stampImage); from the asset store, not
|
// Bind supporting files to their named tool fields (e.g. stampImage, overlayFiles). These
|
||||||
// the document stream.
|
// come from the run's named asset store, not the document stream.
|
||||||
for (Map.Entry<String, String> binding : step.fileParameters().entrySet()) {
|
for (Map.Entry<String, String> binding : step.fileParameters().entrySet()) {
|
||||||
String fieldName = binding.getKey();
|
String fieldName = binding.getKey();
|
||||||
String assetKey = binding.getValue();
|
String assetKey = binding.getValue();
|
||||||
@@ -165,9 +189,9 @@ public class PolicyExecutor {
|
|||||||
for (Map.Entry<String, Object> entry : step.parameters().entrySet()) {
|
for (Map.Entry<String, Object> entry : step.parameters().entrySet()) {
|
||||||
if (entry.getValue() instanceof List<?> list) {
|
if (entry.getValue() instanceof List<?> list) {
|
||||||
if (containsStructuredElements(list)) {
|
if (containsStructuredElements(list)) {
|
||||||
// These endpoints (e.g. /security/redact redactions, /general/edit-text edits)
|
// Endpoints binding lists of structured objects (e.g. /security/redact's
|
||||||
// bind a list of structured objects from a single JSON string field via a
|
// redactions, /general/edit-text's edits) parse a single JSON string field via
|
||||||
// property editor, so pre-serialize the whole list.
|
// a property editor. Pre-serialize the whole list so binding succeeds.
|
||||||
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
|
body.add(entry.getKey(), objectMapper.writeValueAsString(list));
|
||||||
} else {
|
} else {
|
||||||
for (Object item : list) {
|
for (Object item : list) {
|
||||||
@@ -185,8 +209,8 @@ public class PolicyExecutor {
|
|||||||
}
|
}
|
||||||
Resource resource = response.getBody();
|
Resource resource = response.getBody();
|
||||||
|
|
||||||
// Filter ops return an empty body to mean "filtered out": drop it rather than forward a
|
// Filter operations return an empty body to signal the file was filtered out: drop it
|
||||||
// zero-byte document.
|
// rather than forwarding a zero-byte document.
|
||||||
if (isFilterOperation(endpointPath) && isEmpty(resource)) {
|
if (isFilterOperation(endpointPath) && isEmpty(resource)) {
|
||||||
return new ToolResult(List.of(), null);
|
return new ToolResult(List.of(), null);
|
||||||
}
|
}
|
||||||
@@ -194,7 +218,7 @@ public class PolicyExecutor {
|
|||||||
HttpHeaders headers = response.getHeaders();
|
HttpHeaders headers = response.getHeaders();
|
||||||
MediaType contentType = headers.getContentType();
|
MediaType contentType = headers.getContentType();
|
||||||
|
|
||||||
// JSON-only response: whole body is the report, no file.
|
// JSON-only response: the whole body is the structured report, no result file.
|
||||||
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||||
try (InputStream is = resource.getInputStream()) {
|
try (InputStream is = resource.getInputStream()) {
|
||||||
JsonNode report = objectMapper.readTree(is);
|
JsonNode report = objectMapper.readTree(is);
|
||||||
@@ -209,7 +233,10 @@ public class PolicyExecutor {
|
|||||||
return new ToolResult(List.of(resource), report);
|
return new ToolResult(List.of(resource), report);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header, or null. */
|
/**
|
||||||
|
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
|
||||||
|
* or return null.
|
||||||
|
*/
|
||||||
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
|
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
|
||||||
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
|
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
|
||||||
if (raw == null || raw.isBlank()) {
|
if (raw == null || raw.isBlank()) {
|
||||||
@@ -237,7 +264,8 @@ public class PolicyExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fail if any primary-stream file is a type the step rejects. No declared type means anything.
|
* Fail the run if any document in the primary stream is not a file type the step accepts. An
|
||||||
|
* endpoint that declares no specific input type accepts anything.
|
||||||
*/
|
*/
|
||||||
private void requireAcceptedTypes(String operation, List<Resource> files) throws IOException {
|
private void requireAcceptedTypes(String operation, List<Resource> files) throws IOException {
|
||||||
List<String> accepted = toolMetadataService.getExtensionTypes(false, operation);
|
List<String> accepted = toolMetadataService.getExtensionTypes(false, operation);
|
||||||
|
|||||||
+9
-3
@@ -7,9 +7,15 @@ import org.springframework.core.io.Resource;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown by a step that needs further user input, pausing the run in {@code WAITING_FOR_INPUT}
|
* Thrown by a step to signal that the run cannot proceed without further user input, pausing the
|
||||||
* instead of failing. Carries the resume reason, 0-based resume step index, and intermediate files;
|
* run in {@code WAITING_FOR_INPUT} rather than failing it.
|
||||||
* the engine persists those and suspends. Not yet thrown by any step.
|
*
|
||||||
|
* <p>Carries everything needed to resume: a human-readable reason, the 0-based index of the step to
|
||||||
|
* resume from, and the intermediate files produced so far. The engine persists those files and
|
||||||
|
* suspends the run.
|
||||||
|
*
|
||||||
|
* <p>Defined now to fix the run shape; no step throws it yet, and the resume handshake is
|
||||||
|
* implemented in a later stage.
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
public class PolicyInputRequiredException extends RuntimeException {
|
public class PolicyInputRequiredException extends RuntimeException {
|
||||||
|
|||||||
+7
-4
@@ -5,9 +5,12 @@ import java.util.concurrent.CompletableFuture;
|
|||||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returned by {@link PolicyEngine#submit}: the run id (status polling, result download) plus a
|
* Returned by {@link PolicyEngine#submit}: the run id (for status polling and result download) plus
|
||||||
* future that resolves when the run reaches a terminal or paused state. The future carries the
|
* a future that resolves when the run reaches a terminal or paused state.
|
||||||
* {@link PolicyRun} whose status describes the outcome; it does not complete exceptionally for
|
*
|
||||||
* ordinary run failures.
|
* <p>The completion future lets callers react to the end of a run (e.g. an SSE endpoint sending a
|
||||||
|
* final event and closing the stream) without polling. It carries the {@link PolicyRun} whose
|
||||||
|
* status describes the outcome (completed, failed, cancelled, or waiting for input); it does not
|
||||||
|
* complete exceptionally for ordinary run failures.
|
||||||
*/
|
*/
|
||||||
public record PolicyRunHandle(String runId, CompletableFuture<PolicyRun> completion) {}
|
public record PolicyRunHandle(String runId, CompletableFuture<PolicyRun> completion) {}
|
||||||
|
|||||||
+13
-8
@@ -19,12 +19,16 @@ import stirling.software.common.model.ApplicationProperties;
|
|||||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory store of live {@link PolicyRun} state, keyed by runId. Authoritative run state machine;
|
* In-memory store of live {@link PolicyRun} state, keyed by runId. Holds the authoritative run
|
||||||
* durable status/files are projected separately into {@code TaskManager}.
|
* state machine; durable status/files for download are projected separately into {@code
|
||||||
|
* TaskManager}.
|
||||||
*
|
*
|
||||||
* <p>A scheduled sweep evicts only terminal runs aged past {@code policies.runExpiryMinutes};
|
* <p>Finished runs are evicted on a fixed interval once they age past {@code
|
||||||
* active and paused runs are kept regardless of age. Eviction frees only this map's entry: the
|
* policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a run's
|
||||||
* shared {@code TaskManager} job owns file-lifecycle cleanup.
|
* rich in-memory state does not outlive the process. Only terminal runs are evicted; active and
|
||||||
|
* paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not
|
||||||
|
* touched here: a run shares its runId with a {@code TaskManager} job, which owns file-lifecycle
|
||||||
|
* cleanup, so eviction only frees this map's entry.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -57,7 +61,7 @@ public class PolicyRunRegistry {
|
|||||||
return runs.values();
|
return runs.values();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scheduled sweep entry point. */
|
/** Scheduled hook: evict terminal runs that finished before the expiry window. */
|
||||||
private void evictExpiredRuns() {
|
private void evictExpiredRuns() {
|
||||||
try {
|
try {
|
||||||
evictExpired(Instant.now().minus(runExpiry));
|
evictExpired(Instant.now().minus(runExpiry));
|
||||||
@@ -67,8 +71,9 @@ public class PolicyRunRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evict terminal runs last updated before {@code cutoff}, returning the count. Package-visible
|
* Remove every terminal run last updated before {@code cutoff}; active and paused runs are kept
|
||||||
* so the sweep and tests share one path with an explicit cutoff.
|
* regardless of age. Returns the number evicted. Package-visible so the scheduled sweep and
|
||||||
|
* tests exercise the same path with an explicit cutoff.
|
||||||
*/
|
*/
|
||||||
int evictExpired(Instant cutoff) {
|
int evictExpired(Instant cutoff) {
|
||||||
int removed = 0;
|
int removed = 0;
|
||||||
|
|||||||
+17
-6
@@ -20,8 +20,14 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
|||||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide <em>when</em>
|
* Runs policies, and is the one place that knows how to turn a policy's configured {@link InputSpec
|
||||||
* and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
|
* sources} into actual runs. Triggers (schedule, and future webhook/folder-watch) decide
|
||||||
|
* <em>when</em> to run and call {@link #run(Policy)}; they never touch sources themselves. The
|
||||||
|
* controller uses the supplied-input and ad-hoc entry points for on-demand work.
|
||||||
|
*
|
||||||
|
* <p>This is the seam that keeps triggers and sources independent: a trigger depends on the runner,
|
||||||
|
* the runner depends on the {@link InputSource} beans, and a source depends on neither - it just
|
||||||
|
* yields {@link ResolvedInput units of work}, each carrying its own completion hook.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -32,9 +38,10 @@ public class PolicyRunner {
|
|||||||
private final List<InputSource> inputSources;
|
private final List<InputSource> inputSources;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger entry point. Pulls every configured source; each yielded unit becomes its own run so
|
* Run a policy by pulling from every source it configures: each source yields zero or more
|
||||||
* one failure does not affect the others. No sources means one run with no input (generator
|
* units of work, and each unit becomes its own run so one failure does not affect the others. A
|
||||||
* pipeline).
|
* policy with no sources runs once with no input files (a generator pipeline). Used by
|
||||||
|
* automatic triggers.
|
||||||
*/
|
*/
|
||||||
public void run(Policy policy) {
|
public void run(Policy policy) {
|
||||||
List<InputSpec> sources = policy.sources();
|
List<InputSpec> sources = policy.sources();
|
||||||
@@ -47,7 +54,11 @@ public class PolicyRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
|
/**
|
||||||
|
* Run a stored policy on files supplied directly by the caller (e.g. a manual run with
|
||||||
|
* uploads), bypassing its configured sources. Returns the run handle so callers can stream
|
||||||
|
* progress.
|
||||||
|
*/
|
||||||
public PolicyRunHandle runWith(
|
public PolicyRunHandle runWith(
|
||||||
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
|
||||||
return policyEngine.runPolicy(policy, inputs, listener);
|
return policyEngine.runPolicy(policy, inputs, listener);
|
||||||
|
|||||||
+8
-4
@@ -15,9 +15,12 @@ import stirling.software.proprietary.policy.output.PolicyOutputSink;
|
|||||||
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean
|
* Validates a policy's trigger, sources, and output configuration by delegating each facet to the
|
||||||
* that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger
|
* bean that handles its type. Called when a policy is saved so a misconfigured schedule, missing
|
||||||
* is a manual-only policy and skips trigger validation.
|
* folder directory, or unknown type fails fast instead of silently misbehaving at run time.
|
||||||
|
*
|
||||||
|
* <p>The trigger is optional (a {@code null} trigger is a manual-only policy and needs no
|
||||||
|
* validation); every configured source is validated.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -28,7 +31,8 @@ public class PolicyValidator {
|
|||||||
private final List<PolicyOutputSink> outputSinks;
|
private final List<PolicyOutputSink> outputSinks;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws IllegalArgumentException if any facet's type is unknown or its config is invalid
|
* @throws IllegalArgumentException if any facet's type is unknown or its configuration is
|
||||||
|
* invalid
|
||||||
*/
|
*/
|
||||||
public void validate(Policy policy) {
|
public void validate(Policy policy) {
|
||||||
if (policy.trigger() != null) {
|
if (policy.trigger() != null) {
|
||||||
|
|||||||
+17
-8
@@ -22,13 +22,21 @@ import stirling.software.proprietary.policy.model.InputSpec;
|
|||||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads input files from a directory; each ready file is its own unit of work so one failure does
|
* Reads input files from a directory. Each ready file becomes its own unit of work (one run per
|
||||||
* not affect the others.
|
* file) so a failure on one file does not affect the others.
|
||||||
*
|
*
|
||||||
* <p>Mode option: "consume" (default) claims each file by moving it into {@code
|
* <p>Two modes via the {@code mode} option:
|
||||||
* .stirling/processing} then routes it to {@code .stirling/done} or {@code .stirling/error}, so
|
*
|
||||||
* each file runs once; "snapshot" reads without moving, so every run sees the full set. Readiness
|
* <ul>
|
||||||
* is checked first so files mid-write are skipped.
|
* <li>{@code "consume"} (default) - claim each file by moving it into {@code
|
||||||
|
* .stirling/processing}, then route it to {@code .stirling/done} or {@code .stirling/error}
|
||||||
|
* when its run finishes. Each file is processed once; right for "process new arrivals" (and
|
||||||
|
* the basis of watched folders).
|
||||||
|
* <li>{@code "snapshot"} - read the directory's current files without moving them; every run sees
|
||||||
|
* the full set again. Right for "always regenerate from a fixed input set".
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Readiness is checked first (via {@link FileReadinessChecker}) so files mid-write are skipped.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -36,7 +44,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
|||||||
public class FolderInputSource implements InputSource {
|
public class FolderInputSource implements InputSource {
|
||||||
|
|
||||||
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
||||||
// Bookkeeping lives under one hidden dir so the watched folder stays tidy.
|
// Bookkeeping lives under one hidden namespace dir so the watched folder stays tidy.
|
||||||
private static final String WORK_SUBDIR = ".stirling";
|
private static final String WORK_SUBDIR = ".stirling";
|
||||||
private static final String PROCESSING_SUBDIR = "processing";
|
private static final String PROCESSING_SUBDIR = "processing";
|
||||||
private static final String DONE_SUBDIR = "done";
|
private static final String DONE_SUBDIR = "done";
|
||||||
@@ -99,7 +107,6 @@ public class FolderInputSource implements InputSource {
|
|||||||
return work;
|
return work;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic move into processing/: only one sweep can win the claim, the rest see the file gone.
|
|
||||||
private Path claim(Path inputDir, Path file) {
|
private Path claim(Path inputDir, Path file) {
|
||||||
try {
|
try {
|
||||||
Path processingDir = workDir(inputDir, PROCESSING_SUBDIR);
|
Path processingDir = workDir(inputDir, PROCESSING_SUBDIR);
|
||||||
@@ -128,6 +135,7 @@ public class FolderInputSource implements InputSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A bookkeeping subdirectory under the watched folder's {@code .stirling} namespace. */
|
||||||
private static Path workDir(Path inputDir, String subdir) {
|
private static Path workDir(Path inputDir, String subdir) {
|
||||||
return inputDir.resolve(WORK_SUBDIR).resolve(subdir);
|
return inputDir.resolve(WORK_SUBDIR).resolve(subdir);
|
||||||
}
|
}
|
||||||
@@ -158,6 +166,7 @@ public class FolderInputSource implements InputSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The typed, validated form of a folder source's options: the directory and dedup mode. */
|
||||||
record FolderConfig(Path directory, boolean snapshot) {
|
record FolderConfig(Path directory, boolean snapshot) {
|
||||||
|
|
||||||
private static final String DIRECTORY_OPTION = "directory";
|
private static final String DIRECTORY_OPTION = "directory";
|
||||||
|
|||||||
+19
-8
@@ -7,9 +7,14 @@ import java.util.List;
|
|||||||
import stirling.software.proprietary.policy.model.InputSpec;
|
import stirling.software.proprietary.policy.model.InputSpec;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a policy {@link InputSpec} into the files to run on. Implementations are beans selected
|
* Resolves one of a policy's {@link InputSpec sources} into the files to run on - answering
|
||||||
* by {@link #supports(InputSpec)}, so a new source kind (folder, S3) is just a new bean. A manual
|
* <em>where</em> a run's files come from, independent of <em>when</em> it runs. The counterpart of
|
||||||
* run may supply files directly and bypass sources entirely.
|
* {@code PolicyOutputSink}: implementations are beans selected by {@link #supports(InputSpec)}, so
|
||||||
|
* a new source kind (folder, S3) is just a new bean.
|
||||||
|
*
|
||||||
|
* <p>Driven by the {@code PolicyRunner}, which a trigger calls when a policy is due; a source is
|
||||||
|
* passive and knows nothing about what triggered the run. A manual run may instead supply files
|
||||||
|
* directly and bypass sources entirely.
|
||||||
*/
|
*/
|
||||||
public interface InputSource {
|
public interface InputSource {
|
||||||
|
|
||||||
@@ -19,18 +24,24 @@ public interface InputSource {
|
|||||||
/** Whether this source can handle the given spec. */
|
/** Whether this source can handle the given spec. */
|
||||||
boolean supports(InputSpec spec);
|
boolean supports(InputSpec spec);
|
||||||
|
|
||||||
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
|
/**
|
||||||
|
* Check that an input spec is usable, throwing {@link IllegalArgumentException} if not. Called
|
||||||
|
* when a policy is saved so misconfiguration fails fast rather than at run time.
|
||||||
|
*/
|
||||||
default void validate(InputSpec spec) {}
|
default void validate(InputSpec spec) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the spec into zero or more units of work, each carrying one run's files and a
|
* Resolve the spec into zero or more units of work, each carrying the files for one run and a
|
||||||
* completion hook. Empty list means nothing to run right now.
|
* completion hook. Returning an empty list means there is nothing to run right now.
|
||||||
*/
|
*/
|
||||||
List<ResolvedInput> resolve(InputSpec spec) throws IOException;
|
List<ResolvedInput> resolve(InputSpec spec) throws IOException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filesystem dirs this source draws from, for the folder-watch trigger. Advisory: resolving is
|
* The local filesystem directories this source draws from, if any, for triggers that want to
|
||||||
* still done by {@link #resolve}. Non-filesystem sources return empty and are not watchable.
|
* react to changes there (the folder-watch trigger) rather than poll. Advisory only: it merely
|
||||||
|
* tells a trigger <em>where</em> to watch; resolving the spec into files is still this source's
|
||||||
|
* job via {@link #resolve}. Non-filesystem sources (S3, ...) return an empty list and so are
|
||||||
|
* simply not watchable. Default: nothing to watch.
|
||||||
*/
|
*/
|
||||||
default List<Path> watchTargets(InputSpec spec) {
|
default List<Path> watchTargets(InputSpec spec) {
|
||||||
return List.of();
|
return List.of();
|
||||||
|
|||||||
+5
-4
@@ -5,9 +5,10 @@ import java.util.function.Consumer;
|
|||||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One unit of work from an {@link InputSource}: the files to run plus a completion callback invoked
|
* One unit of work produced by an {@link InputSource}: the files to run plus a completion callback
|
||||||
* with the run's success (e.g. a folder source routes the input to done/error). A source may return
|
* invoked with the run's success once it finishes (e.g. a folder source routes the input to {@code
|
||||||
* several of these, one per file.
|
* .stirling/done} or {@code .stirling/error}). A source may return several of these (e.g. one per
|
||||||
|
* file).
|
||||||
*/
|
*/
|
||||||
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ public record ResolvedInput(PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
|||||||
onComplete = onComplete == null ? success -> {} : onComplete;
|
onComplete = onComplete == null ? success -> {} : onComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No completion side effect. */
|
/** A unit of work with no completion side effect. */
|
||||||
public static ResolvedInput of(PolicyInputs inputs) {
|
public static ResolvedInput of(PolicyInputs inputs) {
|
||||||
return new ResolvedInput(inputs, success -> {});
|
return new ResolvedInput(inputs, success -> {});
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -3,8 +3,15 @@ package stirling.software.proprietary.policy.model;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One input source for a policy. {@code type} keys an {@code InputSource} bean; a run pulls from
|
* One source a policy's input files come from. {@code type} selects an {@code InputSource}
|
||||||
* every source.
|
* ("folder", and in future "s3", ...); {@code options} carries source-specific configuration (a
|
||||||
|
* directory, a bucket, a dedup mode, ...).
|
||||||
|
*
|
||||||
|
* <p>A policy holds a list of these; a run pulls from every one. An empty list means the policy
|
||||||
|
* runs with no input files (a generator pipeline, or files supplied directly to a manual run).
|
||||||
|
*
|
||||||
|
* <p>Data-driven and parallel to {@link OutputSpec}/{@link TriggerConfig}: a new source kind is a
|
||||||
|
* new {@code type} handled by a new {@code InputSource} bean.
|
||||||
*/
|
*/
|
||||||
public record InputSpec(String type, Map<String, Object> options) {
|
public record InputSpec(String type, Map<String, Object> options) {
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -2,13 +2,19 @@ package stirling.software.proprietary.policy.model;
|
|||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** Where a run's outputs are delivered. {@code type} keys a {@code PolicyOutputSink} bean. */
|
/**
|
||||||
|
* Describes where a pipeline run's output files should be delivered. {@code type} selects a {@code
|
||||||
|
* PolicyOutputSink} (e.g. "inline"); {@code options} carries sink-specific configuration.
|
||||||
|
*
|
||||||
|
* <p>New destinations (folder, S3) are added as new sink beans keyed on a new {@code type} without
|
||||||
|
* changing this shape or the engine.
|
||||||
|
*/
|
||||||
public record OutputSpec(String type, Map<String, Object> options) {
|
public record OutputSpec(String type, Map<String, Object> options) {
|
||||||
public OutputSpec {
|
public OutputSpec {
|
||||||
options = options == null ? Map.of() : options;
|
options = options == null ? Map.of() : options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default sink: store outputs and return them to the caller for download. */
|
/** The default destination: store outputs and return them to the caller for download. */
|
||||||
public static OutputSpec inline() {
|
public static OutputSpec inline() {
|
||||||
return new OutputSpec("inline", Map.of());
|
return new OutputSpec("inline", Map.of());
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -3,10 +3,11 @@ package stirling.software.proprietary.policy.model;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An ordered chain of tool steps plus an output destination; the unit the engine executes.
|
* An ordered chain of tool steps plus where the output should go.
|
||||||
*
|
*
|
||||||
* <p>{@code output} may be null for callers that handle result files themselves (e.g. the AI
|
* <p>This is the single shape executed by the policy engine, shared by AI plans, manually-triggered
|
||||||
* workflow, which builds its own response payload).
|
* runs, and (later) watched folders. {@code output} may be null for callers that handle result
|
||||||
|
* files themselves (e.g. the AI workflow, which builds its own response payload).
|
||||||
*/
|
*/
|
||||||
public record PipelineDefinition(String name, List<PipelineStep> steps, OutputSpec output) {
|
public record PipelineDefinition(String name, List<PipelineStep> steps, OutputSpec output) {
|
||||||
public PipelineDefinition {
|
public PipelineDefinition {
|
||||||
|
|||||||
+10
-6
@@ -3,13 +3,17 @@ package stirling.software.proprietary.policy.model;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single tool invocation. {@code operation} is a Stirling endpoint path (e.g. {@code
|
* A single tool invocation in a pipeline: the API endpoint path to call and the inputs to pass.
|
||||||
* /api/v1/misc/compress-pdf}) per the {@code InternalApiClient} convention; {@code parameters} are
|
|
||||||
* scalar form fields.
|
|
||||||
*
|
*
|
||||||
* <p>{@code fileParameters} maps a tool's named file field (e.g. {@code stampImage}, beyond the
|
* <p>{@code operation} is a Stirling tool endpoint path (e.g. {@code /api/v1/misc/compress-pdf}),
|
||||||
* primary {@code fileInput} stream) to an asset key in the run's supporting-file store, keeping
|
* matching the dispatch convention used by {@code InternalApiClient}. {@code parameters} are the
|
||||||
* supporting inputs out of the document stream that flows step to step.
|
* tool-specific scalar form fields.
|
||||||
|
*
|
||||||
|
* <p>{@code fileParameters} binds a tool's named file fields (beyond the primary {@code fileInput}
|
||||||
|
* stream) to supporting files supplied with the run: it maps the form field name (e.g. {@code
|
||||||
|
* stampImage}, {@code overlayFiles}) to an asset key in the run's supporting-file store. This keeps
|
||||||
|
* supporting inputs (a stamp image, a certificate, an overlay) out of the document stream that
|
||||||
|
* flows step to step.
|
||||||
*/
|
*/
|
||||||
public record PipelineStep(
|
public record PipelineStep(
|
||||||
String operation, Map<String, Object> parameters, Map<String, String> fileParameters) {
|
String operation, Map<String, Object> parameters, Map<String, String> fileParameters) {
|
||||||
|
|||||||
+12
-24
@@ -3,11 +3,16 @@ package stirling.software.proprietary.policy.model;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stored automation: ordered tool steps, input sources, and an output destination.
|
* A stored automation: an ordered chain of tool steps, the sources its input files come from, and
|
||||||
|
* an output destination for the results.
|
||||||
*
|
*
|
||||||
* <p>Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code
|
* <p>Every policy can always be run on demand (manually). It may additionally carry one automatic
|
||||||
* null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where
|
* {@link TriggerConfig} - usually a schedule - that fires it without a person asking; a {@code
|
||||||
* files come from; a run pulls from every source.
|
* null} trigger means manual-only. A trigger decides <em>when</em> a run happens and a {@link
|
||||||
|
* InputSpec source} decides <em>where</em> its files come from; the two are independent, and a run
|
||||||
|
* pulls from every configured source.
|
||||||
|
*
|
||||||
|
* <p>This is the feature's central configuration object - what a user defines and the engine runs.
|
||||||
*/
|
*/
|
||||||
public record Policy(
|
public record Policy(
|
||||||
String id,
|
String id,
|
||||||
@@ -17,8 +22,7 @@ public record Policy(
|
|||||||
TriggerConfig trigger,
|
TriggerConfig trigger,
|
||||||
List<InputSpec> sources,
|
List<InputSpec> sources,
|
||||||
List<PipelineStep> steps,
|
List<PipelineStep> steps,
|
||||||
OutputSpec output,
|
OutputSpec output) {
|
||||||
Long teamId) {
|
|
||||||
|
|
||||||
public Policy {
|
public Policy {
|
||||||
sources = sources == null ? List.of() : List.copyOf(sources);
|
sources = sources == null ? List.of() : List.copyOf(sources);
|
||||||
@@ -26,22 +30,6 @@ public record Policy(
|
|||||||
output = output == null ? OutputSpec.inline() : output;
|
output = output == null ? OutputSpec.inline() : output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Without an explicit owning team. Kept for the engine and tests; the controller always stamps
|
|
||||||
* a {@code teamId} on stored policies so they stay scoped to the creating user's team.
|
|
||||||
*/
|
|
||||||
public Policy(
|
|
||||||
String id,
|
|
||||||
String name,
|
|
||||||
String owner,
|
|
||||||
boolean enabled,
|
|
||||||
TriggerConfig trigger,
|
|
||||||
List<InputSpec> sources,
|
|
||||||
List<PipelineStep> steps,
|
|
||||||
OutputSpec output) {
|
|
||||||
this(id, name, owner, enabled, trigger, sources, steps, output, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A policy with no configured sources (a generator, or files supplied directly to a run). */
|
/** A policy with no configured sources (a generator, or files supplied directly to a run). */
|
||||||
public Policy(
|
public Policy(
|
||||||
String id,
|
String id,
|
||||||
@@ -51,10 +39,10 @@ public record Policy(
|
|||||||
TriggerConfig trigger,
|
TriggerConfig trigger,
|
||||||
List<PipelineStep> steps,
|
List<PipelineStep> steps,
|
||||||
OutputSpec output) {
|
OutputSpec output) {
|
||||||
this(id, name, owner, enabled, trigger, List.of(), steps, output, null);
|
this(id, name, owner, enabled, trigger, List.of(), steps, output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** This policy's pipeline as the engine sees it. */
|
/** The engine-level, trigger-agnostic view of this policy's pipeline. */
|
||||||
public PipelineDefinition toDefinition() {
|
public PipelineDefinition toDefinition() {
|
||||||
return new PipelineDefinition(name, steps, output);
|
return new PipelineDefinition(name, steps, output);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -6,9 +6,17 @@ import java.util.Map;
|
|||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A run's files. {@code primary} documents flow step to step; {@code supportingFiles} are auxiliary
|
* The files a run operates on, split into two roles:
|
||||||
* assets bound by key via {@link PipelineStep#fileParameters()} and never enter the document
|
*
|
||||||
* stream. Asset values are lists so one key can carry a multi-file field (e.g. attachments).
|
* <ul>
|
||||||
|
* <li>{@code primary} - the documents that flow through the pipeline, each step's output becoming
|
||||||
|
* the next step's input.
|
||||||
|
* <li>{@code supportingFiles} - a named store of auxiliary files (a stamp image, certificate,
|
||||||
|
* overlay, attachments) that steps bind to their named file fields via {@link
|
||||||
|
* PipelineStep#fileParameters()}. These never enter the document stream.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Asset values are lists so a single key can carry multi-file fields (e.g. attachments).
|
||||||
*/
|
*/
|
||||||
public record PolicyInputs(List<Resource> primary, Map<String, List<Resource>> supportingFiles) {
|
public record PolicyInputs(List<Resource> primary, Map<String, List<Resource>> supportingFiles) {
|
||||||
|
|
||||||
|
|||||||
+9
-31
@@ -8,10 +8,12 @@ import lombok.Getter;
|
|||||||
import stirling.software.common.model.job.ResultFile;
|
import stirling.software.common.model.job.ResultFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live, mutable state of one pipeline run, held in memory by {@code PolicyRunRegistry} and the
|
* Live, mutable state of a single pipeline run, held in memory by {@code PolicyRunRegistry}.
|
||||||
* authoritative source of the state machine. Carries execution state ({@code JobResult} does not
|
*
|
||||||
* model status/step cursor/wait state); also projected into {@code TaskManager} for cluster-visible
|
* <p>This carries the rich execution state (status, step cursor, wait state) that the job system's
|
||||||
* status and download.
|
* {@code JobResult} does not model. The run is also projected into {@code TaskManager} for
|
||||||
|
* cluster-visible status, progress notes, and file download; this object is the authoritative
|
||||||
|
* source of the state machine.
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
public class PolicyRun {
|
public class PolicyRun {
|
||||||
@@ -27,21 +29,6 @@ public class PolicyRun {
|
|||||||
|
|
||||||
private volatile WaitState waitState;
|
private volatile WaitState waitState;
|
||||||
private volatile String error;
|
private volatile String error;
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable, machine-readable failure code the client can branch on — e.g. an entitlement-limit
|
|
||||||
* sentinel ({@code PAYG_LIMIT_REACHED} / {@code FEATURE_DEGRADED}) propagated from a downstream
|
|
||||||
* tool call's 402 — alongside the human-readable {@link #error}. Null unless set on failure.
|
|
||||||
*/
|
|
||||||
private volatile String errorCode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For an entitlement-limit failure, whether the team was subscribed (over its spending cap) vs
|
|
||||||
* un-subscribed (free allowance spent) — taken from the blocking 402 body. Drives which
|
|
||||||
* usage-limit modal the client shows. Null unless {@link #errorCode} is an entitlement code.
|
|
||||||
*/
|
|
||||||
private volatile Boolean errorSubscribed;
|
|
||||||
|
|
||||||
private volatile List<ResultFile> outputs = List.of();
|
private volatile List<ResultFile> outputs = List.of();
|
||||||
private volatile Instant updatedAt = Instant.now();
|
private volatile Instant updatedAt = Instant.now();
|
||||||
|
|
||||||
@@ -76,24 +63,15 @@ public class PolicyRun {
|
|||||||
touch();
|
touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fail with a stable {@code errorCode} the client can branch on (e.g. an entitlement-limit
|
|
||||||
* sentinel from a downstream 402), plus the optional {@code subscribed} flag from that
|
|
||||||
* response, in addition to the human-readable message.
|
|
||||||
*/
|
|
||||||
public synchronized void failWithCode(String message, String errorCode, Boolean subscribed) {
|
|
||||||
this.errorCode = errorCode;
|
|
||||||
this.errorSubscribed = subscribed;
|
|
||||||
fail(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void waitForInput(WaitState wait) {
|
public synchronized void waitForInput(WaitState wait) {
|
||||||
this.waitState = wait;
|
this.waitState = wait;
|
||||||
this.status = PolicyRunStatus.WAITING_FOR_INPUT;
|
this.status = PolicyRunStatus.WAITING_FOR_INPUT;
|
||||||
touch();
|
touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancels unless already terminal; returns whether it transitioned. */
|
/**
|
||||||
|
* Mark cancelled if the run has not already reached a terminal state. Returns whether it did.
|
||||||
|
*/
|
||||||
public synchronized boolean cancel() {
|
public synchronized boolean cancel() {
|
||||||
if (status.isTerminal()) {
|
if (status.isTerminal()) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+5
-2
@@ -1,8 +1,11 @@
|
|||||||
package stirling.software.proprietary.policy.model;
|
package stirling.software.proprietary.policy.model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle states of a {@link PolicyRun}. {@code WAITING_FOR_INPUT} models a thread-free pause;
|
* Lifecycle states of a {@link PolicyRun}.
|
||||||
* the resume handshake lands in a later stage.
|
*
|
||||||
|
* <p>{@code WAITING_FOR_INPUT} is modelled now so the engine and run shape support pausing a run
|
||||||
|
* (e.g. a step that blocks for a human decision) without holding a thread; the resume handshake is
|
||||||
|
* implemented in a later stage.
|
||||||
*/
|
*/
|
||||||
public enum PolicyRunStatus {
|
public enum PolicyRunStatus {
|
||||||
PENDING,
|
PENDING,
|
||||||
|
|||||||
+2
-6
@@ -5,8 +5,8 @@ import java.util.List;
|
|||||||
import stirling.software.common.model.job.ResultFile;
|
import stirling.software.common.model.job.ResultFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only view of a {@link PolicyRun} for the status endpoint. Outputs are {@link ResultFile}s,
|
* Read-only view of a {@link PolicyRun} returned by the status endpoint. Output files are surfaced
|
||||||
* downloadable via {@code GET /api/v1/general/files/{id}}.
|
* as {@link ResultFile} so the caller can download each via {@code GET /api/v1/general/files/{id}}.
|
||||||
*/
|
*/
|
||||||
public record PolicyRunView(
|
public record PolicyRunView(
|
||||||
String runId,
|
String runId,
|
||||||
@@ -14,8 +14,6 @@ public record PolicyRunView(
|
|||||||
int currentStep,
|
int currentStep,
|
||||||
int stepCount,
|
int stepCount,
|
||||||
String error,
|
String error,
|
||||||
String errorCode,
|
|
||||||
Boolean errorSubscribed,
|
|
||||||
List<ResultFile> outputs) {
|
List<ResultFile> outputs) {
|
||||||
|
|
||||||
public static PolicyRunView of(PolicyRun run) {
|
public static PolicyRunView of(PolicyRun run) {
|
||||||
@@ -25,8 +23,6 @@ public record PolicyRunView(
|
|||||||
run.getCurrentStep(),
|
run.getCurrentStep(),
|
||||||
run.stepCount(),
|
run.stepCount(),
|
||||||
run.getError(),
|
run.getError(),
|
||||||
run.getErrorCode(),
|
|
||||||
run.getErrorSubscribed(),
|
|
||||||
run.getOutputs());
|
run.getOutputs());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -11,9 +11,16 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
|
|||||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A scheduled policy's firing cadence; {@code type} is the JSON discriminator. Wall-clock kinds
|
* When a scheduled policy should fire, expressed as intent ("every day at 02:00") rather than a
|
||||||
* ({@link Daily}, {@link Weekly}, {@link Monthly}) evaluate in the {@code after} argument's zone;
|
* cron string. A frontend builds one of these from a friendly picker; the schedule trigger asks it
|
||||||
* {@link Every} is a fixed offset and ignores wall-clock time.
|
* for the next firing after a given moment.
|
||||||
|
*
|
||||||
|
* <p>Sealed so every cadence is an explicit, self-validating shape and adding a new one forces a
|
||||||
|
* new subtype rather than another string convention to learn. The {@code type} discriminator stays
|
||||||
|
* on the wire so the frontend can switch on it without knowing the Java hierarchy.
|
||||||
|
*
|
||||||
|
* <p>Wall-clock kinds ({@link Daily}, {@link Weekly}, {@link Monthly}) are evaluated in the zone of
|
||||||
|
* the {@code after} argument; {@link Every} is a fixed offset and ignores wall-clock time.
|
||||||
*/
|
*/
|
||||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||||
@JsonSubTypes({
|
@JsonSubTypes({
|
||||||
@@ -35,7 +42,10 @@ public sealed interface Schedule {
|
|||||||
DAYS
|
DAYS
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A fixed offset from {@code after}: "every 15 minutes", "every 6 hours". No time of day. */
|
/**
|
||||||
|
* A fixed cadence repeating from when the policy was last seen: "every 15 minutes", "every 6
|
||||||
|
* hours". Time of day is irrelevant.
|
||||||
|
*/
|
||||||
record Every(long count, Unit unit) implements Schedule {
|
record Every(long count, Unit unit) implements Schedule {
|
||||||
public Every {
|
public Every {
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
@@ -81,7 +91,8 @@ public sealed interface Schedule {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ZonedDateTime nextAfter(ZonedDateTime after) {
|
public ZonedDateTime nextAfter(ZonedDateTime after) {
|
||||||
// Soonest of the next 7 days landing on a chosen weekday, at the configured time.
|
// The soonest of the next 7 days that lands on a chosen weekday, at the configured
|
||||||
|
// time.
|
||||||
for (int i = 0; i <= 7; i++) {
|
for (int i = 0; i <= 7; i++) {
|
||||||
ZonedDateTime candidate = after.plusDays(i).with(at);
|
ZonedDateTime candidate = after.plusDays(i).with(at);
|
||||||
if (candidate.isAfter(after) && days.contains(candidate.getDayOfWeek())) {
|
if (candidate.isAfter(after) && days.contains(candidate.getDayOfWeek())) {
|
||||||
|
|||||||
+11
-3
@@ -3,9 +3,17 @@ package stirling.software.proprietary.policy.model;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A {@link Policy}'s automatic trigger; {@code type} keys a trigger bean (e.g. "schedule"). Manual
|
* A {@link Policy}'s optional automatic trigger - what fires it without a person asking. {@code
|
||||||
* running is not a trigger kind: a manual-only policy carries a {@code null} {@code TriggerConfig}.
|
* type} selects a trigger kind ("schedule", and in future "webhook", "folder-watch", ...); {@code
|
||||||
* Answers only "when"; file sources are the policy's {@link InputSpec}s.
|
* options} carries type-specific configuration (a {@link Schedule}, a webhook secret, ...).
|
||||||
|
*
|
||||||
|
* <p>Manual running is <em>not</em> a trigger kind: every policy can always be run on demand, so a
|
||||||
|
* policy with no automatic trigger simply has a {@code null} {@code TriggerConfig}. A trigger
|
||||||
|
* answers only "when"; where a run's files come from is a separate concern owned by the policy's
|
||||||
|
* {@link InputSpec sources}.
|
||||||
|
*
|
||||||
|
* <p>Data-driven and parallel to {@link OutputSpec}: new trigger kinds are new {@code type} values
|
||||||
|
* handled by a new trigger bean, with no change to the model.
|
||||||
*/
|
*/
|
||||||
public record TriggerConfig(String type, Map<String, Object> options) {
|
public record TriggerConfig(String type, Map<String, Object> options) {
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -3,10 +3,14 @@ package stirling.software.proprietary.policy.model;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resumable snapshot captured when a run pauses ({@link PolicyRunStatus#WAITING_FOR_INPUT}). {@code
|
* Captured when a run pauses in {@link PolicyRunStatus#WAITING_FOR_INPUT}. Together with the run's
|
||||||
* resumeStepIndex} is the 0-based step to continue from; {@code pendingFileIds} are intermediate
|
* {@link PipelineDefinition} this is the resumable snapshot: {@code resumeStepIndex} is the 0-based
|
||||||
* files held in {@code FileStorage} (not in-memory resources) so a pause survives the worker thread
|
* step to continue from, and {@code pendingFileIds} are the intermediate files (stored in {@code
|
||||||
* ending or a node restart.
|
* FileStorage}, so they survive the worker thread ending or a node restart) that become the input
|
||||||
|
* to the resumed run.
|
||||||
|
*
|
||||||
|
* <p>Stored as fileIds rather than in-memory resources by design: a paused run must be resumable
|
||||||
|
* long after its worker thread has gone.
|
||||||
*/
|
*/
|
||||||
public record WaitState(String reason, int resumeStepIndex, List<String> pendingFileIds) {
|
public record WaitState(String reason, int resumeStepIndex, List<String> pendingFileIds) {
|
||||||
public WaitState {
|
public WaitState {
|
||||||
|
|||||||
+13
-6
@@ -22,10 +22,13 @@ import stirling.software.proprietary.policy.config.FolderAccessGuard;
|
|||||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes a run's outputs to the {@code directory} given in the {@link OutputSpec}. Files are
|
* Writes a run's output files to a directory on disk. The destination is the {@code directory}
|
||||||
* streamed (not buffered) and uniquely named to avoid clobbering. Returned {@link ResultFile}s
|
* option of the {@link OutputSpec}.
|
||||||
* carry a synthetic id since the deliverable is the file on disk, not a {@code FileStorage} entry,
|
*
|
||||||
* so folder outputs are not downloadable via {@code /files/{id}}.
|
* <p>Files are streamed to disk (so large outputs are not buffered) and given unique names to avoid
|
||||||
|
* clobbering existing files. The returned {@link ResultFile}s describe what was written (path +
|
||||||
|
* size); they carry a synthetic id because the deliverable is the file on disk, not a {@code
|
||||||
|
* FileStorage} entry, so folder outputs are not downloadable via {@code /files/{id}}.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -92,7 +95,11 @@ public class FolderOutputSink implements PolicyOutputSink {
|
|||||||
return Path.of(directory.toString());
|
return Path.of(directory.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip any directory component / "../" so a crafted output name cannot escape targetDir.
|
/**
|
||||||
|
* The resource's filename reduced to a bare, traversal-free name: any directory component or
|
||||||
|
* "../" is stripped so a crafted output name cannot escape {@code targetDir}. Falls back to a
|
||||||
|
* synthetic name when the filename is absent or reduces to nothing usable.
|
||||||
|
*/
|
||||||
private static String safeName(String filename, int index) {
|
private static String safeName(String filename, int index) {
|
||||||
if (filename == null || filename.isBlank()) {
|
if (filename == null || filename.isBlank()) {
|
||||||
return "output-" + index;
|
return "output-" + index;
|
||||||
@@ -104,7 +111,7 @@ public class FolderOutputSink implements PolicyOutputSink {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-colliding path, appending " (n)" before the extension.
|
/** Resolve a non-colliding path in {@code dir}, appending " (n)" before the extension. */
|
||||||
private static Path uniqueTarget(Path dir, String filename) {
|
private static Path uniqueTarget(Path dir, String filename) {
|
||||||
Path candidate = dir.resolve(filename);
|
Path candidate = dir.resolve(filename);
|
||||||
if (!Files.exists(candidate)) {
|
if (!Files.exists(candidate)) {
|
||||||
|
|||||||
+3
-2
@@ -17,8 +17,9 @@ import stirling.software.common.service.FileStorage;
|
|||||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default sink: stores each output in {@code FileStorage} so it is downloadable via {@code GET
|
* Default output sink: stores each output file in {@code FileStorage} so it is downloadable via
|
||||||
* /api/v1/general/files/{fileId}}. Used for manual runs whose results return to the caller.
|
* {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs
|
||||||
|
* whose results are returned to the caller.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|||||||
+16
-5
@@ -9,9 +9,11 @@ import stirling.software.common.model.job.ResultFile;
|
|||||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delivers a finished run's outputs to a destination, returning {@link ResultFile} descriptors for
|
* Delivers a finished run's output files to a destination, returning durable {@link ResultFile}
|
||||||
* the run record. Implementations are beans selected by {@link #supports(OutputSpec)}, so a new
|
* descriptors (fileId + metadata) for the run record.
|
||||||
* destination (folder, S3) is just a new bean.
|
*
|
||||||
|
* <p>Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations
|
||||||
|
* (folder, S3) are added as new beans without changing the engine.
|
||||||
*/
|
*/
|
||||||
public interface PolicyOutputSink {
|
public interface PolicyOutputSink {
|
||||||
|
|
||||||
@@ -21,10 +23,19 @@ public interface PolicyOutputSink {
|
|||||||
/** Whether this sink can handle the given output spec. */
|
/** Whether this sink can handle the given output spec. */
|
||||||
boolean supports(OutputSpec spec);
|
boolean supports(OutputSpec spec);
|
||||||
|
|
||||||
/** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */
|
/**
|
||||||
|
* Check that an output spec is usable, throwing {@link IllegalArgumentException} if not. Called
|
||||||
|
* when a policy is saved so misconfiguration fails fast rather than at run time.
|
||||||
|
*/
|
||||||
default void validate(OutputSpec spec) {}
|
default void validate(OutputSpec spec) {}
|
||||||
|
|
||||||
/** Persist/deliver the output files and return their descriptors. */
|
/**
|
||||||
|
* Persist/deliver the output files and return their descriptors.
|
||||||
|
*
|
||||||
|
* @param runId the run these outputs belong to
|
||||||
|
* @param outputs the final pipeline output resources
|
||||||
|
* @param spec the requested destination
|
||||||
|
*/
|
||||||
List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
|
List<ResultFile> deliver(String runId, List<Resource> outputs, OutputSpec spec)
|
||||||
throws IOException;
|
throws IOException;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -1,17 +1,22 @@
|
|||||||
package stirling.software.proprietary.policy.progress;
|
package stirling.software.proprietary.policy.progress;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Receives live progress as a pipeline run executes (SSE stream, job notes, or both). Step indices
|
* Receives live progress as a pipeline run executes. Implementations forward to an SSE stream,
|
||||||
* are 1-based. All methods default to no-ops.
|
* write job notes for polling, or both. Step indices are 1-based.
|
||||||
|
*
|
||||||
|
* <p>All methods default to no-ops so callers implement only what they surface.
|
||||||
*/
|
*/
|
||||||
public interface PolicyProgressListener {
|
public interface PolicyProgressListener {
|
||||||
|
|
||||||
|
/** A listener that ignores all progress. */
|
||||||
PolicyProgressListener NOOP = new PolicyProgressListener() {};
|
PolicyProgressListener NOOP = new PolicyProgressListener() {};
|
||||||
|
|
||||||
|
/** Called immediately before step {@code stepIndex} of {@code stepCount} begins. */
|
||||||
default void onStepStart(int stepIndex, int stepCount, String operation) {}
|
default void onStepStart(int stepIndex, int stepCount, String operation) {}
|
||||||
|
|
||||||
|
/** Called immediately after step {@code stepIndex} of {@code stepCount} completes. */
|
||||||
default void onStepComplete(int stepIndex, int stepCount, String operation) {}
|
default void onStepComplete(int stepIndex, int stepCount, String operation) {}
|
||||||
|
|
||||||
/** Keep-alive tick so downstream connections can detect disconnects promptly. */
|
/** Called on a keep-alive tick so downstream connections can detect disconnects promptly. */
|
||||||
default void onHeartbeat() {}
|
default void onHeartbeat() {}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -9,8 +9,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import stirling.software.proprietary.policy.model.Policy;
|
import stirling.software.proprietary.policy.model.Policy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory {@link PolicyStore} for tests and any future no-database mode. {@link JpaPolicyStore}
|
* In-memory {@link PolicyStore}. Not the runtime bean - {@link JpaPolicyStore} is the durable
|
||||||
* is the runtime bean.
|
* store. Kept as a lightweight, dependency-free implementation for tests and for any future no-
|
||||||
|
* database mode.
|
||||||
*/
|
*/
|
||||||
public class InProcessPolicyStore implements PolicyStore {
|
public class InProcessPolicyStore implements PolicyStore {
|
||||||
|
|
||||||
@@ -31,8 +32,7 @@ public class InProcessPolicyStore implements PolicyStore {
|
|||||||
policy.trigger(),
|
policy.trigger(),
|
||||||
policy.sources(),
|
policy.sources(),
|
||||||
policy.steps(),
|
policy.steps(),
|
||||||
policy.output(),
|
policy.output());
|
||||||
policy.teamId());
|
|
||||||
policies.put(id, stored);
|
policies.put(id, stored);
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -13,8 +13,9 @@ import stirling.software.proprietary.policy.model.Policy;
|
|||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via
|
* Durable {@link PolicyStore} backed by JPA. The runtime store whenever the proprietary module runs
|
||||||
* {@link PolicyEntity}, with scalar columns kept in sync for querying.
|
* (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the
|
||||||
|
* scalar columns are kept in sync for querying.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -38,8 +39,7 @@ public class JpaPolicyStore implements PolicyStore {
|
|||||||
policy.trigger(),
|
policy.trigger(),
|
||||||
policy.sources(),
|
policy.sources(),
|
||||||
policy.steps(),
|
policy.steps(),
|
||||||
policy.output(),
|
policy.output());
|
||||||
policy.teamId());
|
|
||||||
|
|
||||||
PolicyEntity entity = new PolicyEntity();
|
PolicyEntity entity = new PolicyEntity();
|
||||||
entity.setId(id);
|
entity.setId(id);
|
||||||
|
|||||||
+7
-5
@@ -12,11 +12,13 @@ import lombok.NoArgsConstructor;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
|
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}.
|
||||||
* as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
|
*
|
||||||
* for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch
|
* <p>The whole policy is stored as JSON in {@code policyJson} (authoritative on read, and the same
|
||||||
* their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the
|
* serialization the API uses); the scalar columns are denormalized copies for querying - notably
|
||||||
* security entities.
|
* {@code triggerType} + {@code enabled} so background triggers can fetch their policies. Ownership
|
||||||
|
* is a plain {@code owner} string rather than a foreign key, to stay decoupled from the security
|
||||||
|
* entities; richer team scoping can be layered on later.
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "policies")
|
@Table(name = "policies")
|
||||||
|
|||||||
+9
-4
@@ -5,19 +5,24 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import stirling.software.proprietary.policy.model.Policy;
|
import stirling.software.proprietary.policy.model.Policy;
|
||||||
|
|
||||||
/** Stores {@link Policy} definitions. */
|
/**
|
||||||
|
* Stores {@link Policy} definitions. The in-memory implementation backs simple deployments now; a
|
||||||
|
* durable (JPA) implementation can replace it behind this interface without touching callers.
|
||||||
|
*/
|
||||||
public interface PolicyStore {
|
public interface PolicyStore {
|
||||||
|
|
||||||
/** Create or update; a blank/absent id is assigned. Returns the stored policy. */
|
/** Create or update a policy. A blank/absent id is assigned; returns the stored policy. */
|
||||||
Policy save(Policy policy);
|
Policy save(Policy policy);
|
||||||
|
|
||||||
Optional<Policy> get(String id);
|
Optional<Policy> get(String id);
|
||||||
|
|
||||||
List<Policy> all();
|
List<Policy> all();
|
||||||
|
|
||||||
/** Enabled policies with the given trigger type, for background triggers. */
|
/**
|
||||||
|
* Enabled policies whose automatic trigger is of the given type (used by background triggers).
|
||||||
|
*/
|
||||||
List<Policy> findByTriggerType(String triggerType);
|
List<Policy> findByTriggerType(String triggerType);
|
||||||
|
|
||||||
/** Returns whether the policy existed. */
|
/** Remove a policy; returns whether it existed. */
|
||||||
boolean delete(String id);
|
boolean delete(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-14
@@ -33,14 +33,20 @@ import stirling.software.proprietary.policy.model.Policy;
|
|||||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fires policies when a file lands in one of their folder sources, rather than polling on a timer.
|
* Fires policies the moment a file lands in one of their folder sources, instead of polling on a
|
||||||
|
* timer. The trigger only reads that location; turning it into files is still the source's job.
|
||||||
*
|
*
|
||||||
* <p>The watch is a latency optimisation, not a source of truth: a periodic reconcile sweep ({@code
|
* <p>The watcher is a latency optimisation, not a source of truth, so this pairs an event watch
|
||||||
* watchReconcileSeconds}) re-syncs watched dirs and re-runs every policy, covering files that
|
* with a low-frequency <b>reconcile</b> sweep ({@code watchReconcileSeconds}). The reconcile both
|
||||||
* pre-dated the watch, dropped events, and filesystems that emit none (NFS, bind mounts). Redundant
|
* (a) re-syncs which directories are watched as policies are created/edited/deleted and folders
|
||||||
* runs are harmless since {@link InputSource} does the claiming.
|
* appear on disk, and (b) runs every folder-watch policy once, catching files that pre-dated the
|
||||||
|
* watch, events lost to inotify-queue overflow, and changes on filesystems that do not deliver
|
||||||
|
* events at all (NFS, many container bind mounts). Both paths just call {@link PolicyRunner#run};
|
||||||
|
* the {@link InputSource} does the claiming, so a redundant run finds nothing to claim and is
|
||||||
|
* harmless.
|
||||||
*
|
*
|
||||||
* <p>Watch state is in memory, so this assumes a single node and rebuilds registrations on restart.
|
* <p>Like {@link ScheduleTrigger}, watch state is per-node and in memory; this assumes a single
|
||||||
|
* node and rebuilds its registrations on restart from the {@link PolicyStore}.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -59,7 +65,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
|||||||
|
|
||||||
private volatile boolean running;
|
private volatile boolean running;
|
||||||
|
|
||||||
// Package-visible so tests can drive syncRegistrations() against a real service.
|
// Package-visible (not private) so tests can drive syncRegistrations() against a real service.
|
||||||
volatile WatchService watchService;
|
volatile WatchService watchService;
|
||||||
|
|
||||||
private volatile ScheduledExecutorService reconciler;
|
private volatile ScheduledExecutorService reconciler;
|
||||||
@@ -119,7 +125,8 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void watchLoop() {
|
private void watchLoop() {
|
||||||
// Capture once: stop() may null the field; close() still wakes take()/poll() on this local.
|
// Capture the service once: stop() may null the field, and a local avoids racing that to an
|
||||||
|
// NPE (close() still wakes take()/poll() on this same instance).
|
||||||
WatchService watcher = watchService;
|
WatchService watcher = watchService;
|
||||||
if (watcher == null) {
|
if (watcher == null) {
|
||||||
return;
|
return;
|
||||||
@@ -139,8 +146,9 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coalesce a burst of file-system events into one set of affected directories: drain everything
|
* Collect the directories touched by {@code first} and any further events that arrive within
|
||||||
* arriving within the quiet period. Event kinds are irrelevant; any event means "go look".
|
* the quiet period, so a burst of file-system events becomes a single set of affected
|
||||||
|
* directories. The event kinds are irrelevant: any event on a watched dir just means "go look".
|
||||||
*/
|
*/
|
||||||
private Set<Path> drainBurst(WatchService watcher, WatchKey first) {
|
private Set<Path> drainBurst(WatchService watcher, WatchKey first) {
|
||||||
long quietPeriodMs = applicationProperties.getPolicies().getWatchQuietPeriodMs();
|
long quietPeriodMs = applicationProperties.getPolicies().getWatchQuietPeriodMs();
|
||||||
@@ -192,7 +200,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reconcile safety net: run every folder-watch policy regardless of watch events. */
|
/** The reconcile safety net: run every folder-watch policy regardless of watch events. */
|
||||||
void runAll() {
|
void runAll() {
|
||||||
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
|
for (Policy policy : policyStore.findByTriggerType(TYPE)) {
|
||||||
try {
|
try {
|
||||||
@@ -206,7 +214,10 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register newly-wanted dirs that exist on disk, cancel ones no longer wanted. */
|
/**
|
||||||
|
* Bring the set of watched directories in line with the current folder-watch policies: register
|
||||||
|
* newly required directories that exist on disk, and cancel ones no longer wanted.
|
||||||
|
*/
|
||||||
synchronized void syncRegistrations() {
|
synchronized void syncRegistrations() {
|
||||||
if (watchService == null) {
|
if (watchService == null) {
|
||||||
return;
|
return;
|
||||||
@@ -263,8 +274,11 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
|||||||
return dirs;
|
return dirs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Absolute + normalised so registration keys and event-time matching compare regardless of how
|
/**
|
||||||
// the path was configured.
|
* The normalised, absolute directories this policy's sources expose to watch. Normalisation
|
||||||
|
* makes registration keys and event-time matching comparable regardless of how the path was
|
||||||
|
* configured.
|
||||||
|
*/
|
||||||
private List<Path> watchDirsOf(Policy policy) {
|
private List<Path> watchDirsOf(Policy policy) {
|
||||||
List<Path> dirs = new ArrayList<>();
|
List<Path> dirs = new ArrayList<>();
|
||||||
for (InputSpec spec : policy.sources()) {
|
for (InputSpec spec : policy.sources()) {
|
||||||
|
|||||||
+20
-5
@@ -3,21 +3,36 @@ package stirling.software.proprietary.policy.trigger;
|
|||||||
import stirling.software.proprietary.policy.model.Policy;
|
import stirling.software.proprietary.policy.model.Policy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decides <em>when</em> a policy runs. On firing it hands the policy to {@code PolicyRunner}; it
|
* An automatic trigger: the thing that decides <em>when</em> a policy runs without a person asking.
|
||||||
* never resolves sources itself. New trigger kinds are just new beans of this type.
|
* A trigger owns a {@link #type()} (matching {@code TriggerConfig.type()}); when its condition
|
||||||
|
* fires it hands the policy to the {@code PolicyRunner}, which pulls the policy's sources and
|
||||||
|
* starts the runs. A trigger never resolves sources itself.
|
||||||
|
*
|
||||||
|
* <p>Triggers are background, configuration-driven beans (schedule, and in future webhook or
|
||||||
|
* folder-watch): on {@link #start()} they begin watching/scheduling for the policies returned by
|
||||||
|
* {@code PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. New trigger kinds are
|
||||||
|
* new beans of this type; the runner and the {@code Policy} model do not change.
|
||||||
|
*
|
||||||
|
* <p>Manual running is not a trigger - every policy can always be run on demand via the {@code
|
||||||
|
* PolicyRunner} regardless of whether it has a trigger.
|
||||||
*/
|
*/
|
||||||
public interface PolicyTrigger {
|
public interface PolicyTrigger {
|
||||||
|
|
||||||
/** Matches {@code TriggerConfig.type()}. */
|
/** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */
|
||||||
String type();
|
String type();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
|
* Check that this trigger is usable for the given policy, throwing {@link
|
||||||
* {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
|
* IllegalArgumentException} if not. Called when a policy is saved so misconfiguration fails
|
||||||
|
* fast rather than at fire time. Receives the whole {@link Policy} (not just its {@code
|
||||||
|
* TriggerConfig}) so a trigger whose firing depends on the policy's sources (folder-watch) can
|
||||||
|
* assert that relationship; most triggers only inspect {@code policy.trigger()}.
|
||||||
*/
|
*/
|
||||||
default void validate(Policy policy) {}
|
default void validate(Policy policy) {}
|
||||||
|
|
||||||
|
/** Begin activating policies of this type (e.g. start the schedule sweep). */
|
||||||
default void start() {}
|
default void start() {}
|
||||||
|
|
||||||
|
/** Stop activating and release any resources. */
|
||||||
default void stop() {}
|
default void stop() {}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -8,7 +8,14 @@ import org.springframework.stereotype.Service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
/** Starts and stops every {@link PolicyTrigger} with the application lifecycle. */
|
/**
|
||||||
|
* Starts and stops every {@link PolicyTrigger} with the application lifecycle. Background triggers
|
||||||
|
* (schedule, and future folder/S3) begin watching on {@link #start()} and release resources on
|
||||||
|
* {@link #stop()}; request-driven triggers (manual) are no-ops.
|
||||||
|
*
|
||||||
|
* <p>This is the single activation point for triggers - a new background trigger only has to be a
|
||||||
|
* {@link PolicyTrigger} bean.
|
||||||
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|||||||
+14
-5
@@ -24,9 +24,16 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
|||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fires policies on a {@link Schedule}: a fixed-interval sweep runs each due "schedule" policy.
|
* Fires policies on a {@link Schedule}. On {@link #start()} it sweeps on a fixed interval; each
|
||||||
|
* sweep finds the enabled "schedule" policies and runs any whose next firing has come due since it
|
||||||
|
* last fired.
|
||||||
*
|
*
|
||||||
* <p>Last-fire times are in memory, so this assumes a single node and resets on restart.
|
* <p>The trigger only decides <em>when</em>: once a policy is due it hands it to the {@link
|
||||||
|
* PolicyRunner}, which pulls from the policy's configured sources and starts the runs. The trigger
|
||||||
|
* knows nothing about folders, buckets, or how many runs a sweep produces.
|
||||||
|
*
|
||||||
|
* <p>Caveat: last-fire times are tracked <b>in memory</b>, so this assumes a single node and resets
|
||||||
|
* on restart; cluster-wide coordination (leader election) is a follow-up.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -94,7 +101,8 @@ public class ScheduleTrigger implements PolicyTrigger {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baseline a newly-seen policy to now so it does not fire immediately.
|
// First time we see a policy, baseline its last-fire to now so it does not fire
|
||||||
|
// immediately; subsequent sweeps fire it once its next firing has passed.
|
||||||
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
|
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
|
||||||
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
|
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
|
||||||
if (!next.toInstant().isAfter(now)) {
|
if (!next.toInstant().isAfter(now)) {
|
||||||
@@ -106,8 +114,9 @@ public class ScheduleTrigger implements PolicyTrigger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validated schedule-trigger options: the {@link Schedule} and the zone it runs in (UTC by
|
* The typed, validated form of a schedule trigger's options: the {@link Schedule} and the zone
|
||||||
* default).
|
* its wall-clock kinds are evaluated in (default UTC). Construction fails for a missing/invalid
|
||||||
|
* schedule or zone.
|
||||||
*/
|
*/
|
||||||
record ScheduleConfig(Schedule schedule, ZoneId zone) {
|
record ScheduleConfig(Schedule schedule, ZoneId zone) {
|
||||||
|
|
||||||
|
|||||||
+1
-20
@@ -1,13 +1,11 @@
|
|||||||
package stirling.software.proprietary.security;
|
package stirling.software.proprietary.security;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.env.Environment;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
@@ -39,17 +37,6 @@ public class InitialSecuritySetup {
|
|||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
private final DatabaseServiceInterface databaseService;
|
private final DatabaseServiceInterface databaseService;
|
||||||
private final UserLicenseSettingsService licenseSettingsService;
|
private final UserLicenseSettingsService licenseSettingsService;
|
||||||
private final Environment environment;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SaaS manages identity in Supabase and billing via PAYG, so the self-host bootstrap steps that
|
|
||||||
* scan/rewrite the whole user table (default-team backfill, seat-license grandfathering) don't
|
|
||||||
* apply - and against a large SaaS user table they stall startup with full-table loads +
|
|
||||||
* per-row saveAll. Per-user team assignment happens in SupabaseAuthenticationFilter instead.
|
|
||||||
*/
|
|
||||||
private boolean isSaas() {
|
|
||||||
return Arrays.asList(environment.getActiveProfiles()).contains("saas");
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
@@ -64,15 +51,9 @@ public class InitialSecuritySetup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configureJWTSettings();
|
configureJWTSettings();
|
||||||
initializeInternalApiUser();
|
|
||||||
if (isSaas()) {
|
|
||||||
log.info(
|
|
||||||
"SaaS profile active - skipping self-host user-table bootstrap"
|
|
||||||
+ " (default-team backfill, seat-license grandfathering).");
|
|
||||||
} else {
|
|
||||||
assignUsersToDefaultTeamIfMissing();
|
assignUsersToDefaultTeamIfMissing();
|
||||||
|
initializeInternalApiUser();
|
||||||
initializeUserLicenseSettings();
|
initializeUserLicenseSettings();
|
||||||
}
|
|
||||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||||
log.error("Failed to initialize security setup.", e);
|
log.error("Failed to initialize security setup.", e);
|
||||||
System.exit(1);
|
System.exit(1);
|
||||||
|
|||||||
+10
-22
@@ -978,35 +978,23 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lists enabled users for the signing user picker, scoped by storage.signing.userListScope:
|
/**
|
||||||
// 'org' (default) = whole instance, anything else = caller's team only (fail-closed).
|
* List all enabled users for selection in signing workflows.
|
||||||
|
*
|
||||||
|
* @param principal The authenticated user
|
||||||
|
* @return List of user summaries
|
||||||
|
*/
|
||||||
@GetMapping("/users")
|
@GetMapping("/users")
|
||||||
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
|
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
|
||||||
if (principal == null) {
|
if (principal == null) {
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail-closed: only literal "org" opens the whole instance; anything else scopes to team.
|
|
||||||
String scope = applicationProperties.getStorage().getSigning().getUserListScope();
|
|
||||||
boolean teamScoped = !"org".equalsIgnoreCase(scope == null ? "" : scope.trim());
|
|
||||||
|
|
||||||
List<User> source;
|
|
||||||
if (teamScoped) {
|
|
||||||
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
|
|
||||||
if (callerOpt.isEmpty() || callerOpt.get().getTeam() == null) {
|
|
||||||
// No team: return only the caller rather than leak the org.
|
|
||||||
source = callerOpt.map(List::of).orElse(List.of());
|
|
||||||
} else {
|
|
||||||
// KNOWN LIMITATION: scopes the team via the single User.team FK - correct while
|
|
||||||
// acceptInvitation() collapses users to one team; revisit if multi-team enabled.
|
|
||||||
source = userRepository.findAllByTeamId(callerOpt.get().getTeam().getId());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
source = userRepository.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
List<UserSummaryDTO> users =
|
List<UserSummaryDTO> users =
|
||||||
source.stream().filter(User::isEnabled).map(this::toUserSummaryDTO).toList();
|
userRepository.findAll().stream()
|
||||||
|
.filter(User::isEnabled)
|
||||||
|
.map(this::toUserSummaryDTO)
|
||||||
|
.toList();
|
||||||
|
|
||||||
return ResponseEntity.ok(users);
|
return ResponseEntity.ok(users);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -87,11 +87,7 @@ public class User implements UserDetails, Serializable {
|
|||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
|
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
|
||||||
// Column is `supabase_auth_id` (canonical name from the initial Supabase remote
|
@Column(name = "supabase_id", unique = true)
|
||||||
// schema migration). An earlier Flyway V2 (PR #6384) accidentally introduced a
|
|
||||||
// parallel `supabase_id` column that was used by Java; V17 backfilled and dropped
|
|
||||||
// it. Field name is kept as `supabaseId` to avoid a wide refactor of callers.
|
|
||||||
@Column(name = "supabase_auth_id", unique = true)
|
|
||||||
private UUID supabaseId;
|
private UUID supabaseId;
|
||||||
|
|
||||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||||
|
|||||||
-50
@@ -18,7 +18,6 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.http.MediaTypeFactory;
|
import org.springframework.http.MediaTypeFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.HttpServerErrorException;
|
import org.springframework.web.client.HttpServerErrorException;
|
||||||
import org.springframework.web.client.RestClientResponseException;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import io.github.pixee.security.Filenames;
|
import io.github.pixee.security.Filenames;
|
||||||
@@ -386,13 +385,6 @@ public class AiWorkflowService {
|
|||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
|
|
||||||
if (limit != null) {
|
|
||||||
log.info(
|
|
||||||
"AI markdown conversion blocked by downstream entitlement gate ({})",
|
|
||||||
limit.getErrorCode());
|
|
||||||
return new WorkflowState.Terminal(limit);
|
|
||||||
}
|
|
||||||
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
|
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
|
||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||||
@@ -478,14 +470,6 @@ public class AiWorkflowService {
|
|||||||
log.error("Tool {} timed out: {}", endpointPath, e.getMessage());
|
log.error("Tool {} timed out: {}", endpointPath, e.getMessage());
|
||||||
return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e)));
|
return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e)));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
|
|
||||||
if (limit != null) {
|
|
||||||
log.info(
|
|
||||||
"AI workflow tool {} blocked by downstream entitlement gate ({})",
|
|
||||||
endpointPath,
|
|
||||||
limit.getErrorCode());
|
|
||||||
return new WorkflowState.Terminal(limit);
|
|
||||||
}
|
|
||||||
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
|
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
|
||||||
return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, e)));
|
return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, e)));
|
||||||
}
|
}
|
||||||
@@ -601,13 +585,6 @@ public class AiWorkflowService {
|
|||||||
log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason);
|
log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason);
|
||||||
return new WorkflowState.Terminal(cannotContinue(reason));
|
return new WorkflowState.Terminal(cannotContinue(reason));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
|
|
||||||
if (limit != null) {
|
|
||||||
log.info(
|
|
||||||
"AI workflow plan blocked by downstream entitlement gate ({})",
|
|
||||||
limit.getErrorCode());
|
|
||||||
return new WorkflowState.Terminal(limit);
|
|
||||||
}
|
|
||||||
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
||||||
return new WorkflowState.Terminal(
|
return new WorkflowState.Terminal(
|
||||||
cannotContinue("Plan execution failed: " + e.getMessage()));
|
cannotContinue("Plan execution failed: " + e.getMessage()));
|
||||||
@@ -745,33 +722,6 @@ public class AiWorkflowService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* If {@code e} is a downstream usage-limit block — a 401/402 from a tool call carrying the saas
|
|
||||||
* EntitlementGuard's {@code error} sentinel — build a terminal response that carries the
|
|
||||||
* structured code (+ {@code subscribed}) through to the client, so it can pop the matching
|
|
||||||
* usage-limit modal instead of surfacing the raw "tool failed: 402…" text. Returns null for any
|
|
||||||
* other failure, so the caller falls back to its normal tool-failure handling.
|
|
||||||
*
|
|
||||||
* <p>The agent's tool calls run server-side (loopback HTTP via {@link PolicyExecutor}), so this
|
|
||||||
* 402 never reaches the frontend's API-client interceptor that pops the modal for direct calls
|
|
||||||
* — same gap the policy auto-run path bridges in {@code PolicyEngine}.
|
|
||||||
*/
|
|
||||||
private AiWorkflowResponse paygLimitResponseOrNull(Throwable e) {
|
|
||||||
if (!(e instanceof RestClientResponseException rce)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String code = DownstreamEntitlementError.extractCode(rce);
|
|
||||||
if (code == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
AiWorkflowResponse response = new AiWorkflowResponse();
|
|
||||||
response.setOutcome(AiWorkflowOutcome.CANNOT_CONTINUE);
|
|
||||||
response.setReason("You've reached your current usage limit.");
|
|
||||||
response.setErrorCode(code);
|
|
||||||
response.setErrorSubscribed(DownstreamEntitlementError.extractSubscribed(rce));
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drive the engine's streaming orchestrator endpoint. Progress events are forwarded to {@code
|
* Drive the engine's streaming orchestrator endpoint. Progress events are forwarded to {@code
|
||||||
* listener} as they arrive (each one keeps the SSE connection to the frontend alive too). The
|
* listener} as they arrive (each one keeps the SSE connection to the frontend alive too). The
|
||||||
|
|||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
package stirling.software.proprietary.service;
|
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import org.springframework.web.client.RestClientResponseException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the {@code error} sentinel and {@code subscribed} flag out of a downstream 401/402 JSON
|
|
||||||
* body — e.g. the saas EntitlementGuard's {@code
|
|
||||||
* {"error":"PAYG_LIMIT_REACHED","subscribed":false}}.
|
|
||||||
*
|
|
||||||
* <p>Server-side run paths (policy auto-run, AI agent workflows) execute tool calls via loopback
|
|
||||||
* HTTP, so a usage-limit 402 surfaces as a {@link RestClientResponseException} rather than reaching
|
|
||||||
* the frontend's API-client interceptor. These helpers let those paths pass the structured code
|
|
||||||
* through to the client, which maps it to the right usage-limit modal instead of showing a generic
|
|
||||||
* failure.
|
|
||||||
*
|
|
||||||
* <p>Regex (not a JSON parse) on purpose: the body is a small, server-controlled shape and this
|
|
||||||
* keeps the proprietary module free of any billing-layer (saas) coupling.
|
|
||||||
*/
|
|
||||||
public final class DownstreamEntitlementError {
|
|
||||||
|
|
||||||
private DownstreamEntitlementError() {}
|
|
||||||
|
|
||||||
/** Matches the {@code "error":"CODE"} field of a small JSON error body. */
|
|
||||||
private static final Pattern ERROR_CODE_FIELD =
|
|
||||||
Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\"");
|
|
||||||
|
|
||||||
/** Matches the {@code "subscribed":true|false} field of a small JSON error body. */
|
|
||||||
private static final Pattern SUBSCRIBED_FIELD =
|
|
||||||
Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull the {@code error} sentinel out of a downstream 401/402 JSON body. Returns null for other
|
|
||||||
* statuses or an unmatched body, in which case the caller treats it as a generic failure.
|
|
||||||
*/
|
|
||||||
public static String extractCode(RestClientResponseException e) {
|
|
||||||
int status = e.getStatusCode().value();
|
|
||||||
if (status != 401 && status != 402) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String body = e.getResponseBodyAsString();
|
|
||||||
if (body == null || body.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Matcher m = ERROR_CODE_FIELD.matcher(body);
|
|
||||||
return m.find() ? m.group(1) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull the {@code subscribed} flag out of the body (present on the saas {@code
|
|
||||||
* PAYG_LIMIT_REACHED}/{@code FEATURE_DEGRADED} responses). Null when absent — the client then
|
|
||||||
* defaults to the free-limit modal.
|
|
||||||
*/
|
|
||||||
public static Boolean extractSubscribed(RestClientResponseException e) {
|
|
||||||
String body = e.getResponseBodyAsString();
|
|
||||||
if (body == null || body.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Matcher m = SUBSCRIBED_FIELD.matcher(body);
|
|
||||||
return m.find() ? Boolean.valueOf(m.group(1)) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-24
@@ -53,30 +53,6 @@ class McpAudienceValidatorTest {
|
|||||||
assertThat(result.hasErrors()).isTrue();
|
assertThat(result.hasErrors()).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void acceptedAudience_isAccepted_alongsideResourceId() {
|
|
||||||
// Supabase-style IdP: every token carries aud=authenticated, never the resource id.
|
|
||||||
McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("authenticated"));
|
|
||||||
assertThat(relaxed.validate(tokenWithAudience(List.of("authenticated"))).hasErrors())
|
|
||||||
.isFalse();
|
|
||||||
assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse();
|
|
||||||
assertThat(relaxed.validate(tokenWithAudience(List.of("something-else"))).hasErrors())
|
|
||||||
.isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void blankAcceptedAudienceEntries_areIgnored() {
|
|
||||||
McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("", " "));
|
|
||||||
assertThat(relaxed.validate(tokenWithAudience(List.of(""))).hasErrors()).isTrue();
|
|
||||||
assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void blankResourceIdWithOnlyBlankAccepted_failsClosed() {
|
|
||||||
McpAudienceValidator blank = new McpAudienceValidator("", List.of(" "));
|
|
||||||
assertThat(blank.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Jwt tokenWithAudience(List<String> audience) {
|
private static Jwt tokenWithAudience(List<String> audience) {
|
||||||
return new Jwt(
|
return new Jwt(
|
||||||
"header.payload.signature",
|
"header.payload.signature",
|
||||||
|
|||||||
+1
-20
@@ -116,8 +116,7 @@ class McpOAuthIntegrationTest {
|
|||||||
assertThat(response.statusCode()).isEqualTo(401);
|
assertThat(response.statusCode()).isEqualTo(401);
|
||||||
String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
|
String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
|
||||||
assertThat(wwwAuth).contains("resource_metadata=");
|
assertThat(wwwAuth).contains("resource_metadata=");
|
||||||
// The advertised URL must be the RFC 9728 path-inserted form for the /mcp resource.
|
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource");
|
||||||
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource/mcp");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -248,24 +247,6 @@ class McpOAuthIntegrationTest {
|
|||||||
assertThat(response.body()).contains("mcp.tools.read");
|
assertThat(response.body()).contains("mcp.tools.read");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void pathInsertedMetadataEndpoint_servesCustomizedMetadata() throws Exception {
|
|
||||||
// RFC 9728 path-inserted form for the /mcp resource. Must be served by the MCP chain
|
|
||||||
// with authorization_servers populated; a default/uncustomized document here makes MCP
|
|
||||||
// clients fall back to treating this server as its own authorization server.
|
|
||||||
HttpRequest request =
|
|
||||||
HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(base() + "/.well-known/oauth-protected-resource/mcp"))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
assertThat(response.statusCode()).isEqualTo(200);
|
|
||||||
assertThat(response.body()).contains(RESOURCE_ID);
|
|
||||||
assertThat(response.body()).contains("authorization_servers");
|
|
||||||
assertThat(response.body()).contains(ISSUER);
|
|
||||||
assertThat(response.body()).contains("mcp.tools.read");
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpResponse<String> postMcp(String token, String body) throws Exception {
|
private HttpResponse<String> postMcp(String token, String body) throws Exception {
|
||||||
HttpRequest.Builder builder =
|
HttpRequest.Builder builder =
|
||||||
HttpRequest.newBuilder()
|
HttpRequest.newBuilder()
|
||||||
|
|||||||
-105
@@ -1,105 +0,0 @@
|
|||||||
package stirling.software.proprietary.mcp.security;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.SpringBootConfiguration;
|
|
||||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
|
||||||
import org.springframework.test.context.DynamicPropertySource;
|
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
|
||||||
import stirling.software.proprietary.mcp.McpServerController;
|
|
||||||
import stirling.software.proprietary.mcp.tools.DescribeOperationTool;
|
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for the protected-resource metadata when {@code mcp.scopes-enabled=false} (the
|
|
||||||
* SaaS/Supabase setup, where the IdP cannot mint {@code mcp.tools.*} scopes). Advertising scopes
|
|
||||||
* the authorization server can't issue makes spec-compliant MCP clients request them and get
|
|
||||||
* bounced with {@code invalid_request}, so the metadata must omit them when scopes are not
|
|
||||||
* enforced.
|
|
||||||
*/
|
|
||||||
@SpringBootTest(
|
|
||||||
classes = McpScopeMetadataDisabledTest.TestApp.class,
|
|
||||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
|
||||||
class McpScopeMetadataDisabledTest {
|
|
||||||
|
|
||||||
private static final String ISSUER = "https://test-issuer.example.com";
|
|
||||||
private static final String RESOURCE_ID = "http://localhost/mcp";
|
|
||||||
|
|
||||||
@LocalServerPort private int port;
|
|
||||||
|
|
||||||
private final HttpClient http = HttpClient.newHttpClient();
|
|
||||||
|
|
||||||
// McpSecurityConfig is @ConditionalOnProperty("mcp.enabled"); that condition reads the Spring
|
|
||||||
// Environment, so it must be set here (the ApplicationProperties bean alone is not enough to
|
|
||||||
// register the chain).
|
|
||||||
@DynamicPropertySource
|
|
||||||
static void mcpProperties(DynamicPropertyRegistry registry) {
|
|
||||||
registry.add("mcp.enabled", () -> "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void metadata_omitsToolScopes_whenScopesDisabled() throws Exception {
|
|
||||||
String body = getMetadata("/.well-known/oauth-protected-resource");
|
|
||||||
assertThat(body).contains(RESOURCE_ID);
|
|
||||||
assertThat(body).contains(ISSUER);
|
|
||||||
assertThat(body).doesNotContain("mcp.tools.read");
|
|
||||||
assertThat(body).doesNotContain("mcp.tools.write");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void pathInsertedMetadata_omitsToolScopes_whenScopesDisabled() throws Exception {
|
|
||||||
String body = getMetadata("/.well-known/oauth-protected-resource/mcp");
|
|
||||||
assertThat(body).contains(RESOURCE_ID);
|
|
||||||
assertThat(body).contains("authorization_servers");
|
|
||||||
assertThat(body).doesNotContain("mcp.tools.read");
|
|
||||||
assertThat(body).doesNotContain("mcp.tools.write");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getMetadata(String path) throws Exception {
|
|
||||||
HttpRequest request =
|
|
||||||
HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create("http://localhost:" + port + path))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
assertThat(response.statusCode()).isEqualTo(200);
|
|
||||||
return response.body();
|
|
||||||
}
|
|
||||||
|
|
||||||
@SpringBootConfiguration
|
|
||||||
@EnableAutoConfiguration
|
|
||||||
@Import({McpSecurityConfig.class, McpServerController.class, DescribeOperationTool.class})
|
|
||||||
static class TestApp {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
ApplicationProperties applicationProperties() {
|
|
||||||
ApplicationProperties props = new ApplicationProperties();
|
|
||||||
props.getMcp().setEnabled(true);
|
|
||||||
props.getMcp().setScopesEnabled(false);
|
|
||||||
props.getMcp().getAuth().setIssuerUri(ISSUER);
|
|
||||||
// No real JWKS fetch happens for the permitAll metadata endpoint; a placeholder URI is
|
|
||||||
// fine because NimbusJwtDecoder.withJwkSetUri(...) resolves the key set lazily.
|
|
||||||
props.getMcp().getAuth().setJwksUri(ISSUER + "/jwks");
|
|
||||||
props.getMcp().getAuth().setResourceId(RESOURCE_ID);
|
|
||||||
props.getAutomaticallyGenerated().setAppVersion("test");
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
UserService userService() {
|
|
||||||
return org.mockito.Mockito.mock(UserService.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
package stirling.software.proprietary.policy.config;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.model.Team;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
|
||||||
|
|
||||||
/** Self-hosted policy context: a global admin may edit; scoping uses the current user's team. */
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class AdminPolicyManagementAuthorityTest {
|
|
||||||
|
|
||||||
@Mock private UserService userService;
|
|
||||||
|
|
||||||
private AdminPolicyManagementAuthority authority() {
|
|
||||||
return new AdminPolicyManagementAuthority(userService);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminMayEditPolicies() {
|
|
||||||
when(userService.isCurrentUserAdmin()).thenReturn(true);
|
|
||||||
assertTrue(authority().canEditPolicies());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void nonAdminMayNot() {
|
|
||||||
when(userService.isCurrentUserAdmin()).thenReturn(false);
|
|
||||||
assertFalse(authority().canEditPolicies());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void currentUserTeamIdResolvesFromTheCurrentUsersTeam() {
|
|
||||||
Team team = new Team();
|
|
||||||
team.setId(42L);
|
|
||||||
User user = new User();
|
|
||||||
user.setTeam(team);
|
|
||||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
|
||||||
when(userService.findByUsername("alice")).thenReturn(Optional.of(user));
|
|
||||||
assertEquals(42L, authority().currentUserTeamId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void currentUserTeamIdIsNullWhenNoCurrentUser() {
|
|
||||||
when(userService.getCurrentUsername()).thenReturn(null);
|
|
||||||
assertNull(authority().currentUserTeamId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
package stirling.software.proprietary.policy.config;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
|
||||||
import stirling.software.common.service.UserServiceInterface;
|
|
||||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
|
||||||
import stirling.software.proprietary.policy.model.Policy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only
|
|
||||||
* their own team's policies (admins included — there is no cross-team escape). Login disabled
|
|
||||||
* (single-user) bypasses scoping.
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class PolicyAccessGuardTest {
|
|
||||||
|
|
||||||
@Mock private UserServiceInterface userService;
|
|
||||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
|
||||||
|
|
||||||
private PolicyAccessGuard guard(boolean loginEnabled) {
|
|
||||||
ApplicationProperties properties = new ApplicationProperties();
|
|
||||||
properties.getSecurity().setEnableLogin(loginEnabled);
|
|
||||||
return new PolicyAccessGuard(userService, properties, policyManagementAuthority);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void visibleFiltersToTheCallersTeam() {
|
|
||||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
|
|
||||||
List<Policy> all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null));
|
|
||||||
List<Policy> visible = guard(true).visible(all);
|
|
||||||
assertEquals(2, visible.size());
|
|
||||||
assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId())));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void visibleReturnsEverythingWhenLoginDisabled() {
|
|
||||||
List<Policy> all = List.of(inTeam(1L), inTeam(2L));
|
|
||||||
assertEquals(all, guard(false).visible(all));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void canAccessOnlyOwnTeamsPolicy() {
|
|
||||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L);
|
|
||||||
assertTrue(guard(true).canAccess(inTeam(1L)));
|
|
||||||
assertFalse(guard(true).canAccess(inTeam(2L)));
|
|
||||||
assertFalse(guard(true).canAccess(inTeam(null)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void canAccessAnythingWhenLoginDisabled() {
|
|
||||||
assertTrue(guard(false).canAccess(inTeam(2L)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void ownerAndTeamForNewPolicyComeFromTheCurrentUserWhenLoginEnabled() {
|
|
||||||
when(userService.getCurrentUsername()).thenReturn("alice");
|
|
||||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(7L);
|
|
||||||
assertEquals("alice", guard(true).ownerForNewPolicy());
|
|
||||||
assertEquals(7L, guard(true).teamForNewPolicy());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void ownerAndTeamForNewPolicyAreNullWhenLoginDisabled() {
|
|
||||||
assertNull(guard(false).ownerForNewPolicy());
|
|
||||||
assertNull(guard(false).teamForNewPolicy());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Policy inTeam(Long teamId) {
|
|
||||||
return new Policy(
|
|
||||||
"p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-113
@@ -28,13 +28,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.slf4j.MDC;
|
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.client.HttpClientErrorException;
|
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.service.FileStorage;
|
import stirling.software.common.service.FileStorage;
|
||||||
@@ -174,35 +170,6 @@ class PolicyEngineTest {
|
|||||||
verify(taskManager, never()).setComplete(runId);
|
verify(taskManager, never()).setComplete(runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void runBlockedByUsageLimit_surfacesErrorCodeAndSubscribed() throws Exception {
|
|
||||||
// A downstream tool call gets a 402 entitlement block. The run fails, but its errorCode +
|
|
||||||
// subscribed are taken from the 402 body so the client can pop the right usage-limit modal
|
|
||||||
// (the policy 402 happens server-side, out of reach of the apiClient interceptor).
|
|
||||||
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
|
|
||||||
String body = "{\"error\":\"PAYG_LIMIT_REACHED\",\"subscribed\":true}";
|
|
||||||
when(internalApiClient.post(eq(ROTATE), any()))
|
|
||||||
.thenThrow(
|
|
||||||
HttpClientErrorException.create(
|
|
||||||
HttpStatus.PAYMENT_REQUIRED,
|
|
||||||
"Payment Required",
|
|
||||||
HttpHeaders.EMPTY,
|
|
||||||
body.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
|
||||||
java.nio.charset.StandardCharsets.UTF_8));
|
|
||||||
|
|
||||||
PolicyRun run =
|
|
||||||
engine.submit(
|
|
||||||
definition(new PipelineStep(ROTATE, Map.of())),
|
|
||||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
|
||||||
PolicyProgressListener.NOOP)
|
|
||||||
.completion()
|
|
||||||
.get(10, TimeUnit.SECONDS);
|
|
||||||
|
|
||||||
assertEquals(PolicyRunStatus.FAILED, run.getStatus());
|
|
||||||
assertEquals("PAYG_LIMIT_REACHED", run.getErrorCode());
|
|
||||||
assertEquals(Boolean.TRUE, run.getErrorSubscribed());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runPolicyExecutesThePolicysPipeline() throws Exception {
|
void runPolicyExecutesThePolicysPipeline() throws Exception {
|
||||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||||
@@ -237,86 +204,6 @@ class PolicyEngineTest {
|
|||||||
verify(internalApiClient).post(eq(ROTATE), any());
|
verify(internalApiClient).post(eq(ROTATE), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void runPolicyDispatchesToolCallsAsTheOwner() throws Exception {
|
|
||||||
// Billing-attribution regression: the pipeline runs on a background worker thread, but the
|
|
||||||
// policy owner must be propagated as the audit principal so InternalApiClient (and thus
|
|
||||||
// PAYG) attributes each tool call to the owner — not the INTERNAL_API_USER fallback.
|
|
||||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
|
||||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
|
||||||
int[] counter = {0};
|
|
||||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
|
||||||
.thenAnswer(
|
|
||||||
inv ->
|
|
||||||
new StoredFile(
|
|
||||||
"file-" + ++counter[0],
|
|
||||||
((InputStream) inv.getArgument(0)).readAllBytes().length));
|
|
||||||
|
|
||||||
String[] principalAtDispatch = {"<none>"};
|
|
||||||
when(internalApiClient.post(eq(ROTATE), any()))
|
|
||||||
.thenAnswer(
|
|
||||||
inv -> {
|
|
||||||
principalAtDispatch[0] = MDC.get("auditPrincipal");
|
|
||||||
return ResponseEntity.ok(pdf("rotated", "rotated.pdf"));
|
|
||||||
});
|
|
||||||
|
|
||||||
Policy policy =
|
|
||||||
new Policy(
|
|
||||||
"p1",
|
|
||||||
"rotate",
|
|
||||||
"alice",
|
|
||||||
true,
|
|
||||||
null,
|
|
||||||
List.of(new PipelineStep(ROTATE, Map.of())),
|
|
||||||
OutputSpec.inline());
|
|
||||||
|
|
||||||
engine.runPolicy(
|
|
||||||
policy,
|
|
||||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
|
||||||
PolicyProgressListener.NOOP)
|
|
||||||
.completion()
|
|
||||||
.get(10, TimeUnit.SECONDS);
|
|
||||||
|
|
||||||
assertEquals("alice", principalAtDispatch[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adHocRunDispatchesToolCallsAsTheSubmittingUser() throws Exception {
|
|
||||||
// Ad-hoc runs (no stored policy) bill whoever kicked them off; the principal is captured on
|
|
||||||
// the request thread (here simulated via MDC) and re-established on the worker thread.
|
|
||||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
|
||||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
|
||||||
int[] counter = {0};
|
|
||||||
when(fileStorage.storeInputStream(any(InputStream.class), anyString()))
|
|
||||||
.thenAnswer(
|
|
||||||
inv ->
|
|
||||||
new StoredFile(
|
|
||||||
"file-" + ++counter[0],
|
|
||||||
((InputStream) inv.getArgument(0)).readAllBytes().length));
|
|
||||||
|
|
||||||
String[] principalAtDispatch = {"<none>"};
|
|
||||||
when(internalApiClient.post(eq(ROTATE), any()))
|
|
||||||
.thenAnswer(
|
|
||||||
inv -> {
|
|
||||||
principalAtDispatch[0] = MDC.get("auditPrincipal");
|
|
||||||
return ResponseEntity.ok(pdf("rotated", "rotated.pdf"));
|
|
||||||
});
|
|
||||||
|
|
||||||
MDC.put("auditPrincipal", "bob"); // the request thread's audit principal
|
|
||||||
try {
|
|
||||||
engine.submit(
|
|
||||||
definition(new PipelineStep(ROTATE, Map.of())),
|
|
||||||
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
|
|
||||||
PolicyProgressListener.NOOP)
|
|
||||||
.completion()
|
|
||||||
.get(10, TimeUnit.SECONDS);
|
|
||||||
} finally {
|
|
||||||
MDC.remove("auditPrincipal");
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("bob", principalAtDispatch[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runIsQueuedUnderResourcePressure() {
|
void runIsQueuedUnderResourcePressure() {
|
||||||
when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true);
|
when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true);
|
||||||
|
|||||||
+1
-5
@@ -16,7 +16,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.core.env.Environment;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
@@ -37,7 +36,6 @@ class InitialSecuritySetupTest {
|
|||||||
@Mock private TeamService teamService;
|
@Mock private TeamService teamService;
|
||||||
@Mock private DatabaseServiceInterface databaseService;
|
@Mock private DatabaseServiceInterface databaseService;
|
||||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||||
@Mock private Environment environment;
|
|
||||||
|
|
||||||
private ApplicationProperties applicationProperties;
|
private ApplicationProperties applicationProperties;
|
||||||
private InitialSecuritySetup initialSecuritySetup;
|
private InitialSecuritySetup initialSecuritySetup;
|
||||||
@@ -55,15 +53,13 @@ class InitialSecuritySetupTest {
|
|||||||
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||||
.thenReturn(Optional.of(internalUser));
|
.thenReturn(Optional.of(internalUser));
|
||||||
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
|
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
|
||||||
when(environment.getActiveProfiles()).thenReturn(new String[] {});
|
|
||||||
initialSecuritySetup =
|
initialSecuritySetup =
|
||||||
new InitialSecuritySetup(
|
new InitialSecuritySetup(
|
||||||
userService,
|
userService,
|
||||||
teamService,
|
teamService,
|
||||||
applicationProperties,
|
applicationProperties,
|
||||||
databaseService,
|
databaseService,
|
||||||
licenseSettingsService,
|
licenseSettingsService);
|
||||||
environment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
-186
@@ -1,17 +1,13 @@
|
|||||||
package stirling.software.proprietary.security.controller.api;
|
package stirling.software.proprietary.security.controller.api;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -154,186 +150,4 @@ class UserControllerTest {
|
|||||||
|
|
||||||
verify(loginAttemptService).resetAttempts("lockeduser");
|
verify(loginAttemptService).resetAttempts("lockeduser");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// GET /api/v1/user/users - storage.signing.userListScope scoping
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static User user(long id, String username, boolean enabled, Team team) {
|
|
||||||
User u = new User();
|
|
||||||
u.setId(id);
|
|
||||||
u.setUsername(username);
|
|
||||||
u.setEnabled(enabled);
|
|
||||||
u.setTeam(team);
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Team team(long id, String name) {
|
|
||||||
Team t = new Team();
|
|
||||||
t.setId(id);
|
|
||||||
t.setName(name);
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Authentication auth(String username) {
|
|
||||||
return new UsernamePasswordAuthenticationToken(username, "pw");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersDefaultScopeIsOrgWide() throws Exception {
|
|
||||||
// Default "org" scope returns every enabled user via findAll(), no team lookup.
|
|
||||||
Team alpha = team(1L, "alpha");
|
|
||||||
when(userRepository.findAll())
|
|
||||||
.thenReturn(
|
|
||||||
List.of(
|
|
||||||
user(1L, "[email protected]", true, alpha),
|
|
||||||
user(2L, "[email protected]", true, alpha)));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.length()").value(2))
|
|
||||||
.andExpect(jsonPath("$[0].username").value("[email protected]"))
|
|
||||||
.andExpect(jsonPath("$[1].username").value("[email protected]"));
|
|
||||||
|
|
||||||
verify(userRepository, never()).findAllByTeamId(any());
|
|
||||||
verify(userService, never()).findByUsernameIgnoreCase(anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersOrgScopeFiltersDisabledUsers() throws Exception {
|
|
||||||
Team alpha = team(1L, "alpha");
|
|
||||||
when(userRepository.findAll())
|
|
||||||
.thenReturn(
|
|
||||||
List.of(
|
|
||||||
user(1L, "[email protected]", true, alpha),
|
|
||||||
user(2L, "[email protected]", false, alpha)));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.length()").value(1))
|
|
||||||
.andExpect(jsonPath("$[0].username").value("[email protected]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersTeamScopeReturnsOnlyCallerTeam() throws Exception {
|
|
||||||
applicationProperties.getStorage().getSigning().setUserListScope("team");
|
|
||||||
Team alpha = team(7L, "alpha");
|
|
||||||
User caller = user(1L, "[email protected]", true, alpha);
|
|
||||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
|
||||||
.thenReturn(Optional.of(caller));
|
|
||||||
when(userRepository.findAllByTeamId(7L))
|
|
||||||
.thenReturn(List.of(caller, user(2L, "[email protected]", true, alpha)));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.length()").value(2))
|
|
||||||
.andExpect(jsonPath("$[0].teamName").value("alpha"));
|
|
||||||
|
|
||||||
verify(userRepository).findAllByTeamId(7L);
|
|
||||||
verify(userRepository, never()).findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersTeamScopeWithMissingCallerReturnsEmpty() throws Exception {
|
|
||||||
applicationProperties.getStorage().getSigning().setUserListScope("team");
|
|
||||||
when(userService.findByUsernameIgnoreCase("[email protected]")).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.length()").value(0));
|
|
||||||
|
|
||||||
verify(userRepository, never()).findAllByTeamId(any());
|
|
||||||
verify(userRepository, never()).findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersTeamScopeWithNullTeamReturnsSelfOnly() throws Exception {
|
|
||||||
applicationProperties.getStorage().getSigning().setUserListScope("team");
|
|
||||||
User caller = user(1L, "[email protected]", true, null);
|
|
||||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
|
||||||
.thenReturn(Optional.of(caller));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.length()").value(1))
|
|
||||||
.andExpect(jsonPath("$[0].username").value("[email protected]"));
|
|
||||||
|
|
||||||
verify(userRepository, never()).findAllByTeamId(any());
|
|
||||||
verify(userRepository, never()).findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersFailsClosedOnUnrecognisedScope() throws Exception {
|
|
||||||
// Any non-"org" value must restrict to the caller's team, not leak the instance.
|
|
||||||
applicationProperties.getStorage().getSigning().setUserListScope("tewm");
|
|
||||||
Team alpha = team(3L, "alpha");
|
|
||||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
|
||||||
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
|
|
||||||
when(userRepository.findAllByTeamId(3L))
|
|
||||||
.thenReturn(List.of(user(1L, "[email protected]", true, alpha)));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(userRepository).findAllByTeamId(3L);
|
|
||||||
verify(userRepository, never()).findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersFailsClosedOnBlankScope() throws Exception {
|
|
||||||
applicationProperties.getStorage().getSigning().setUserListScope(" ");
|
|
||||||
Team alpha = team(4L, "alpha");
|
|
||||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
|
||||||
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
|
|
||||||
when(userRepository.findAllByTeamId(4L)).thenReturn(List.of());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(userRepository).findAllByTeamId(4L);
|
|
||||||
verify(userRepository, never()).findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersFailsClosedOnNullScope() throws Exception {
|
|
||||||
// A null value must also fail closed to the caller's team.
|
|
||||||
applicationProperties.getStorage().getSigning().setUserListScope(null);
|
|
||||||
Team alpha = team(9L, "alpha");
|
|
||||||
when(userService.findByUsernameIgnoreCase("[email protected]"))
|
|
||||||
.thenReturn(Optional.of(user(1L, "[email protected]", true, alpha)));
|
|
||||||
when(userRepository.findAllByTeamId(9L)).thenReturn(List.of());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(userRepository).findAllByTeamId(9L);
|
|
||||||
verify(userRepository, never()).findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersOrgScopeIsCaseInsensitive() throws Exception {
|
|
||||||
applicationProperties.getStorage().getSigning().setUserListScope("ORG");
|
|
||||||
when(userRepository.findAll()).thenReturn(List.of(user(1L, "[email protected]", true, null)));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/user/users").principal(auth("[email protected]")))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(userRepository).findAll();
|
|
||||||
verify(userRepository, never()).findAllByTeamId(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listUsersRequiresAuthentication() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/v1/user/users")).andExpect(status().isUnauthorized());
|
|
||||||
|
|
||||||
verify(userRepository, never()).findAll();
|
|
||||||
verify(userRepository, never()).findAllByTeamId(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void signingUserListScopeDefaultsToOrg() {
|
|
||||||
// Self-host backward-compat: default must stay "org" (saas profile flips it to "team").
|
|
||||||
assertEquals(
|
|
||||||
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,19 +38,11 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.saas.ai.model.AiCreateSession;
|
import stirling.software.saas.ai.model.AiCreateSession;
|
||||||
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
|
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
|
||||||
import stirling.software.saas.ai.service.AiCreateProxyService;
|
import stirling.software.saas.ai.service.AiCreateProxyService;
|
||||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
|
||||||
import stirling.software.saas.payg.charge.ChargeContext;
|
|
||||||
import stirling.software.saas.payg.charge.JobChargeService;
|
|
||||||
import stirling.software.saas.payg.model.BillingCategory;
|
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
|
||||||
import stirling.software.saas.payg.model.JobSource;
|
|
||||||
import stirling.software.saas.payg.model.ProcessType;
|
|
||||||
import stirling.software.saas.service.CreditService;
|
import stirling.software.saas.service.CreditService;
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
import stirling.software.saas.service.TeamCreditService;
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
@@ -62,7 +54,6 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
|||||||
@Tag(name = "AI")
|
@Tag(name = "AI")
|
||||||
@Hidden
|
@Hidden
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class AiCreateController {
|
public class AiCreateController {
|
||||||
|
|
||||||
@@ -73,7 +64,6 @@ public class AiCreateController {
|
|||||||
private final TeamCreditService teamCreditService;
|
private final TeamCreditService teamCreditService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final CreditHeaderUtils creditHeaderUtils;
|
private final CreditHeaderUtils creditHeaderUtils;
|
||||||
private final JobChargeService jobChargeService;
|
|
||||||
|
|
||||||
@PostMapping("/sessions")
|
@PostMapping("/sessions")
|
||||||
public ResponseEntity<CreateSessionResponse> createSession(
|
public ResponseEntity<CreateSessionResponse> createSession(
|
||||||
@@ -94,45 +84,9 @@ public class AiCreateController {
|
|||||||
session.getUserId(),
|
session.getUserId(),
|
||||||
session.getDocType(),
|
session.getDocType(),
|
||||||
session.getTemplateId());
|
session.getTemplateId());
|
||||||
chargeForCreate(session);
|
|
||||||
return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId()));
|
return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bill one document for a new AI Create session — creating a document is the charge point;
|
|
||||||
* follow-up edits on the same session (outline / reprompt / draft / template / stream) carry no
|
|
||||||
* charge. AI usage is billable, so a JWT (web) session counts the same as an API-key one.
|
|
||||||
*
|
|
||||||
* <p>Best-effort: a charge failure must not block the user's session. Entitlement is already
|
|
||||||
* enforced upstream — this controller is {@code @RequiresFeature(AI_SUPPORT)}, so the
|
|
||||||
* EntitlementGuard 402s a team with no AI allowance before we ever get here; this call only
|
|
||||||
* does the accounting (free-grant draw + Stripe meter).
|
|
||||||
*/
|
|
||||||
private void chargeForCreate(AiCreateSession session) {
|
|
||||||
try {
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
|
||||||
if (user == null || user.getTeam() == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
JobSource source =
|
|
||||||
auth instanceof ApiKeyAuthenticationToken ? JobSource.API : JobSource.WEB;
|
|
||||||
ChargeContext ctx =
|
|
||||||
new ChargeContext(
|
|
||||||
user.getId(),
|
|
||||||
user.getTeam().getId(),
|
|
||||||
source,
|
|
||||||
ProcessType.SINGLE_TOOL,
|
|
||||||
BillingCategory.AI);
|
|
||||||
jobChargeService.chargeStandalone(ctx, 1);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn(
|
|
||||||
"AI create session {} charge failed; session proceeds unbilled: {}",
|
|
||||||
session.getSessionId(),
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/sessions/{sessionId}")
|
@DeleteMapping("/sessions/{sessionId}")
|
||||||
public ResponseEntity<Void> deleteSession(@PathVariable String sessionId) {
|
public ResponseEntity<Void> deleteSession(@PathVariable String sessionId) {
|
||||||
sessionService.deleteSessionForCurrentUser(sessionId);
|
sessionService.deleteSessionForCurrentUser(sessionId);
|
||||||
|
|||||||
-3
@@ -26,8 +26,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.saas.ai.model.AiCreateSession;
|
import stirling.software.saas.ai.model.AiCreateSession;
|
||||||
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
||||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@Profile("saas")
|
@Profile("saas")
|
||||||
@@ -35,7 +33,6 @@ import stirling.software.saas.payg.model.FeatureGate;
|
|||||||
@Tag(name = "AI")
|
@Tag(name = "AI")
|
||||||
@Hidden
|
@Hidden
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class AiCreateInternalController {
|
public class AiCreateInternalController {
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.saas.ai.service.AiProxyService;
|
import stirling.software.saas.ai.service.AiProxyService;
|
||||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
|
||||||
import stirling.software.saas.service.CreditService;
|
import stirling.software.saas.service.CreditService;
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
import stirling.software.saas.service.TeamCreditService;
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
@@ -38,7 +36,6 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
|||||||
@RestController
|
@RestController
|
||||||
@Profile("saas")
|
@Profile("saas")
|
||||||
@RequestMapping("/api/v1/ai")
|
@RequestMapping("/api/v1/ai")
|
||||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
|
||||||
@Tag(name = "AI")
|
@Tag(name = "AI")
|
||||||
@Hidden
|
@Hidden
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ import lombok.RequiredArgsConstructor;
|
|||||||
|
|
||||||
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
|
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
|
||||||
|
|
||||||
// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits
|
|
||||||
// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back.
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@Profile("saas & legacy-credits")
|
@Profile("saas")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CreditInterceptorConfig implements WebMvcConfigurer {
|
public class CreditInterceptorConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ import stirling.software.saas.service.CreditService;
|
|||||||
import stirling.software.saas.service.CreditService.CreditSummary;
|
import stirling.software.saas.service.CreditService.CreditSummary;
|
||||||
import stirling.software.saas.util.LogRedactionUtils;
|
import stirling.software.saas.util.LogRedactionUtils;
|
||||||
|
|
||||||
// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile.
|
|
||||||
@RestController
|
@RestController
|
||||||
@Profile("saas & legacy-credits")
|
@Profile("saas")
|
||||||
@RequestMapping("/api/v1/credits")
|
@RequestMapping("/api/v1/credits")
|
||||||
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
|
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|||||||
@@ -39,10 +39,8 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
|||||||
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
|
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
|
||||||
* exception flow.
|
* exception flow.
|
||||||
*/
|
*/
|
||||||
// Legacy credit-billing error advice. PAYG handles its own error semantics via
|
|
||||||
// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed.
|
|
||||||
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
|
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
|
||||||
@Profile("saas & legacy-credits")
|
@Profile("saas")
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Order(1)
|
@Order(1)
|
||||||
public class CreditErrorAdvice {
|
public class CreditErrorAdvice {
|
||||||
|
|||||||
@@ -27,10 +27,8 @@ import stirling.software.saas.service.TeamCreditService;
|
|||||||
import stirling.software.saas.util.AuthenticationUtils;
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
import stirling.software.saas.util.CreditHeaderUtils;
|
import stirling.software.saas.util.CreditHeaderUtils;
|
||||||
|
|
||||||
// Legacy credit-billing success advice. PAYG writes its own ledger entries via
|
|
||||||
// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed.
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
@Profile("saas & legacy-credits")
|
@Profile("saas")
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
|
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -32,10 +32,8 @@ import stirling.software.saas.service.SaasUserExtensionService;
|
|||||||
import stirling.software.saas.service.TeamCreditService;
|
import stirling.software.saas.service.TeamCreditService;
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
|
|
||||||
// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled
|
|
||||||
// by default in saas, activate legacy-credits profile to bring it back.
|
|
||||||
@Component
|
@Component
|
||||||
@Profile("saas & legacy-credits")
|
@Profile("saas")
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
|
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package stirling.software.saas.payg.api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cap conversion between the dollar amount the leader edits in the UI and the unit count stored on
|
|
||||||
* {@code wallet_policy.cap_units}.
|
|
||||||
*
|
|
||||||
* <p>The cap is application-layer only — Stripe stays on a flat-priced single meter, so the
|
|
||||||
* conversion rate here doesn't need to match any Stripe price. It only needs to be stable: a leader
|
|
||||||
* who set "$25" should read back "$25" on the next page load.
|
|
||||||
*
|
|
||||||
* <p>V1 rate: {@value #UNITS_PER_USD} units = $1. This anchors the in-app cap representation to the
|
|
||||||
* same unit count the ledger writes, so the "X of Y units used" widget in the FE lines up against
|
|
||||||
* the cap. A future iteration can read this from {@code pricing_policy} once the per-policy money
|
|
||||||
* conversion lands.
|
|
||||||
*
|
|
||||||
* <p>Both directions floor: $24.50 → 2450 units, 2450 units → $24. The FE only sends whole-dollar
|
|
||||||
* inputs (the cap-edit field is an integer text box) so the floor on the read path is the only
|
|
||||||
* place rounding ever shows up, and only when an admin set a non-multiple via SQL.
|
|
||||||
*/
|
|
||||||
public final class CapMoneyUnits {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Doc-units per USD. {@code 100} = "1 cent per unit" at the in-app display layer. Tied to the
|
|
||||||
* unit-count meter the engine writes to the ledger; not tied to Stripe pricing.
|
|
||||||
*/
|
|
||||||
public static final int UNITS_PER_USD = 100;
|
|
||||||
|
|
||||||
/** Smallest currency unit per USD (always 100 cents in USD; explicit for clarity). */
|
|
||||||
public static final int CENTS_PER_USD = 100;
|
|
||||||
|
|
||||||
private CapMoneyUnits() {}
|
|
||||||
|
|
||||||
/** Convert a dollar cap entered by the leader to doc-units for {@code cap_units}. */
|
|
||||||
public static long usdToUnits(int capUsd) {
|
|
||||||
if (capUsd < 0) {
|
|
||||||
throw new IllegalArgumentException("capUsd must be >= 0");
|
|
||||||
}
|
|
||||||
return (long) capUsd * UNITS_PER_USD;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert {@code cap_units} back to dollars for the response payload. Floor on read. */
|
|
||||||
public static int unitsToUsd(long capUnits) {
|
|
||||||
if (capUnits < 0L) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (int) (capUnits / UNITS_PER_USD);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert a dollar cap to smallest-currency-unit cents for {@code cap_source_money}. */
|
|
||||||
public static long usdToCents(int capUsd) {
|
|
||||||
if (capUsd < 0) {
|
|
||||||
throw new IllegalArgumentException("capUsd must be >= 0");
|
|
||||||
}
|
|
||||||
return (long) capUsd * CENTS_PER_USD;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
package stirling.software.saas.payg.api;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Hidden;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.common.model.enumeration.TeamRole;
|
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.model.TeamMembership;
|
|
||||||
import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow;
|
|
||||||
import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown;
|
|
||||||
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
|
|
||||||
import stirling.software.saas.payg.billing.TeamBillingContext;
|
|
||||||
import stirling.software.saas.payg.billing.TeamBillingService;
|
|
||||||
import stirling.software.saas.payg.entitlement.EntitlementService;
|
|
||||||
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
|
|
||||||
import stirling.software.saas.payg.model.BillingCategory;
|
|
||||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
|
||||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
|
||||||
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
|
|
||||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
|
||||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
|
||||||
import stirling.software.saas.payg.repository.WalletPolicyRepository;
|
|
||||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
|
||||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
|
||||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read + cap-mutation surface backing the FE PAYG Plan page.
|
|
||||||
*
|
|
||||||
* <p>{@code GET /api/v1/payg/wallet} is the single fetch the {@code useWallet} hook calls. Returns
|
|
||||||
* a fully-populated {@link WalletSnapshotResponse} — derived from {@link EntitlementService} (for
|
|
||||||
* spend / cap / period), {@link PaygTeamExtensions} (for subscription state), and {@link
|
|
||||||
* WalletLedgerRepository} (for the per-category breakdown widget). Leader callers also get a roster
|
|
||||||
* of team members + their per-member usage; member callers see an empty roster.
|
|
||||||
*
|
|
||||||
* <p>{@code PATCH /api/v1/payg/cap} updates {@code wallet_policy.cap_units} (no Stripe call — the
|
|
||||||
* cap is enforced application-side via the entitlement guard) and invalidates the team's snapshot
|
|
||||||
* cache so the next read reflects the change immediately. Only leaders may call this; the team is
|
|
||||||
* derived from the caller, so we authorise inside the method rather than via {@code @PreAuthorize}
|
|
||||||
* — the team id never appears on the path or query string.
|
|
||||||
*
|
|
||||||
* <p>Subscription state is sourced from {@code payg_team_extensions.payg_subscription_id} (added in
|
|
||||||
* V14): {@code stripeSubscriptionId} echoes it via {@link TeamBillingService}, and a team reads as
|
|
||||||
* {@link #STATUS_SUBSCRIBED} once {@code billing.subscribed()} is true — i.e. it has a subscription
|
|
||||||
* id, or a Stripe customer id as the pre-webhook bridge for a just-completed checkout whose
|
|
||||||
* subscription-created webhook hasn't landed yet (see {@code TeamBillingService.compute}).
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Hidden
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/v1/payg")
|
|
||||||
@Profile("saas")
|
|
||||||
public class PaygWalletController {
|
|
||||||
|
|
||||||
static final String STATUS_FREE = "free";
|
|
||||||
static final String STATUS_SUBSCRIBED = "subscribed";
|
|
||||||
static final String ROLE_LEADER = "leader";
|
|
||||||
static final String ROLE_MEMBER = "member";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder ceiling for the team-less empty snapshot only (authenticated caller without a
|
|
||||||
* membership — shouldn't happen post-migration). Teams always get the live {@code
|
|
||||||
* pricing_policy.free_tier_units} grant via {@link TeamBillingService}.
|
|
||||||
*/
|
|
||||||
private static final int FREE_TIER_LIMIT_UNITS_FALLBACK = 500;
|
|
||||||
|
|
||||||
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
|
|
||||||
|
|
||||||
private final EntitlementService entitlementService;
|
|
||||||
private final TeamBillingService billingService;
|
|
||||||
private final TeamMembershipRepository memberRepo;
|
|
||||||
private final PaygTeamExtensionsRepository extRepo;
|
|
||||||
private final WalletPolicyRepository policyRepo;
|
|
||||||
private final WalletLedgerRepository ledgerRepo;
|
|
||||||
private final PaygShadowChargeRepository shadowRepo;
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
|
|
||||||
public PaygWalletController(
|
|
||||||
EntitlementService entitlementService,
|
|
||||||
TeamBillingService billingService,
|
|
||||||
TeamMembershipRepository memberRepo,
|
|
||||||
PaygTeamExtensionsRepository extRepo,
|
|
||||||
WalletPolicyRepository policyRepo,
|
|
||||||
WalletLedgerRepository ledgerRepo,
|
|
||||||
PaygShadowChargeRepository shadowRepo,
|
|
||||||
UserRepository userRepository) {
|
|
||||||
this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService");
|
|
||||||
this.billingService = Objects.requireNonNull(billingService, "billingService");
|
|
||||||
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
|
|
||||||
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
|
|
||||||
this.policyRepo = Objects.requireNonNull(policyRepo, "policyRepo");
|
|
||||||
this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo");
|
|
||||||
this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo");
|
|
||||||
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
|
||||||
// GET /wallet — the single FE fetch
|
|
||||||
// ---------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@GetMapping("/wallet")
|
|
||||||
@PreAuthorize("isAuthenticated()")
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
|
|
||||||
User user;
|
|
||||||
try {
|
|
||||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
|
||||||
} catch (SecurityException e) {
|
|
||||||
// SecurityException maps to 401 per the existing controller convention.
|
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<TeamMembership> primary = primaryMembership(user.getId());
|
|
||||||
if (primary.isEmpty()) {
|
|
||||||
// Authenticated user without a team — shouldn't happen post-migration, but we don't
|
|
||||||
// want to 500. Return a free-tier-shaped empty snapshot so the FE renders the gated UI
|
|
||||||
// rather than blowing up on a null body.
|
|
||||||
return ResponseEntity.ok(emptySnapshot());
|
|
||||||
}
|
|
||||||
|
|
||||||
TeamMembership membership = primary.get();
|
|
||||||
Long teamId = membership.getTeam().getId();
|
|
||||||
boolean isLeader = membership.getRole() == TeamRole.LEADER;
|
|
||||||
|
|
||||||
// Billing facts (window, free allowance, per-doc rate, doc cap) and the entitlement
|
|
||||||
// snapshot (period spend over that window) share the same composition service, so what
|
|
||||||
// the customer sees here is exactly what the 402 guard enforces.
|
|
||||||
TeamBillingContext billing = billingService.forTeam(teamId);
|
|
||||||
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
|
|
||||||
|
|
||||||
String status = billing.subscribed() ? STATUS_SUBSCRIBED : STATUS_FREE;
|
|
||||||
|
|
||||||
boolean noCap = billing.subscribed() && billing.capMoneyMinor() == null;
|
|
||||||
Integer capMajor =
|
|
||||||
billing.capMoneyMinor() != null
|
|
||||||
? Math.toIntExact(billing.capMoneyMinor() / 100)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Per-state by construction (see EntitlementService.computeSnapshot): free team → spend is
|
|
||||||
// lifetime free used, cap is the grant size; subscribed → spend is this month's net
|
|
||||||
// billable
|
|
||||||
// docs, cap is the monthly paid-doc ceiling (null = uncapped).
|
|
||||||
int spend = clampToInt(snap.periodSpendUnits());
|
|
||||||
Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null;
|
|
||||||
|
|
||||||
CategoryBreakdown breakdown = buildBreakdown(teamId, snap.periodStart(), snap.periodEnd());
|
|
||||||
|
|
||||||
// Estimated bill = paid (Stripe-metered) docs this period × rate — the free portion was
|
|
||||||
// already netted out at charge time, so this is the metered total, not spend − grant.
|
|
||||||
long periodPaid = shadowRepo.sumPaidUnits(teamId, snap.periodStart(), snap.periodEnd());
|
|
||||||
Long estimatedBill = billingService.estimateBillMinor(billing, periodPaid).orElse(null);
|
|
||||||
|
|
||||||
List<MemberRow> members =
|
|
||||||
isLeader
|
|
||||||
? buildMemberRows(teamId, snap.periodStart(), snap.periodEnd())
|
|
||||||
: List.of();
|
|
||||||
|
|
||||||
WalletSnapshotResponse body =
|
|
||||||
new WalletSnapshotResponse(
|
|
||||||
teamId,
|
|
||||||
status,
|
|
||||||
isLeader ? ROLE_LEADER : ROLE_MEMBER,
|
|
||||||
ISO_DATE.format(snap.periodStart().toLocalDate()),
|
|
||||||
ISO_DATE.format(snap.periodEnd().toLocalDate()),
|
|
||||||
spend,
|
|
||||||
limit,
|
|
||||||
clampToInt(billing.freeGrantUnits()),
|
|
||||||
clampToInt(billing.freeRemainingUnits()),
|
|
||||||
billing.perDocMinor(),
|
|
||||||
billing.currency(),
|
|
||||||
estimatedBill,
|
|
||||||
capMajor,
|
|
||||||
noCap,
|
|
||||||
billing.subscriptionId(),
|
|
||||||
spend,
|
|
||||||
breakdown,
|
|
||||||
members,
|
|
||||||
buildActivity(teamId));
|
|
||||||
return ResponseEntity.ok(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private CategoryBreakdown buildBreakdown(
|
|
||||||
Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) {
|
|
||||||
Map<BillingCategory, Long> byCategory = new HashMap<>();
|
|
||||||
for (Object[] row :
|
|
||||||
ledgerRepo.sumPeriodAmountByCategory(
|
|
||||||
teamId, LedgerEntryType.DEBIT, periodStart, periodEnd)) {
|
|
||||||
if (row.length >= 2
|
|
||||||
&& row[0] instanceof BillingCategory cat
|
|
||||||
&& row[1] instanceof Number n) {
|
|
||||||
byCategory.put(cat, n.longValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new CategoryBreakdown(
|
|
||||||
clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)),
|
|
||||||
clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)),
|
|
||||||
clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Latest ledger entries shaped for the FE activity feed. DEBITs read as usage, REFUNDs as
|
|
||||||
* credits-back; system entries without a category render as {@code other}.
|
|
||||||
*/
|
|
||||||
private List<ActivityRow> buildActivity(Long teamId) {
|
|
||||||
List<ActivityRow> out = new ArrayList<>();
|
|
||||||
for (WalletLedgerEntry e : ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)) {
|
|
||||||
BillingCategory category = e.getBillingCategory();
|
|
||||||
String kind = category != null ? category.name().toLowerCase(Locale.ROOT) : "other";
|
|
||||||
String categoryLabel = category != null ? categoryDisplayName(category) : "Document";
|
|
||||||
String label =
|
|
||||||
e.getEntryType() == LedgerEntryType.REFUND
|
|
||||||
? "Refund — " + categoryLabel
|
|
||||||
: categoryLabel + " usage";
|
|
||||||
int docUnits = e.getAmountUnits() == null ? 0 : Math.abs(e.getAmountUnits());
|
|
||||||
out.add(
|
|
||||||
new ActivityRow(
|
|
||||||
e.getId(),
|
|
||||||
kind,
|
|
||||||
label,
|
|
||||||
e.getOccurredAt() != null ? e.getOccurredAt().toString() : "",
|
|
||||||
docUnits));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String categoryDisplayName(BillingCategory category) {
|
|
||||||
return switch (category) {
|
|
||||||
case API -> "API";
|
|
||||||
case AI -> "AI";
|
|
||||||
case AUTOMATION -> "Automation";
|
|
||||||
case BYPASSED -> "Manual";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
|
||||||
// PATCH /cap — leader-only, cap is application-layer, no Stripe call
|
|
||||||
// ---------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@PatchMapping("/cap")
|
|
||||||
@PreAuthorize("isAuthenticated()")
|
|
||||||
@Transactional
|
|
||||||
public ResponseEntity<Void> updateCap(
|
|
||||||
@Valid @RequestBody UpdateCapRequest req, Authentication auth) {
|
|
||||||
User user;
|
|
||||||
try {
|
|
||||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
|
||||||
} catch (SecurityException e) {
|
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
|
||||||
}
|
|
||||||
Optional<TeamMembership> primary = primaryMembership(user.getId());
|
|
||||||
if (primary.isEmpty()) {
|
|
||||||
// No team → can't have a wallet to cap.
|
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
|
||||||
}
|
|
||||||
TeamMembership membership = primary.get();
|
|
||||||
if (membership.getRole() != TeamRole.LEADER) {
|
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
|
||||||
}
|
|
||||||
Long teamId = membership.getTeam().getId();
|
|
||||||
|
|
||||||
WalletPolicy policy =
|
|
||||||
policyRepo
|
|
||||||
.findByTeamId(teamId)
|
|
||||||
.orElseGet(
|
|
||||||
() -> {
|
|
||||||
WalletPolicy created = new WalletPolicy();
|
|
||||||
created.setTeamId(teamId);
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (req.noCap()) {
|
|
||||||
policy.setCapUnits(null);
|
|
||||||
policy.setCapSourceMoney(null);
|
|
||||||
} else {
|
|
||||||
long capMinor = CapMoneyUnits.usdToCents(req.capUsd());
|
|
||||||
policy.setCapSourceMoney(capMinor);
|
|
||||||
// Derived document allowance: store both the money intent and the unit translation.
|
|
||||||
// The live snapshot recomputes from cap_source_money + current rate; this stored value
|
|
||||||
// is the enforcement fallback when the rate is unreachable.
|
|
||||||
TeamBillingContext billing = billingService.forTeam(teamId);
|
|
||||||
Optional<Long> docCap = billingService.docCapForMoney(billing, capMinor);
|
|
||||||
if (docCap.isPresent()) {
|
|
||||||
policy.setCapUnits(docCap.get());
|
|
||||||
} else {
|
|
||||||
// Rate unknown (price-info fn unconfigured / Stripe blip): keep the legacy
|
|
||||||
// money-as-units conversion so the cap still binds rather than silently lifting.
|
|
||||||
log.warn(
|
|
||||||
"Per-document rate unavailable for team {}; storing legacy cap_units"
|
|
||||||
+ " conversion.",
|
|
||||||
teamId);
|
|
||||||
policy.setCapUnits(CapMoneyUnits.usdToUnits(req.capUsd()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
policyRepo.save(policy);
|
|
||||||
entitlementService.invalidate(teamId);
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Request body for {@link #updateCap}. */
|
|
||||||
public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private Optional<TeamMembership> primaryMembership(Long userId) {
|
|
||||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(userId);
|
|
||||||
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<MemberRow> buildMemberRows(
|
|
||||||
Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) {
|
|
||||||
List<TeamMembership> all = memberRepo.findByTeamId(teamId);
|
|
||||||
if (all.isEmpty()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
LocalDateTime[] window = {periodStart, periodEnd};
|
|
||||||
List<MemberRow> out = new ArrayList<>(all.size());
|
|
||||||
for (TeamMembership tm : all) {
|
|
||||||
User u = tm.getUser();
|
|
||||||
if (u == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// We could batch these; team sizes are small (FE design assumes ≤ ~20 members per
|
|
||||||
// team on the Plan page) so a per-member sum is fine. If teams grow we'd switch to
|
|
||||||
// a single GROUP BY actor_user_id query.
|
|
||||||
long spend = 0L; // sumPeriodAmountForMember stores signed debits (negative); negate.
|
|
||||||
try {
|
|
||||||
spend = -memberSpend(teamId, u.getId(), window[0], window[1]);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn(
|
|
||||||
"buildMemberRows: per-member spend lookup failed for user {}",
|
|
||||||
u.getId(),
|
|
||||||
e);
|
|
||||||
}
|
|
||||||
String displayName =
|
|
||||||
Optional.ofNullable(u.getUsername())
|
|
||||||
.orElse(Optional.ofNullable(u.getEmail()).orElse(""));
|
|
||||||
out.add(
|
|
||||||
new MemberRow(
|
|
||||||
Long.toString(u.getId()),
|
|
||||||
displayName,
|
|
||||||
Optional.ofNullable(u.getEmail()).orElse(""),
|
|
||||||
clampToInt(spend)));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-member period spend in signed ledger units (debits are negative). Helper so the test
|
|
||||||
* slice can override without standing up a real database, and so the controller doesn't inline
|
|
||||||
* the negation arithmetic at every call site.
|
|
||||||
*/
|
|
||||||
long memberSpend(Long teamId, Long userId, LocalDateTime start, LocalDateTime end) {
|
|
||||||
return ledgerRepo.sumPeriodAmountForMember(
|
|
||||||
teamId, userId, LedgerEntryType.DEBIT, start, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LocalDateTime[] currentMonthWindow() {
|
|
||||||
java.time.YearMonth ym = java.time.YearMonth.now();
|
|
||||||
LocalDateTime start = ym.atDay(1).atStartOfDay();
|
|
||||||
LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay();
|
|
||||||
return new LocalDateTime[] {start, end};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int clampToInt(long v) {
|
|
||||||
if (v <= 0) return 0;
|
|
||||||
if (v >= Integer.MAX_VALUE) return Integer.MAX_VALUE;
|
|
||||||
return (int) v;
|
|
||||||
}
|
|
||||||
|
|
||||||
private WalletSnapshotResponse emptySnapshot() {
|
|
||||||
LocalDateTime[] window = currentMonthWindow();
|
|
||||||
return new WalletSnapshotResponse(
|
|
||||||
null, // teamId — unknown when the caller has no team membership
|
|
||||||
STATUS_FREE,
|
|
||||||
ROLE_MEMBER,
|
|
||||||
ISO_DATE.format(window[0].toLocalDate()),
|
|
||||||
ISO_DATE.format(window[1].toLocalDate()),
|
|
||||||
0,
|
|
||||||
FREE_TIER_LIMIT_UNITS_FALLBACK,
|
|
||||||
FREE_TIER_LIMIT_UNITS_FALLBACK,
|
|
||||||
FREE_TIER_LIMIT_UNITS_FALLBACK,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
0,
|
|
||||||
new CategoryBreakdown(0, 0, 0),
|
|
||||||
List.of(),
|
|
||||||
Collections.emptyList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package stirling.software.saas.payg.api;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON payload returned by {@code GET /api/v1/payg/wallet}. Mirrors the {@code Wallet} type the
|
|
||||||
* frontend {@code useWallet} hook consumes, plus the leader-only fields ({@code members},
|
|
||||||
* breakdowns, recent activity) used by the PAYG Plan page.
|
|
||||||
*
|
|
||||||
* <p>Every number is real: the billing window is the Stripe subscription's current period (via Sync
|
|
||||||
* Engine) for subscribed teams, the one-time free grant size comes from {@code
|
|
||||||
* pricing_policy.free_tier_units} (live balance from {@code
|
|
||||||
* payg_team_extensions.free_units_remaining}), and the per-document rate comes from the
|
|
||||||
* subscription's Stripe Price. Fields that can't be resolved are {@code null} and the FE renders
|
|
||||||
* "unknown" — never a substituted default.
|
|
||||||
*
|
|
||||||
* @param teamId the caller's primary team_id. Needed by the frontend so it can pass it to the
|
|
||||||
* Supabase edge functions that create Stripe Checkout / portal sessions — those run outside
|
|
||||||
* Spring Security and have no other way to resolve the caller's team.
|
|
||||||
* @param status {@code "free"} when the team has no Stripe subscription; {@code "subscribed"} once
|
|
||||||
* a card is on file and the engine bills meter events.
|
|
||||||
* @param role the current caller's role within their team — {@code "leader"} or {@code "member"}.
|
|
||||||
* Controls which UI variant the frontend renders.
|
|
||||||
* @param billingPeriodStart inclusive ISO date (yyyy-MM-dd) for the current cycle — the Stripe
|
|
||||||
* subscription period when subscribed, the calendar month otherwise.
|
|
||||||
* @param billingPeriodEnd exclusive ISO date (yyyy-MM-dd) for the current cycle.
|
|
||||||
* @param billableUsed alias of {@code spendUnitsThisPeriod} kept for clarity in the FE. For a free
|
|
||||||
* team this is the lifetime free documents used so far ({@code freeAllowance − freeRemaining});
|
|
||||||
* for a subscribed team it's this month's net billable documents.
|
|
||||||
* @param billableLimit the team's document ceiling for the matching window: the one-time free grant
|
|
||||||
* ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month for
|
|
||||||
* capped subscribed teams; {@code null} when subscribed with no cap (uncapped).
|
|
||||||
* @param freeAllowance the team's one-time free document grant size (the "N" in "X of N free").
|
|
||||||
* Never resets; survives subscribing. Applies to billable categories only.
|
|
||||||
* @param freeRemaining one-time free documents still available to the team ({@code
|
|
||||||
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
|
|
||||||
* @param pricePerDocMinor paid per-document rate in minor units of {@code currency} (may be
|
|
||||||
* fractional — Stripe supports sub-cent rates); {@code null} when the rate can't be resolved.
|
|
||||||
* @param currency lower-case ISO 4217 currency of the subscription's Stripe Price; {@code null}
|
|
||||||
* when unknown (free teams, unresolved rate).
|
|
||||||
* @param estimatedBillMinor estimated charges so far this period in minor units of {@code
|
|
||||||
* currency}: paid (Stripe-metered) documents this period × {@code pricePerDocMinor}. The free
|
|
||||||
* portion was already netted out at charge time. Informational — the Stripe invoice is
|
|
||||||
* authoritative. {@code null} when the rate is unknown.
|
|
||||||
* @param capUsd the leader's monthly spending cap in major currency units; {@code null} when free
|
|
||||||
* or when the leader has opted into no-cap. (Field name predates multi-currency; the FE pairs
|
|
||||||
* it with {@code currency} for the symbol.)
|
|
||||||
* @param noCap {@code true} when the leader has explicitly disabled the cap. Only meaningful when
|
|
||||||
* subscribed.
|
|
||||||
* @param stripeSubscriptionId Stripe subscription id from {@code
|
|
||||||
* payg_team_extensions.payg_subscription_id}; {@code null} when status is free.
|
|
||||||
* @param spendUnitsThisPeriod documents debited this cycle across billable categories.
|
|
||||||
* @param categoryBreakdown per-category spend slice over the same billing window.
|
|
||||||
* @param members leader-only roster of team members + their per-member sub-caps. Empty for member
|
|
||||||
* callers.
|
|
||||||
* @param recent latest wallet-ledger entries (newest first) for the activity feed.
|
|
||||||
*/
|
|
||||||
public record WalletSnapshotResponse(
|
|
||||||
Long teamId,
|
|
||||||
String status,
|
|
||||||
String role,
|
|
||||||
String billingPeriodStart,
|
|
||||||
String billingPeriodEnd,
|
|
||||||
int billableUsed,
|
|
||||||
Integer billableLimit,
|
|
||||||
int freeAllowance,
|
|
||||||
int freeRemaining,
|
|
||||||
BigDecimal pricePerDocMinor,
|
|
||||||
String currency,
|
|
||||||
Long estimatedBillMinor,
|
|
||||||
Integer capUsd,
|
|
||||||
boolean noCap,
|
|
||||||
String stripeSubscriptionId,
|
|
||||||
int spendUnitsThisPeriod,
|
|
||||||
CategoryBreakdown categoryBreakdown,
|
|
||||||
List<MemberRow> members,
|
|
||||||
List<ActivityRow> recent) {
|
|
||||||
|
|
||||||
/** Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget. */
|
|
||||||
public record CategoryBreakdown(int api, int ai, int automation) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row of the team-members table on the leader's Plan page — display-only per-member usage.
|
|
||||||
* (Per-member sub-caps aren't enforced yet. When they ship, a cap field returns here.)
|
|
||||||
*/
|
|
||||||
public record MemberRow(String userId, String name, String email, int spendUnits) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One wallet-ledger entry shaped for the FE activity feed.
|
|
||||||
*
|
|
||||||
* @param id ledger entry id (stable React key)
|
|
||||||
* @param kind lower-case billing category ({@code api} / {@code ai} / {@code automation}) or
|
|
||||||
* {@code other} for system entries
|
|
||||||
* @param label human line, e.g. {@code "API usage"} or {@code "Refund — API"}
|
|
||||||
* @param ts ISO-8601 local timestamp of the entry
|
|
||||||
* @param docUnits absolute document count of the entry
|
|
||||||
*/
|
|
||||||
public record ActivityRow(long id, String kind, String label, String ts, int docUnits) {}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package stirling.software.saas.payg.billing;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One team's billing facts, composed by {@link TeamBillingService}. Two independent meters live
|
|
||||||
* here and must not be conflated:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>the <b>one-time lifetime free grant</b> ({@link #freeGrantUnits} total, {@link
|
|
||||||
* #freeRemainingUnits} left) — gates an un-subscribed team and decides the free-vs-paid split
|
|
||||||
* of every job; never resets, survives subscribing;
|
|
||||||
* <li>the <b>monthly billing window</b> ({@link #periodStart}/{@link #periodEnd}) and the
|
|
||||||
* optional monthly spending cap ({@link #monthlyCapDocUnits}) — govern the subscribed invoice
|
|
||||||
* + cap only.
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @param subscribed team has a live PAYG subscription — i.e. {@code payg_subscription_id} is set.
|
|
||||||
* Cleared by {@code payg_unlink_subscription} on cancellation, so a cancelled team reads false.
|
|
||||||
* @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free
|
|
||||||
* @param periodStart inclusive start of the monthly billing window — the Stripe subscription's
|
|
||||||
* current period when subscribed, calendar month otherwise
|
|
||||||
* @param periodEnd exclusive end of the monthly billing window
|
|
||||||
* @param freeGrantUnits the team's one-time free grant size (policy {@code free_tier_units}); the
|
|
||||||
* denominator for "used X of N free". Never resets.
|
|
||||||
* @param freeRemainingUnits one-time free documents still available ({@code
|
|
||||||
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
|
|
||||||
* @param perDocMinor paid per-document rate in minor units of {@link #currency()}; null when the
|
|
||||||
* rate can't be resolved (free team, price row unsynced) — display "unknown", never substitute
|
|
||||||
* @param currency lower-case ISO 4217 of the subscription's Price; null when unknown
|
|
||||||
* @param capMoneyMinor leader-set monthly spending cap in minor units ({@code
|
|
||||||
* wallet_policy.cap_source_money}); null = no cap configured
|
|
||||||
* @param monthlyCapDocUnits the subscribed monthly paid-document ceiling — {@code floor(capMoney /
|
|
||||||
* perDocRate)}; null = uncapped, or the team is not subscribed
|
|
||||||
*/
|
|
||||||
public record TeamBillingContext(
|
|
||||||
boolean subscribed,
|
|
||||||
String subscriptionId,
|
|
||||||
LocalDateTime periodStart,
|
|
||||||
LocalDateTime periodEnd,
|
|
||||||
long freeGrantUnits,
|
|
||||||
long freeRemainingUnits,
|
|
||||||
BigDecimal perDocMinor,
|
|
||||||
String currency,
|
|
||||||
Long capMoneyMinor,
|
|
||||||
Long monthlyCapDocUnits) {}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
package stirling.software.saas.payg.billing;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import com.github.benmanes.caffeine.cache.Cache;
|
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
|
||||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
|
||||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
|
||||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
|
||||||
import stirling.software.saas.payg.repository.WalletPolicyRepository;
|
|
||||||
import stirling.software.saas.payg.stripe.StripeSubscriptionDao;
|
|
||||||
import stirling.software.saas.payg.stripe.StripeSubscriptionDao.PriceRate;
|
|
||||||
import stirling.software.saas.payg.stripe.StripeSubscriptionDao.SubscriptionBilling;
|
|
||||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single composition point for "what does billing look like for this team right now." Both the
|
|
||||||
* entitlement hot path and the wallet endpoint read from here, so what the customer sees is what
|
|
||||||
* the guard enforces.
|
|
||||||
*
|
|
||||||
* <p>Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant):
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li><b>Free grant</b> — one-time, per team. Size from {@code pricing_policy.free_tier_units};
|
|
||||||
* live balance from the {@code payg_team_extensions.free_units_remaining} counter (maintained
|
|
||||||
* by the charge pipeline). Never resets, survives subscribing. Gates un-subscribed teams and
|
|
||||||
* drives the free-vs-paid split.
|
|
||||||
* <li><b>Monthly window + cap</b> — the Stripe subscription period (calendar month otherwise) and
|
|
||||||
* the optional money cap. Govern the subscribed invoice + spending cap only. The per-document
|
|
||||||
* rate is the synced {@code stripe.prices.unit_amount} (PAYG prices are plain per-unit).
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Cached per team for {@value #CACHE_TTL_SECONDS}s. {@code EntitlementService.invalidate}
|
|
||||||
* cascades into {@link #invalidate(Long)} so both caches drop together on cap edits / webhooks.
|
|
||||||
* Note the cached context's {@code freeRemainingUnits} is a 30s-stale read of the counter — the
|
|
||||||
* authoritative decrement happens in {@code JobChargeService} against the row directly; this cache
|
|
||||||
* is for display + the entitlement gate, where 30s staleness is the accepted cap-evaluation floor.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@Profile("saas")
|
|
||||||
public class TeamBillingService {
|
|
||||||
|
|
||||||
static final int CACHE_TTL_SECONDS = 30;
|
|
||||||
private static final int CACHE_MAX_SIZE = 10_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-app display/estimate currency. The app prices in dollars; Stripe handles real currency
|
|
||||||
* selection at checkout. Used to pick the right Price for un-subscribed teams.
|
|
||||||
*/
|
|
||||||
private static final String DISPLAY_CURRENCY = "usd";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stripe Price {@code lookup_key} for the PAYG per-document price. The stable handle we resolve
|
|
||||||
* an un-subscribed team's rate from (the default policy carries no price ids in the seed).
|
|
||||||
*/
|
|
||||||
private static final String PAYG_LOOKUP_KEY = "plan:processor";
|
|
||||||
|
|
||||||
private final PaygTeamExtensionsRepository extensionsRepository;
|
|
||||||
private final WalletPolicyRepository walletPolicyRepository;
|
|
||||||
private final PricingPolicyService pricingPolicyService;
|
|
||||||
private final StripeSubscriptionDao subscriptionDao;
|
|
||||||
|
|
||||||
private final Cache<Long, TeamBillingContext> cache;
|
|
||||||
|
|
||||||
public TeamBillingService(
|
|
||||||
PaygTeamExtensionsRepository extensionsRepository,
|
|
||||||
WalletPolicyRepository walletPolicyRepository,
|
|
||||||
PricingPolicyService pricingPolicyService,
|
|
||||||
StripeSubscriptionDao subscriptionDao) {
|
|
||||||
this.extensionsRepository =
|
|
||||||
Objects.requireNonNull(extensionsRepository, "extensionsRepository");
|
|
||||||
this.walletPolicyRepository =
|
|
||||||
Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository");
|
|
||||||
this.pricingPolicyService =
|
|
||||||
Objects.requireNonNull(pricingPolicyService, "pricingPolicyService");
|
|
||||||
this.subscriptionDao = Objects.requireNonNull(subscriptionDao, "subscriptionDao");
|
|
||||||
this.cache =
|
|
||||||
Caffeine.newBuilder()
|
|
||||||
.maximumSize(CACHE_MAX_SIZE)
|
|
||||||
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public TeamBillingContext forTeam(Long teamId) {
|
|
||||||
Objects.requireNonNull(teamId, "teamId");
|
|
||||||
return cache.get(teamId, this::compute);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop {@code teamId}'s entry after cap edits / subscription webhooks / grant consumption. */
|
|
||||||
public void invalidate(Long teamId) {
|
|
||||||
if (teamId != null) {
|
|
||||||
cache.invalidate(teamId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private TeamBillingContext compute(Long teamId) {
|
|
||||||
Optional<PaygTeamExtensions> extOpt = extensionsRepository.findById(teamId);
|
|
||||||
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
|
|
||||||
|
|
||||||
String subscriptionId = extOpt.map(PaygTeamExtensions::getPaygSubscriptionId).orElse(null);
|
|
||||||
// payg_subscription_id is the single subscription switch. payg_link_subscription sets it
|
|
||||||
// (alongside stripe_customer_id, in the same write) on customer.subscription.created;
|
|
||||||
// payg_unlink_subscription nulls it on customer.subscription.deleted while deliberately
|
|
||||||
// keeping stripe_customer_id so a future re-subscribe can reuse the Stripe customer. So a
|
|
||||||
// cancelled team has a null subscription id and must read as free again.
|
|
||||||
//
|
|
||||||
// We deliberately do NOT fall back to stripe_customer_id presence. payg_link_subscription
|
|
||||||
// is the only writer of that column and it writes it together with the subscription id, so
|
|
||||||
// it can never be set "before the webhook lands" — there is no gap for it to bridge. A
|
|
||||||
// customer-id fallback would instead keep every team that ever subscribed pinned to
|
|
||||||
// subscribed forever (the customer outlives the subscription), which is the cancelled-team
|
|
||||||
// bug this guards against.
|
|
||||||
boolean subscribed = subscriptionId != null;
|
|
||||||
|
|
||||||
long freeGrant = resolveGrant(teamId);
|
|
||||||
long freeRemaining =
|
|
||||||
extOpt.map(PaygTeamExtensions::getFreeUnitsRemaining)
|
|
||||||
.map(Long::longValue)
|
|
||||||
.orElse(0L);
|
|
||||||
|
|
||||||
Optional<SubscriptionBilling> billing =
|
|
||||||
subscriptionId != null
|
|
||||||
? subscriptionDao.findBilling(subscriptionId)
|
|
||||||
: Optional.empty();
|
|
||||||
|
|
||||||
LocalDateTime[] window =
|
|
||||||
billing.map(b -> new LocalDateTime[] {b.periodStart(), b.periodEnd()})
|
|
||||||
.orElseGet(TeamBillingService::calendarMonthWindow);
|
|
||||||
|
|
||||||
BigDecimal perDocMinor = billing.map(SubscriptionBilling::perDocMinor).orElse(null);
|
|
||||||
String currency = billing.map(SubscriptionBilling::currency).orElse(null);
|
|
||||||
|
|
||||||
// Un-subscribed teams have no Stripe subscription to read a rate from, but the cap
|
|
||||||
// estimate (the upgrade flow's "≈ N paid PDFs/month") still needs one. Resolve it from
|
|
||||||
// the default policy's USD Price — Stripe hasn't assigned the team a currency yet, and
|
|
||||||
// the whole app prices in dollars. Display-only: resolveMonthlyCap stays gated on
|
|
||||||
// `subscribed`, so this never starts enforcing a cap on a free team.
|
|
||||||
if (!subscribed && perDocMinor == null) {
|
|
||||||
Optional<PriceRate> rate =
|
|
||||||
subscriptionDao.findRateByLookupKey(PAYG_LOOKUP_KEY, DISPLAY_CURRENCY);
|
|
||||||
if (rate.isPresent()) {
|
|
||||||
perDocMinor = rate.get().perDocMinor();
|
|
||||||
currency = rate.get().currency();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Long capMoneyMinor = walletPolicyOpt.map(WalletPolicy::getCapSourceMoney).orElse(null);
|
|
||||||
Long legacyCapUnits = walletPolicyOpt.map(WalletPolicy::getCapUnits).orElse(null);
|
|
||||||
|
|
||||||
Long monthlyCapDocUnits =
|
|
||||||
resolveMonthlyCap(subscribed, capMoneyMinor, legacyCapUnits, perDocMinor);
|
|
||||||
|
|
||||||
return new TeamBillingContext(
|
|
||||||
subscribed,
|
|
||||||
subscriptionId,
|
|
||||||
window[0],
|
|
||||||
window[1],
|
|
||||||
freeGrant,
|
|
||||||
freeRemaining,
|
|
||||||
perDocMinor,
|
|
||||||
currency,
|
|
||||||
capMoneyMinor,
|
|
||||||
monthlyCapDocUnits);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The policy grant size — the "N" denominator for display; the counter is the live balance. */
|
|
||||||
private long resolveGrant(Long teamId) {
|
|
||||||
try {
|
|
||||||
PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId);
|
|
||||||
Long grant = policy.getFreeTierUnits();
|
|
||||||
return grant == null ? 0L : grant;
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn("No effective pricing policy for team {}: {}", teamId, e.getMessage());
|
|
||||||
return 0L;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subscribed monthly paid-document ceiling; {@code null} = uncapped or not subscribed. The
|
|
||||||
* one-time free grant is NOT added here — it's a separate lifetime pool consumed at charge
|
|
||||||
* time. The cap purely limits how many paid documents the team will fund per billing period.
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>not subscribed → null (the free grant, not a money cap, is what bounds them);
|
|
||||||
* <li>subscribed, no money cap → uncapped (null), unless an admin set raw {@code cap_units};
|
|
||||||
* <li>subscribed, money cap + known rate → {@code floor(capMoney / perDocRate)};
|
|
||||||
* <li>subscribed, money cap but rate unknown → stored {@code cap_units} fallback (WARN).
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
private Long resolveMonthlyCap(
|
|
||||||
boolean subscribed, Long capMoneyMinor, Long legacyCapUnits, BigDecimal perDocMinor) {
|
|
||||||
if (!subscribed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (capMoneyMinor == null) {
|
|
||||||
return legacyCapUnits; // admin-set unit cap (source money null) still applies
|
|
||||||
}
|
|
||||||
if (perDocMinor != null && perDocMinor.signum() > 0) {
|
|
||||||
return BigDecimal.valueOf(capMoneyMinor)
|
|
||||||
.divide(perDocMinor, 0, RoundingMode.FLOOR)
|
|
||||||
.longValue();
|
|
||||||
}
|
|
||||||
log.warn(
|
|
||||||
"Per-document rate unavailable; enforcing stored cap_units fallback ({}).",
|
|
||||||
legacyCapUnits);
|
|
||||||
return legacyCapUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimated charges for the current period in minor units of {@link
|
|
||||||
* TeamBillingContext#currency()}: the paid (metered) documents this period at the per-document
|
|
||||||
* rate. Informational — the Stripe invoice is authoritative. Empty when the rate is unknown.
|
|
||||||
*
|
|
||||||
* @param paidUnitsThisPeriod metered documents this period ({@code payg_units −
|
|
||||||
* free_units_consumed} summed over the period's charged jobs)
|
|
||||||
*/
|
|
||||||
public Optional<Long> estimateBillMinor(TeamBillingContext ctx, long paidUnitsThisPeriod) {
|
|
||||||
if (ctx.perDocMinor() == null) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
long paid = Math.max(0, paidUnitsThisPeriod);
|
|
||||||
BigDecimal bill =
|
|
||||||
ctx.perDocMinor()
|
|
||||||
.multiply(BigDecimal.valueOf(paid))
|
|
||||||
.setScale(0, RoundingMode.HALF_UP);
|
|
||||||
return Optional.of(bill.longValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Documents a hypothetical monthly money cap would buy: {@code floor(capMinor / rate)}. Used by
|
|
||||||
* the cap editor's live preview and the {@code PATCH /cap} derived write. The free grant is NOT
|
|
||||||
* added — it's a separate one-time pool. Empty when the rate is unknown.
|
|
||||||
*/
|
|
||||||
public Optional<Long> docCapForMoney(TeamBillingContext ctx, long capMinor) {
|
|
||||||
if (ctx.perDocMinor() == null || ctx.perDocMinor().signum() <= 0) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return Optional.of(
|
|
||||||
BigDecimal.valueOf(capMinor)
|
|
||||||
.divide(ctx.perDocMinor(), 0, RoundingMode.FLOOR)
|
|
||||||
.longValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inclusive-start / exclusive-end window for the calendar month — the monthly billing window
|
|
||||||
* used when there's no Stripe subscription period to anchor on.
|
|
||||||
*/
|
|
||||||
static LocalDateTime[] calendarMonthWindow() {
|
|
||||||
return calendarMonthWindow(LocalDateTime.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test seam — accepts a clock value so tests don't race the calendar boundary. */
|
|
||||||
static LocalDateTime[] calendarMonthWindow(LocalDateTime now) {
|
|
||||||
java.time.YearMonth ym = java.time.YearMonth.from(now);
|
|
||||||
return new LocalDateTime[] {
|
|
||||||
ym.atDay(1).atStartOfDay(), ym.plusMonths(1).atDay(1).atStartOfDay()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package stirling.software.saas.payg.cap;
|
|
||||||
|
|
||||||
import org.springframework.web.servlet.HandlerMapping;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Central definition of the AI document-tool route namespace ({@code /api/v1/ai/tools/**}).
|
|
||||||
*
|
|
||||||
* <p>These tools (e.g. {@code PdfCommentAgentController}, {@code MathAuditorAgentController}) live
|
|
||||||
* in the {@code proprietary} module, which does not depend on {@code saas} and therefore cannot
|
|
||||||
* carry the saas-only {@link RequiresFeature} annotation. Rather than weaken the layering, the PAYG
|
|
||||||
* hot-path components recognise the path prefix instead:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code PaygChargeInterceptor} brings these routes into scope and bills them as {@code
|
|
||||||
* BillingCategory.AI} on a direct call (an orchestrator-dispatched call still resolves to
|
|
||||||
* AUTOMATION first, via the {@code X-Stirling-Automation} header);
|
|
||||||
* <li>{@code EntitlementGuard} gates them on {@link
|
|
||||||
* stirling.software.saas.payg.model.FeatureGate#AI_SUPPORT}.
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Kept as a single source of truth so the interceptor and the guard can never drift on what
|
|
||||||
* counts as an AI tool.
|
|
||||||
*/
|
|
||||||
public final class AiToolRoutes {
|
|
||||||
|
|
||||||
/** Trailing slash so it matches the tool sub-paths, not a bare {@code /api/v1/ai/tools}. */
|
|
||||||
public static final String PREFIX = "/api/v1/ai/tools/";
|
|
||||||
|
|
||||||
private AiToolRoutes() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the request resolved to an AI document-tool 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();
|
|
||||||
return path != null && path.startsWith(PREFIX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package stirling.software.saas.payg.cap;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import stirling.software.saas.payg.model.EntitlementState;
|
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
|
||||||
import stirling.software.saas.payg.model.FeatureSet;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure-compute cap evaluation. Given a team's (or member's) spend, cap, and warn / degrade
|
|
||||||
* thresholds, returns the {@link EntitlementState}, the {@link FeatureSet} that should be in
|
|
||||||
* effect, and the corresponding enabled {@link FeatureGate}s. No DB access, no caches — the caller
|
|
||||||
* (entitlement service) supplies the inputs.
|
|
||||||
*
|
|
||||||
* <p>State transitions:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally.
|
|
||||||
* <li>{@code spend / cap < warnPct} → {@code FULL}.
|
|
||||||
* <li><b>MINIMAL semantics:</b> under DEGRADED+MINIMAL manual server-side tools (gated by {@link
|
|
||||||
* FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link
|
|
||||||
* FeatureGate#AUTOMATION} and {@link FeatureGate#AI_SUPPORT} are blocked.
|
|
||||||
* <li>{@code warnPct ≤ spend / cap < degradePct} → {@code WARNED}; feature set still {@link
|
|
||||||
* FeatureSet#FULL} — the warn band is a notification trigger, not a degradation.
|
|
||||||
* <li>{@code spend / cap ≥ degradePct} → {@code DEGRADED}; feature set drops to the policy's
|
|
||||||
* configured {@code degradedFeatureSet} (default {@link FeatureSet#MINIMAL}).
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>The percentage compare is integer math. We multiply spend by 100 before dividing — this keeps
|
|
||||||
* the precision and avoids floating-point on the hot path. Spend × 100 can overflow long at 9.2e16
|
|
||||||
* units, which is not a realistic value (would represent quintillions of charged documents); we
|
|
||||||
* don't guard against it.
|
|
||||||
*/
|
|
||||||
public final class CapEvaluator {
|
|
||||||
|
|
||||||
private CapEvaluator() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snapshot of one cap evaluation. The caller persists this into the appropriate {@code
|
|
||||||
* wallet_entitlement_snapshot} row (team-wide or per-member).
|
|
||||||
*/
|
|
||||||
public record Evaluation(
|
|
||||||
EntitlementState state, FeatureSet featureSet, List<FeatureGate> enabledGates) {}
|
|
||||||
|
|
||||||
public static Evaluation evaluate(
|
|
||||||
long spendUnits,
|
|
||||||
Long capUnits,
|
|
||||||
int warnAtPct,
|
|
||||||
int degradeAtPct,
|
|
||||||
FeatureSet degradedFeatureSet) {
|
|
||||||
|
|
||||||
if (capUnits == null || capUnits <= 0) {
|
|
||||||
return full();
|
|
||||||
}
|
|
||||||
if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) {
|
|
||||||
// Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise
|
|
||||||
// degradation. The admin endpoints that set the policy should validate; this
|
|
||||||
// protects the hot path from a bad row sneaking through.
|
|
||||||
return full();
|
|
||||||
}
|
|
||||||
|
|
||||||
// pct = floor((spend * 100) / cap). Integer arithmetic on the hot path.
|
|
||||||
long pct = (spendUnits * 100L) / capUnits;
|
|
||||||
|
|
||||||
if (pct >= degradeAtPct) {
|
|
||||||
FeatureSet effective =
|
|
||||||
degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL;
|
|
||||||
return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective));
|
|
||||||
}
|
|
||||||
if (pct >= warnAtPct) {
|
|
||||||
// Warn band: still FULL feature set, but state flag is set so the FE can show a
|
|
||||||
// banner / send a notification. The wallet service emits a
|
|
||||||
// WalletEntitlementChanged event when state transitions; subscribers (email
|
|
||||||
// reminder, SSE to FE) act on that.
|
|
||||||
return new Evaluation(
|
|
||||||
EntitlementState.WARNED, FeatureSet.FULL, gatesFor(FeatureSet.FULL));
|
|
||||||
}
|
|
||||||
return full();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default enabled gates for a given feature set. */
|
|
||||||
public static List<FeatureGate> gatesFor(FeatureSet set) {
|
|
||||||
if (set == null) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
return switch (set) {
|
|
||||||
case FULL ->
|
|
||||||
List.of(
|
|
||||||
FeatureGate.OFFSITE_PROCESSING,
|
|
||||||
FeatureGate.AUTOMATION,
|
|
||||||
FeatureGate.AI_SUPPORT,
|
|
||||||
FeatureGate.CLIENT_SIDE);
|
|
||||||
case MINIMAL -> List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
|
|
||||||
case CLIENT_ONLY -> List.of(FeatureGate.CLIENT_SIDE);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Evaluation full() {
|
|
||||||
return new Evaluation(EntitlementState.FULL, FeatureSet.FULL, gatesFor(FeatureSet.FULL));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package stirling.software.saas.payg.cap;
|
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Declares which {@link FeatureGate}(s) a controller method requires. Read at request time by
|
|
||||||
* {@code EntitlementGuard}; if any required gate is not in the team's currently-enabled gates the
|
|
||||||
* request is rejected with HTTP 402.
|
|
||||||
*
|
|
||||||
* <p>The annotation is <em>not required</em> on every endpoint. The guard's default rule is:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code @RequiresFeature} present → use exactly those gates.
|
|
||||||
* <li>No annotation, but the method has {@code @AutoJobPostMapping} → assume {@link
|
|
||||||
* FeatureGate#OFFSITE_PROCESSING}.
|
|
||||||
* <li>Neither → skip (admin endpoints, info, config — these don't accrue charges and shouldn't
|
|
||||||
* degrade).
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>So the only endpoints that need this annotation explicitly are those whose gate is
|
|
||||||
* <em>different</em> from the default {@code OFFSITE_PROCESSING} — chiefly {@code
|
|
||||||
* PipelineController} ({@link FeatureGate#AUTOMATION}) and the AI proxy layer ({@link
|
|
||||||
* FeatureGate#AI_SUPPORT}). Per-tool proliferation of the annotation is intentional non-goal.
|
|
||||||
*
|
|
||||||
* <p>Multiple gates declared = ALL must be enabled (AND, not OR). Realistic usage is single-gate;
|
|
||||||
* the array form is here for future combinations (e.g. an AI workflow inside a pipeline that needs
|
|
||||||
* both {@code AUTOMATION} and {@code AI_SUPPORT}).
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* @RequiresFeature(FeatureGate.AUTOMATION)
|
|
||||||
* @AutoJobPostMapping("/pipeline")
|
|
||||||
* public ResponseEntity<...> runPipeline(@ModelAttribute PipelineRequest req) { ... }
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
public @interface RequiresFeature {
|
|
||||||
|
|
||||||
/** One or more gates that must all be enabled for the request to proceed. */
|
|
||||||
FeatureGate[] value();
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package stirling.software.saas.payg.charge;
|
package stirling.software.saas.payg.charge;
|
||||||
|
|
||||||
import stirling.software.saas.payg.model.BillingCategory;
|
|
||||||
import stirling.software.saas.payg.model.JobSource;
|
import stirling.software.saas.payg.model.JobSource;
|
||||||
import stirling.software.saas.payg.model.ProcessType;
|
import stirling.software.saas.payg.model.ProcessType;
|
||||||
|
|
||||||
@@ -9,18 +8,9 @@ import stirling.software.saas.payg.model.ProcessType;
|
|||||||
* kind of process this is. Does NOT carry policy fields — the charge service resolves the effective
|
* kind of process this is. Does NOT carry policy fields — the charge service resolves the effective
|
||||||
* policy from {@code PricingPolicyService} so a stale snapshot from the caller can't desync from
|
* policy from {@code PricingPolicyService} so a stale snapshot from the caller can't desync from
|
||||||
* the live policy.
|
* the live policy.
|
||||||
*
|
|
||||||
* <p>{@code billingCategory} is the analytics axis for ledger + shadow rows and is determined by
|
|
||||||
* the interceptor before this context is built. Manual UI tools never reach {@code openProcess}
|
|
||||||
* (they short-circuit on {@link BillingCategory#BYPASSED}); any context constructed here therefore
|
|
||||||
* carries one of {@code API}, {@code AI}, or {@code AUTOMATION}.
|
|
||||||
*/
|
*/
|
||||||
public record ChargeContext(
|
public record ChargeContext(
|
||||||
Long ownerUserId,
|
Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType) {
|
||||||
Long ownerTeamId,
|
|
||||||
JobSource source,
|
|
||||||
ProcessType processType,
|
|
||||||
BillingCategory billingCategory) {
|
|
||||||
|
|
||||||
public ChargeContext {
|
public ChargeContext {
|
||||||
if (ownerUserId == null) {
|
if (ownerUserId == null) {
|
||||||
@@ -32,8 +22,5 @@ public record ChargeContext(
|
|||||||
if (processType == null) {
|
if (processType == null) {
|
||||||
throw new IllegalArgumentException("processType is required");
|
throw new IllegalArgumentException("processType is required");
|
||||||
}
|
}
|
||||||
if (billingCategory == null) {
|
|
||||||
throw new IllegalArgumentException("billingCategory is required");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import java.util.UUID;
|
|||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -23,23 +21,14 @@ import stirling.software.saas.payg.job.JobContext;
|
|||||||
import stirling.software.saas.payg.job.JobService;
|
import stirling.software.saas.payg.job.JobService;
|
||||||
import stirling.software.saas.payg.job.JoinOrOpenResult;
|
import stirling.software.saas.payg.job.JoinOrOpenResult;
|
||||||
import stirling.software.saas.payg.job.ProcessingJob;
|
import stirling.software.saas.payg.job.ProcessingJob;
|
||||||
import stirling.software.saas.payg.meter.PaygMeterReportingService;
|
|
||||||
import stirling.software.saas.payg.model.BillingCategory;
|
|
||||||
import stirling.software.saas.payg.model.JobSource;
|
import stirling.software.saas.payg.model.JobSource;
|
||||||
import stirling.software.saas.payg.model.JobStatus;
|
import stirling.software.saas.payg.model.JobStatus;
|
||||||
import stirling.software.saas.payg.model.LedgerBucket;
|
|
||||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
|
||||||
import stirling.software.saas.payg.model.ReferenceType;
|
|
||||||
import stirling.software.saas.payg.model.ShadowChargeStatus;
|
import stirling.software.saas.payg.model.ShadowChargeStatus;
|
||||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
|
||||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||||
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
|
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
|
||||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
|
||||||
import stirling.software.saas.payg.repository.ProcessingJobRepository;
|
import stirling.software.saas.payg.repository.ProcessingJobRepository;
|
||||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
|
||||||
import stirling.software.saas.payg.shadow.PaygShadowCharge;
|
import stirling.software.saas.payg.shadow.PaygShadowCharge;
|
||||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestrates a tool call's open-process decision: look up the team's effective policy, resolve
|
* Orchestrates a tool call's open-process decision: look up the team's effective policy, resolve
|
||||||
@@ -66,29 +55,18 @@ public class JobChargeService {
|
|||||||
private final DocumentClassifier classifier;
|
private final DocumentClassifier classifier;
|
||||||
private final PaygShadowChargeRepository shadowRepository;
|
private final PaygShadowChargeRepository shadowRepository;
|
||||||
private final ProcessingJobRepository jobRepository;
|
private final ProcessingJobRepository jobRepository;
|
||||||
private final PaygTeamExtensionsRepository teamExtensionsRepository;
|
|
||||||
private final PaygMeterReportingService meterReportingService;
|
|
||||||
private final WalletLedgerRepository ledgerRepository;
|
|
||||||
|
|
||||||
public JobChargeService(
|
public JobChargeService(
|
||||||
JobService jobService,
|
JobService jobService,
|
||||||
PricingPolicyService policyService,
|
PricingPolicyService policyService,
|
||||||
DocumentClassifier classifier,
|
DocumentClassifier classifier,
|
||||||
PaygShadowChargeRepository shadowRepository,
|
PaygShadowChargeRepository shadowRepository,
|
||||||
ProcessingJobRepository jobRepository,
|
ProcessingJobRepository jobRepository) {
|
||||||
PaygTeamExtensionsRepository teamExtensionsRepository,
|
|
||||||
PaygMeterReportingService meterReportingService,
|
|
||||||
WalletLedgerRepository ledgerRepository) {
|
|
||||||
this.jobService = Objects.requireNonNull(jobService, "jobService");
|
this.jobService = Objects.requireNonNull(jobService, "jobService");
|
||||||
this.policyService = Objects.requireNonNull(policyService, "policyService");
|
this.policyService = Objects.requireNonNull(policyService, "policyService");
|
||||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||||
this.shadowRepository = Objects.requireNonNull(shadowRepository, "shadowRepository");
|
this.shadowRepository = Objects.requireNonNull(shadowRepository, "shadowRepository");
|
||||||
this.jobRepository = Objects.requireNonNull(jobRepository, "jobRepository");
|
this.jobRepository = Objects.requireNonNull(jobRepository, "jobRepository");
|
||||||
this.teamExtensionsRepository =
|
|
||||||
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
|
|
||||||
this.meterReportingService =
|
|
||||||
Objects.requireNonNull(meterReportingService, "meterReportingService");
|
|
||||||
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,115 +104,11 @@ public class JobChargeService {
|
|||||||
int units = computeUnits(inputs, policy);
|
int units = computeUnits(inputs, policy);
|
||||||
result.job().setDocUnits(units);
|
result.job().setDocUnits(units);
|
||||||
|
|
||||||
int freeUsed = consumeFreeGrant(ctx, units);
|
recordShadowRow(ctx, result.job().getId(), policy.getId(), units);
|
||||||
recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed);
|
|
||||||
recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units);
|
|
||||||
|
|
||||||
return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED);
|
return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Charge a fixed number of units for a billable action that isn't file/lineage-driven — e.g. an
|
|
||||||
* AI Create session, billed once per document at session creation. Opens a standalone
|
|
||||||
* bookkeeping job (no lineage inputs, so follow-up calls never lineage-join it), draws the
|
|
||||||
* free-grant split, and writes the shadow + ledger rows exactly as {@link #openProcess} does,
|
|
||||||
* then closes the job so the paid portion meters to Stripe via the same {@code afterCommit}
|
|
||||||
* path and idempotency key ({@code process:<jobId>:close}).
|
|
||||||
*
|
|
||||||
* <p>Each call is independent: there is no join/dedup, so two sessions charge twice (correct —
|
|
||||||
* each is a distinct document). The caller passes the unit count; the policy {@code
|
|
||||||
* minChargeUnits} floor still applies. Must not be called for {@link BillingCategory#BYPASSED}.
|
|
||||||
*
|
|
||||||
* @return the bookkeeping job id (mostly useful for tests / tracing)
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public UUID chargeStandalone(ChargeContext ctx, int units) {
|
|
||||||
Objects.requireNonNull(ctx, "ctx");
|
|
||||||
if (ctx.billingCategory() == BillingCategory.BYPASSED) {
|
|
||||||
throw new IllegalArgumentException("chargeStandalone must not be called for BYPASSED");
|
|
||||||
}
|
|
||||||
|
|
||||||
PricingPolicy policy = policyService.getEffectivePolicy(ctx.ownerTeamId());
|
|
||||||
int chargeUnits = Math.max(units, policy.getMinChargeUnits());
|
|
||||||
int stepLimit = resolveStepLimit(policy, ctx.source());
|
|
||||||
|
|
||||||
JobContext jobCtx =
|
|
||||||
new JobContext(
|
|
||||||
ctx.ownerUserId(),
|
|
||||||
ctx.ownerTeamId(),
|
|
||||||
ctx.source(),
|
|
||||||
ctx.processType(),
|
|
||||||
policy.getId(),
|
|
||||||
stepLimit);
|
|
||||||
ProcessingJob job = jobService.open(jobCtx, chargeUnits);
|
|
||||||
|
|
||||||
int freeUsed = consumeFreeGrant(ctx, chargeUnits);
|
|
||||||
recordShadowRow(ctx, job.getId(), policy.getId(), chargeUnits, freeUsed);
|
|
||||||
recordLedgerDebit(ctx, job.getId(), policy.getId(), chargeUnits);
|
|
||||||
|
|
||||||
// Close immediately — nothing will lineage-join a standalone job — so the paid portion
|
|
||||||
// meters via the same afterCommit hook + idempotency key as a normal process completion.
|
|
||||||
close(job.getId());
|
|
||||||
return job.getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw this job's free portion from the team's one-time lifetime grant, atomically, and return
|
|
||||||
* the units taken (0..{@code units}); the remainder is the paid portion that will be metered to
|
|
||||||
* Stripe. Runs inside {@code openProcess}'s transaction with a pessimistic row lock so
|
|
||||||
* concurrent same-team charges split the grant exactly — no two jobs can both claim the last
|
|
||||||
* free unit. The grant is a soft floor: it never goes below 0, and the single job that crosses
|
|
||||||
* the boundary takes whatever's left (its remaining units bill). Skipped for non-billable /
|
|
||||||
* team-less calls (BYPASSED never reaches openProcess; guarded defensively).
|
|
||||||
*/
|
|
||||||
private int consumeFreeGrant(ChargeContext ctx, int units) {
|
|
||||||
BillingCategory category = ctx.billingCategory();
|
|
||||||
if (category == null || category == BillingCategory.BYPASSED || ctx.ownerTeamId() == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
Optional<PaygTeamExtensions> extOpt =
|
|
||||||
teamExtensionsRepository.findByIdForUpdate(ctx.ownerTeamId());
|
|
||||||
if (extOpt.isEmpty()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
PaygTeamExtensions ext = extOpt.get();
|
|
||||||
long remaining = ext.getFreeUnitsRemaining() == null ? 0L : ext.getFreeUnitsRemaining();
|
|
||||||
int freeUsed = (int) Math.min(units, Math.max(0L, remaining));
|
|
||||||
if (freeUsed > 0) {
|
|
||||||
ext.setFreeUnitsRemaining(remaining - freeUsed);
|
|
||||||
teamExtensionsRepository.save(ext);
|
|
||||||
}
|
|
||||||
return freeUsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The live spend record. Everything the customer-facing side reads — the wallet endpoint's
|
|
||||||
* {@code spendUnitsThisPeriod}, the per-category breakdown ({@code wallet_category_summary}
|
|
||||||
* view), and the cap evaluator's period sum — derives from {@code wallet_ledger} DEBITs. Shadow
|
|
||||||
* rows are the comparison audit trail; this row is what actually counts.
|
|
||||||
*
|
|
||||||
* <p>Sign convention: debits are stored NEGATIVE (the entitlement snapshot negates the sum).
|
|
||||||
* Skipped for {@code BYPASSED} / uncategorised calls — manual UI work is never billed.
|
|
||||||
*/
|
|
||||||
private void recordLedgerDebit(
|
|
||||||
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
|
|
||||||
BillingCategory category = ctx.billingCategory();
|
|
||||||
if (category == null || category == BillingCategory.BYPASSED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WalletLedgerEntry entry = new WalletLedgerEntry();
|
|
||||||
entry.setTeamId(ctx.ownerTeamId());
|
|
||||||
entry.setActorUserId(ctx.ownerUserId());
|
|
||||||
entry.setEntryType(LedgerEntryType.DEBIT);
|
|
||||||
entry.setBucket(LedgerBucket.CYCLE);
|
|
||||||
entry.setAmountUnits(-units);
|
|
||||||
entry.setReferenceType(ReferenceType.JOB);
|
|
||||||
entry.setReferenceId(jobId.toString());
|
|
||||||
entry.setPolicyId(policyId);
|
|
||||||
entry.setBillingCategory(category);
|
|
||||||
ledgerRepository.save(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int resolveStepLimit(PricingPolicy policy, JobSource source) {
|
private int resolveStepLimit(PricingPolicy policy, JobSource source) {
|
||||||
Integer fromPolicy =
|
Integer fromPolicy =
|
||||||
policy.getStepLimits() == null ? null : policy.getStepLimits().get(source);
|
policy.getStepLimits() == null ? null : policy.getStepLimits().get(source);
|
||||||
@@ -268,28 +142,17 @@ public class JobChargeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void recordShadowRow(
|
private void recordShadowRow(
|
||||||
ChargeContext ctx,
|
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
|
||||||
java.util.UUID jobId,
|
|
||||||
Long policyId,
|
|
||||||
int units,
|
|
||||||
int freeUnitsConsumed) {
|
|
||||||
PaygShadowCharge row = new PaygShadowCharge();
|
PaygShadowCharge row = new PaygShadowCharge();
|
||||||
row.setTeamId(ctx.ownerTeamId());
|
row.setTeamId(ctx.ownerTeamId());
|
||||||
row.setJobId(jobId);
|
row.setJobId(jobId);
|
||||||
row.setPolicyId(policyId);
|
row.setPolicyId(policyId);
|
||||||
row.setPaygUnits(units);
|
row.setPaygUnits(units);
|
||||||
// Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed,
|
|
||||||
// and a refund restores freeUnitsConsumed to the team's grant.
|
|
||||||
row.setFreeUnitsConsumed(freeUnitsConsumed);
|
|
||||||
// No legacy comparison yet — wired when the shadow path is connected to the legacy
|
// No legacy comparison yet — wired when the shadow path is connected to the legacy
|
||||||
// CreditService in the follow-up PR. Until then, diff stays at 0.
|
// CreditService in the follow-up PR. Until then, diff stays at 0.
|
||||||
row.setLegacyCreditsCharged(0);
|
row.setLegacyCreditsCharged(0);
|
||||||
row.setDiffPct(0);
|
row.setDiffPct(0);
|
||||||
row.setStatus(ShadowChargeStatus.CHARGED);
|
row.setStatus(ShadowChargeStatus.CHARGED);
|
||||||
// PAYG analytics axis + caller surface — copied from ctx so the row stays self-describing
|
|
||||||
// after processing_job is pruned. Never affects what Stripe meters (single flat meter).
|
|
||||||
row.setBillingCategory(ctx.billingCategory());
|
|
||||||
row.setJobSource(ctx.source());
|
|
||||||
shadowRepository.save(row);
|
shadowRepository.save(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,31 +182,6 @@ public class JobChargeService {
|
|||||||
row.setRefundedAt(now);
|
row.setRefundedAt(now);
|
||||||
row.setRefundReason(trimReason(refundReason));
|
row.setRefundReason(trimReason(refundReason));
|
||||||
shadowRepository.save(row);
|
shadowRepository.save(row);
|
||||||
// Compensate the live ledger DEBIT written at openProcess so the period spend
|
|
||||||
// nets to zero for the failed work. Positive amount mirrors the negative debit;
|
|
||||||
// same JOB reference ties the pair together. The idempotency guard above (only
|
|
||||||
// on the CHARGED→REFUNDED transition) prevents double-credits on re-invocation.
|
|
||||||
BillingCategory category = row.getBillingCategory();
|
|
||||||
if (category != null && category != BillingCategory.BYPASSED) {
|
|
||||||
WalletLedgerEntry refund = new WalletLedgerEntry();
|
|
||||||
refund.setTeamId(row.getTeamId());
|
|
||||||
refund.setEntryType(LedgerEntryType.REFUND);
|
|
||||||
refund.setBucket(LedgerBucket.CYCLE);
|
|
||||||
refund.setAmountUnits(row.getPaygUnits());
|
|
||||||
refund.setReferenceType(ReferenceType.JOB);
|
|
||||||
refund.setReferenceId(jobId.toString());
|
|
||||||
refund.setPolicyId(row.getPolicyId());
|
|
||||||
refund.setBillingCategory(category);
|
|
||||||
ledgerRepository.save(refund);
|
|
||||||
// Hand back the free units this job consumed (first-step failures are
|
|
||||||
// pre-meter, so nothing was billed to Stripe — only the grant moved). Exactly
|
|
||||||
// what was taken at charge time, so the counter can't drift above the grant.
|
|
||||||
int freeConsumed =
|
|
||||||
row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
|
|
||||||
if (freeConsumed > 0 && row.getTeamId() != null) {
|
|
||||||
teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,142 +197,6 @@ public class JobChargeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes a process and — as a fallback — meters its usage. The primary meter trigger is the
|
|
||||||
* charge interceptor's {@code afterCompletion} on a successful request (see {@link
|
|
||||||
* #meterJobUsage(UUID)}); this close-time meter exists to catch processes that were never
|
|
||||||
* cleanly completed (request thread died before {@code afterCompletion}) and are swept up later
|
|
||||||
* by {@code StaleJobCloser}. The deterministic idempotency key means a job already metered at
|
|
||||||
* completion is deduped here at Stripe, so the two paths never double-bill.
|
|
||||||
*
|
|
||||||
* <p>Idempotent w.r.t. process state (delegates to {@link JobService#close(UUID)}, which
|
|
||||||
* silently no-ops on an already-closed row). The meter POST runs in an {@code afterCommit} hook
|
|
||||||
* so a failed POST does not roll back the close; the reconciliation backfill (separate chunk)
|
|
||||||
* is the durability mechanism.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public ProcessingJob close(UUID jobId) {
|
|
||||||
Objects.requireNonNull(jobId, "jobId");
|
|
||||||
ProcessingJob closed = jobService.close(jobId);
|
|
||||||
|
|
||||||
// The afterCommit hook only fires if there's an active transaction (Spring's
|
|
||||||
// @Transactional ensures that). If we're called outside one — e.g. a test using the raw
|
|
||||||
// bean — fall through with a debug log: the close() above already happened in a
|
|
||||||
// sub-transaction created by JobService, but the surrounding scope has no synchronization.
|
|
||||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
|
||||||
log.debug("close({}): no active synchronization; skipping meter POST", jobId);
|
|
||||||
return closed;
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionSynchronizationManager.registerSynchronization(
|
|
||||||
new TransactionSynchronization() {
|
|
||||||
@Override
|
|
||||||
public void afterCommit() {
|
|
||||||
try {
|
|
||||||
meterJobUsage(jobId);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// PaygMeterReportingService should already swallow; defence in depth so
|
|
||||||
// a thrown exception out of afterCommit doesn't leak past the
|
|
||||||
// synchronization boundary and bubble into the caller.
|
|
||||||
log.warn(
|
|
||||||
"afterCommit meter post for job {} threw unexpectedly: {}",
|
|
||||||
jobId,
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return closed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post this job's billable usage to Stripe. The primary caller is the charge interceptor's
|
|
||||||
* {@code afterCompletion} on a successful OPENED request — i.e. the moment the work finishes —
|
|
||||||
* so the meter moves promptly. {@link #close(UUID)} also calls this from its {@code
|
|
||||||
* afterCommit} hook as the fallback for processes that were never cleanly completed (e.g. the
|
|
||||||
* request thread died); the deterministic idempotency key ({@code process:<id>:close}) makes
|
|
||||||
* the two paths dedup at Stripe, so a job metered at completion isn't billed again when it's
|
|
||||||
* later stale-closed.
|
|
||||||
*
|
|
||||||
* <p>Safe to call outside a transaction: it only reads (the job's openProcess DEBIT is already
|
|
||||||
* committed by the time either caller runs) and the POST is best-effort. Never throws — see
|
|
||||||
* {@link PaygMeterReportingService}.
|
|
||||||
*
|
|
||||||
* <p>Skips: no shadow row (not PAYG-tracked), REFUNDED row (first-step failure — never billed),
|
|
||||||
* BYPASSED/uncategorised, zero units, free-tier team (no Stripe customer), or usage still
|
|
||||||
* within the app-side free allowance.
|
|
||||||
*/
|
|
||||||
public void meterJobUsage(UUID jobId) {
|
|
||||||
Optional<PaygShadowCharge> rowOpt = shadowRepository.findFirstByJobIdOrderByIdAsc(jobId);
|
|
||||||
if (rowOpt.isEmpty()) {
|
|
||||||
// No shadow row → not a PAYG-tracked job; nothing to meter.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
PaygShadowCharge row = rowOpt.get();
|
|
||||||
if (row.getStatus() == ShadowChargeStatus.REFUNDED) {
|
|
||||||
// Refunded rows are zero-net charges; do not emit a meter event.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BillingCategory category = row.getBillingCategory();
|
|
||||||
if (category == null || category == BillingCategory.BYPASSED) {
|
|
||||||
// Defensive: BYPASSED rows shouldn't exist (interceptor short-circuits before
|
|
||||||
// openProcess), but tolerate if a future caller writes one.
|
|
||||||
log.debug("close({}): shadow row category={} → no meter event", jobId, category);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Integer units = row.getPaygUnits();
|
|
||||||
if (units == null || units <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Long teamId = row.getTeamId();
|
|
||||||
if (teamId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
PaygTeamExtensions ext = teamExtensionsRepository.findById(teamId).orElse(null);
|
|
||||||
if (ext == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// payg_subscription_id is the single switch that says "this team is billed" (see
|
|
||||||
// PaygTeamExtensions). Gate on it directly now that V14 ships the column: a team with a
|
|
||||||
// Stripe customer but no live subscription — e.g. the brief window after checkout but
|
|
||||||
// before the subscription-created webhook lands — must not post meter events against a
|
|
||||||
// subscription that doesn't exist. A job finishing in that window is still metered later
|
|
||||||
// via the stale-close fallback, once the subscription has landed (same idempotency key).
|
|
||||||
String subscriptionId = ext.getPaygSubscriptionId();
|
|
||||||
if (subscriptionId == null || subscriptionId.isBlank()) {
|
|
||||||
log.debug(
|
|
||||||
"close({}): team {} has no active subscription → no meter event",
|
|
||||||
jobId,
|
|
||||||
teamId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String stripeCustomerId = ext.getStripeCustomerId();
|
|
||||||
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
|
|
||||||
// Subscribed but no customer id is a data inconsistency — we can't address the event.
|
|
||||||
log.warn(
|
|
||||||
"close({}): team {} has a subscription but no stripeCustomerId → cannot meter",
|
|
||||||
jobId,
|
|
||||||
teamId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paid portion = units beyond the team's one-time free grant, fixed at charge time. The
|
|
||||||
// free grant is app-side only (Stripe's Prices are plain per-unit, no free tier), so the
|
|
||||||
// free units were already withheld when this row's free_units_consumed was set.
|
|
||||||
int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
|
|
||||||
int paidUnits = units - freeConsumed;
|
|
||||||
if (paidUnits <= 0) {
|
|
||||||
log.debug(
|
|
||||||
"close({}): all {} units came from the free grant → no meter event",
|
|
||||||
jobId,
|
|
||||||
units);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String idempotencyKey = "process:" + jobId + ":close";
|
|
||||||
meterReportingService.recordUsage(
|
|
||||||
teamId, stripeCustomerId, paidUnits, category, idempotencyKey, jobId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mid-chain 5xx on a JOINED step: return the step slot. The {@code lastStepAt} timestamp stays
|
* Mid-chain 5xx on a JOINED step: return the step slot. The {@code lastStepAt} timestamp stays
|
||||||
* advanced (workflow window intentionally remains active for the next retry). No shadow-row
|
* advanced (workflow window intentionally remains active for the next retry). No shadow-row
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
package stirling.software.saas.payg.entitlement;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.core.annotation.AnnotationUtils;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.method.HandlerMethod;
|
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import io.micrometer.core.instrument.Counter;
|
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
|
||||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.payg.cap.AiToolRoutes;
|
|
||||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hot-path entitlement check. Runs after {@code PaygChargeInterceptor} in the MVC chain and short-
|
|
||||||
* circuits the request before any handler work happens when the team's snapshot is missing one of
|
|
||||||
* the gates the route declared via {@link RequiresFeature}.
|
|
||||||
*
|
|
||||||
* <p>Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping}
|
|
||||||
* (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated
|
|
||||||
* routes). Admin / info / config endpoints are excluded by the path-pattern in {@code
|
|
||||||
* PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non-
|
|
||||||
* billable infra never trips the guard.
|
|
||||||
*
|
|
||||||
* <p>Decision matrix:
|
|
||||||
*
|
|
||||||
* <table>
|
|
||||||
* <tr><th>auth</th><th>required gates</th><th>snapshot enabled?</th><th>outcome</th></tr>
|
|
||||||
* <tr><td>anonymous</td><td>AUTOMATION or AI_SUPPORT</td><td>n/a</td><td>401 SIGNUP_REQUIRED</td></tr>
|
|
||||||
* <tr><td>anonymous</td><td>OFFSITE_PROCESSING / CLIENT_SIDE</td><td>n/a</td><td>200 (pass through)</td></tr>
|
|
||||||
* <tr><td>authenticated</td><td>required ⊆ enabled</td><td>yes</td><td>200</td></tr>
|
|
||||||
* <tr><td>authenticated</td><td>required ⊄ enabled</td><td>no</td><td>402 FEATURE_DEGRADED</td></tr>
|
|
||||||
* </table>
|
|
||||||
*
|
|
||||||
* <p>Fail-open: any unexpected exception is logged at WARN and the request passes through. The cap
|
|
||||||
* pipeline must never block a customer because the guard tripped on a transient DB error.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@Profile("saas")
|
|
||||||
public class EntitlementGuard implements HandlerInterceptor {
|
|
||||||
|
|
||||||
private static final FeatureGate[] DEFAULT_REQUIRED_GATES = {FeatureGate.OFFSITE_PROCESSING};
|
|
||||||
|
|
||||||
private final EntitlementService entitlementService;
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
private final Counter passCounter;
|
|
||||||
private final Counter deniedDegradedCounter;
|
|
||||||
private final Counter deniedPaygLimitCounter;
|
|
||||||
private final Counter deniedSignupRequiredCounter;
|
|
||||||
private final Counter errorsCounter;
|
|
||||||
private final Counter skippedNoAnnotationCounter;
|
|
||||||
|
|
||||||
public EntitlementGuard(
|
|
||||||
EntitlementService entitlementService,
|
|
||||||
UserRepository userRepository,
|
|
||||||
MeterRegistry meterRegistry) {
|
|
||||||
this.entitlementService = entitlementService;
|
|
||||||
this.userRepository = userRepository;
|
|
||||||
this.objectMapper = new ObjectMapper();
|
|
||||||
|
|
||||||
this.passCounter =
|
|
||||||
Counter.builder("payg.entitlement.guard")
|
|
||||||
.tag("outcome", "pass")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.deniedDegradedCounter =
|
|
||||||
Counter.builder("payg.entitlement.guard")
|
|
||||||
.tag("outcome", "denied_degraded")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.deniedPaygLimitCounter =
|
|
||||||
Counter.builder("payg.entitlement.guard")
|
|
||||||
.tag("outcome", "denied_payg_limit")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.deniedSignupRequiredCounter =
|
|
||||||
Counter.builder("payg.entitlement.guard")
|
|
||||||
.tag("outcome", "denied_signup_required")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.skippedNoAnnotationCounter =
|
|
||||||
Counter.builder("payg.entitlement.guard")
|
|
||||||
.tag("outcome", "skipped")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.errorsCounter =
|
|
||||||
Counter.builder("payg.entitlement.guard.errors")
|
|
||||||
.description("EntitlementGuard internal failures (fail-open)")
|
|
||||||
.register(meterRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean preHandle(
|
|
||||||
HttpServletRequest request, HttpServletResponse response, Object handler) {
|
|
||||||
if (!(handler instanceof HandlerMethod hm)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Scope: AutoJobPostMapping routes (multipart tool POSTs) OR routes that explicitly
|
|
||||||
// declare @RequiresFeature (e.g. AI controllers — JSON-bodied, no AutoJobPostMapping).
|
|
||||||
// Admin / info / config endpoints carry neither annotation and never trip the guard.
|
|
||||||
boolean hasAutoJobPostMapping =
|
|
||||||
AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null
|
|
||||||
|| AnnotationUtils.findAnnotation(
|
|
||||||
hm.getBeanType(), AutoJobPostMapping.class)
|
|
||||||
!= null;
|
|
||||||
boolean hasRequiresFeature =
|
|
||||||
AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null
|
|
||||||
|| AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class)
|
|
||||||
!= null;
|
|
||||||
// AI document tools (/api/v1/ai/tools/**) live in the proprietary module and can't carry
|
|
||||||
// @RequiresFeature; recognise them by path so they're gated on AI_SUPPORT — see
|
|
||||||
// AiToolRoutes and PaygChargeInterceptor, which classify the same routes as AI.
|
|
||||||
boolean aiToolRoute = AiToolRoutes.matches(request);
|
|
||||||
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) {
|
|
||||||
skippedNoAnnotationCounter.increment();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
FeatureGate[] required =
|
|
||||||
aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm);
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
|
|
||||||
boolean anonymous = isAnonymous(auth);
|
|
||||||
boolean billable = isBillable(required);
|
|
||||||
|
|
||||||
if (anonymous) {
|
|
||||||
if (billable) {
|
|
||||||
return write401SignupRequired(response, required);
|
|
||||||
}
|
|
||||||
// Anonymous user calling a manual / OFFSITE-only tool — let it through; PAYG only
|
|
||||||
// charges authenticated requests.
|
|
||||||
passCounter.increment();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Long teamId;
|
|
||||||
try {
|
|
||||||
teamId = resolveTeamId(auth);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn("EntitlementGuard resolveTeamId failed; passing through", e);
|
|
||||||
errorsCounter.increment();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (teamId == null) {
|
|
||||||
// Defensive: authenticated principal with no team — shouldn't happen post-migration,
|
|
||||||
// but we don't want to lock those users out. PaygChargeInterceptor short-circuits the
|
|
||||||
// same shape upstream.
|
|
||||||
passCounter.increment();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
EntitlementSnapshot snapshot;
|
|
||||||
try {
|
|
||||||
snapshot = entitlementService.getSnapshot(teamId);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn("EntitlementGuard getSnapshot failed for team {}; passing through", teamId, e);
|
|
||||||
errorsCounter.increment();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API-key calls are always billable usage (BillingCategory.API) — there is no "free
|
|
||||||
// manual" path for a programmatic client the way there is for a JWT/web user, whose
|
|
||||||
// everyday tool calls are BYPASSED and never reach a gate. So once the team is over its
|
|
||||||
// free allowance / spending cap (DEGRADED), every API-key call hard-stops, regardless of
|
|
||||||
// which gate the route declares. The gate loop below would otherwise wave through an API
|
|
||||||
// call to a plain server tool (it needs only OFFSITE_PROCESSING, which survives DEGRADED),
|
|
||||||
// letting an unsubscribed team keep consuming the API for free past its allowance.
|
|
||||||
if (auth instanceof ApiKeyAuthenticationToken && snapshot.isDegraded()) {
|
|
||||||
return write402PaygLimitReached(response, snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<FeatureGate> enabled = snapshot.enabledGates();
|
|
||||||
for (FeatureGate gate : required) {
|
|
||||||
if (enabled == null || !enabled.contains(gate)) {
|
|
||||||
return write402FeatureDegraded(response, required, snapshot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
passCounter.increment();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static FeatureGate[] resolveRequiredGates(HandlerMethod hm) {
|
|
||||||
RequiresFeature ann = AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class);
|
|
||||||
if (ann == null) {
|
|
||||||
ann = AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class);
|
|
||||||
}
|
|
||||||
if (ann != null && ann.value().length > 0) {
|
|
||||||
return ann.value();
|
|
||||||
}
|
|
||||||
return DEFAULT_REQUIRED_GATES;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isAnonymous(Authentication auth) {
|
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Spring's anonymous filter installs a token whose name is "anonymousUser".
|
|
||||||
return "anonymousUser".equals(auth.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isBillable(FeatureGate[] required) {
|
|
||||||
for (FeatureGate g : required) {
|
|
||||||
if (g == FeatureGate.AUTOMATION || g == FeatureGate.AI_SUPPORT) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long resolveTeamId(Authentication auth) {
|
|
||||||
if (auth instanceof ApiKeyAuthenticationToken
|
|
||||||
&& auth.getPrincipal() instanceof User apiUser) {
|
|
||||||
return apiUser.getTeam() == null ? null : apiUser.getTeam().getId();
|
|
||||||
}
|
|
||||||
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
|
|
||||||
if (supabaseId == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
UUID supabaseUuid;
|
|
||||||
try {
|
|
||||||
supabaseUuid = UUID.fromString(supabaseId);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
// Username-style principals (legacy local accounts) — no Supabase ID to look up. Skip.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return userRepository
|
|
||||||
.findBySupabaseId(supabaseUuid)
|
|
||||||
.map(u -> u.getTeam() == null ? null : u.getTeam().getId())
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean write401SignupRequired(HttpServletResponse response, FeatureGate[] required) {
|
|
||||||
deniedSignupRequiredCounter.increment();
|
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
|
||||||
body.put("error", "SIGNUP_REQUIRED");
|
|
||||||
body.put("category", inferCategory(required));
|
|
||||||
writeJson(response, HttpStatus.UNAUTHORIZED, body);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 402 for a billable API-key call once the team is over its allowance / cap. The message is
|
|
||||||
* tailored by subscription state: an un-subscribed team is told to subscribe (their free
|
|
||||||
* allowance is spent); a subscribed team is told it hit its own spending cap. Programmatic
|
|
||||||
* clients get a stable {@code error} code plus the spend/cap numbers so they can surface
|
|
||||||
* something actionable.
|
|
||||||
*/
|
|
||||||
private boolean write402PaygLimitReached(
|
|
||||||
HttpServletResponse response, EntitlementSnapshot snapshot) {
|
|
||||||
deniedPaygLimitCounter.increment();
|
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
|
||||||
body.put("error", "PAYG_LIMIT_REACHED");
|
|
||||||
body.put("subscribed", snapshot.subscribed());
|
|
||||||
body.put(
|
|
||||||
"message",
|
|
||||||
snapshot.subscribed()
|
|
||||||
? "Your team has reached its monthly spending cap. Raise the cap to"
|
|
||||||
+ " continue, or wait for it to reset next billing period."
|
|
||||||
: "Your team has used its free document allowance."
|
|
||||||
+ " Subscribe to continue using the API.");
|
|
||||||
body.put("state", snapshot.state().name());
|
|
||||||
body.put("spendUnits", snapshot.periodSpendUnits());
|
|
||||||
body.put("capUnits", snapshot.periodCapUnits());
|
|
||||||
body.put(
|
|
||||||
"periodEnd",
|
|
||||||
Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null));
|
|
||||||
writeJson(response, HttpStatus.PAYMENT_REQUIRED, body);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean write402FeatureDegraded(
|
|
||||||
HttpServletResponse response, FeatureGate[] required, EntitlementSnapshot snapshot) {
|
|
||||||
deniedDegradedCounter.increment();
|
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
|
||||||
body.put("error", "FEATURE_DEGRADED");
|
|
||||||
// subscribed tells the client which usage-limit modal to show: a subscribed team is over
|
|
||||||
// its spending cap; an un-subscribed one has spent its free allowance. (PAYG_LIMIT_REACHED
|
|
||||||
// already carries this; mirror it here so the JWT/web path can pick the right modal too.)
|
|
||||||
body.put("subscribed", snapshot.subscribed());
|
|
||||||
body.put("missingGates", missingGates(required, snapshot.enabledGates()));
|
|
||||||
body.put("state", snapshot.state().name());
|
|
||||||
body.put(
|
|
||||||
"periodEnd",
|
|
||||||
Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null));
|
|
||||||
body.put("capUnits", snapshot.periodCapUnits());
|
|
||||||
body.put("spendUnits", snapshot.periodSpendUnits());
|
|
||||||
writeJson(response, HttpStatus.PAYMENT_REQUIRED, body);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<String> missingGates(FeatureGate[] required, List<FeatureGate> enabled) {
|
|
||||||
List<FeatureGate> enabledOrEmpty = enabled == null ? Collections.emptyList() : enabled;
|
|
||||||
return Arrays.stream(required)
|
|
||||||
.filter(g -> !enabledOrEmpty.contains(g))
|
|
||||||
.map(Enum::name)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String inferCategory(FeatureGate[] required) {
|
|
||||||
// Mirrors PaygChargeInterceptor.determineCategory precedence: AUTOMATION dominates AI.
|
|
||||||
for (FeatureGate g : required) {
|
|
||||||
if (g == FeatureGate.AUTOMATION) {
|
|
||||||
return "AUTOMATION";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (FeatureGate g : required) {
|
|
||||||
if (g == FeatureGate.AI_SUPPORT) {
|
|
||||||
return "AI";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "OFFSITE_PROCESSING";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeJson(
|
|
||||||
HttpServletResponse response, HttpStatus status, Map<String, Object> body) {
|
|
||||||
response.setStatus(status.value());
|
|
||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
|
||||||
response.setCharacterEncoding("UTF-8");
|
|
||||||
try {
|
|
||||||
byte[] payload = objectMapper.writeValueAsBytes(body);
|
|
||||||
response.setHeader(HttpHeaders.CONTENT_LENGTH, Integer.toString(payload.length));
|
|
||||||
response.getOutputStream().write(payload);
|
|
||||||
response.getOutputStream().flush();
|
|
||||||
} catch (IOException e) {
|
|
||||||
// Container will fall back to its default error page — we did set the status code,
|
|
||||||
// so the client still sees the right HTTP code even if the body fails to write.
|
|
||||||
log.warn("EntitlementGuard write response body failed", e);
|
|
||||||
errorsCounter.increment();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-182
@@ -1,182 +0,0 @@
|
|||||||
package stirling.software.saas.payg.entitlement;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.YearMonth;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import com.github.benmanes.caffeine.cache.Cache;
|
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.saas.payg.billing.TeamBillingContext;
|
|
||||||
import stirling.software.saas.payg.billing.TeamBillingService;
|
|
||||||
import stirling.software.saas.payg.cap.CapEvaluator;
|
|
||||||
import stirling.software.saas.payg.cap.CapEvaluator.Evaluation;
|
|
||||||
import stirling.software.saas.payg.model.EntitlementState;
|
|
||||||
import stirling.software.saas.payg.model.FeatureSet;
|
|
||||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
|
||||||
import stirling.software.saas.payg.repository.WalletPolicyRepository;
|
|
||||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hot-path entitlement lookup. Returns the {@link EntitlementSnapshot} for a team: the billing
|
|
||||||
* facts (window, free allowance, document cap) come from {@link TeamBillingService}; this service
|
|
||||||
* layers the period spend (ledger SUM over that window) and the warn/degrade evaluation on top.
|
|
||||||
*
|
|
||||||
* <p>Backed by a per-team Caffeine cache with {@value #CACHE_TTL_SECONDS}s TTL and {@value
|
|
||||||
* #CACHE_MAX_SIZE}-entry cap. The TTL is the correctness floor — a cap change becomes visible on
|
|
||||||
* every instance within that window without coordination. Mutators (wallet policy admin updates,
|
|
||||||
* subscription webhook handlers) call {@link #invalidate(Long)} to drop a single team's entry
|
|
||||||
* immediately on the originating instance.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@Profile("saas")
|
|
||||||
public class EntitlementService {
|
|
||||||
|
|
||||||
static final int CACHE_TTL_SECONDS = 30;
|
|
||||||
private static final int CACHE_MAX_SIZE = 10_000;
|
|
||||||
|
|
||||||
private static final int WARN_AT_PCT = 80;
|
|
||||||
private static final int DEGRADE_AT_PCT = 100;
|
|
||||||
|
|
||||||
private final TeamBillingService teamBillingService;
|
|
||||||
private final WalletPolicyRepository walletPolicyRepository;
|
|
||||||
private final WalletLedgerRepository ledgerRepository;
|
|
||||||
|
|
||||||
private final Cache<Long, EntitlementSnapshot> snapshotCache;
|
|
||||||
|
|
||||||
public EntitlementService(
|
|
||||||
TeamBillingService teamBillingService,
|
|
||||||
WalletPolicyRepository walletPolicyRepository,
|
|
||||||
WalletLedgerRepository ledgerRepository) {
|
|
||||||
this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService");
|
|
||||||
this.walletPolicyRepository =
|
|
||||||
Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository");
|
|
||||||
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
|
|
||||||
this.snapshotCache =
|
|
||||||
Caffeine.newBuilder()
|
|
||||||
.maximumSize(CACHE_MAX_SIZE)
|
|
||||||
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
|
|
||||||
.recordStats()
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the entitlement snapshot for {@code teamId}. Caches per-team for {@value
|
|
||||||
* #CACHE_TTL_SECONDS}s — burst requests share a single SUM query against the ledger.
|
|
||||||
*
|
|
||||||
* <p>{@code null} teamId throws — the guard short-circuits team-less requests upstream so a
|
|
||||||
* null reach here is a programming error.
|
|
||||||
*/
|
|
||||||
public EntitlementSnapshot getSnapshot(Long teamId) {
|
|
||||||
Objects.requireNonNull(teamId, "teamId");
|
|
||||||
return snapshotCache.get(teamId, this::computeSnapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drops {@code teamId}'s cache entry. Call after subscription state changes (webhook handlers),
|
|
||||||
* cap edits, or manual ledger adjustments so the next read recomputes immediately rather than
|
|
||||||
* waiting out the TTL. Also drops the underlying billing context so window/cap facts recompute
|
|
||||||
* together with the spend.
|
|
||||||
*/
|
|
||||||
public void invalidate(Long teamId) {
|
|
||||||
if (teamId != null) {
|
|
||||||
snapshotCache.invalidate(teamId);
|
|
||||||
teamBillingService.invalidate(teamId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Visible for tests. */
|
|
||||||
long cacheSize() {
|
|
||||||
return snapshotCache.estimatedSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
EntitlementSnapshot computeSnapshot(Long teamId) {
|
|
||||||
TeamBillingContext billing = teamBillingService.forTeam(teamId);
|
|
||||||
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
|
|
||||||
|
|
||||||
FeatureSet degradedSet =
|
|
||||||
walletPolicyOpt.map(WalletPolicy::getDegradedFeatureSet).orElse(FeatureSet.MINIMAL);
|
|
||||||
int warnAtPct =
|
|
||||||
walletPolicyOpt
|
|
||||||
.map(WalletPolicy::getWarnAtPct)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.orElse(WARN_AT_PCT);
|
|
||||||
int degradeAtPct =
|
|
||||||
walletPolicyOpt
|
|
||||||
.map(WalletPolicy::getDegradeAtPct)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.orElse(DEGRADE_AT_PCT);
|
|
||||||
|
|
||||||
// Subscription-anchored window when subscribed; calendar month otherwise. Used for the
|
|
||||||
// subscribed monthly cap + the displayed billing period.
|
|
||||||
LocalDateTime periodStart = billing.periodStart();
|
|
||||||
LocalDateTime periodEnd = billing.periodEnd();
|
|
||||||
|
|
||||||
Evaluation eval;
|
|
||||||
long snapshotSpend;
|
|
||||||
Long snapshotCap;
|
|
||||||
|
|
||||||
if (billing.subscribed()) {
|
|
||||||
// Subscribed: gate on the monthly spending cap. Spend = this period's net billable
|
|
||||||
// documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The one-time
|
|
||||||
// free grant doesn't gate a paying team — it only reduced what they were metered.
|
|
||||||
long signedNet = ledgerRepository.sumPeriodNetBillable(teamId, periodStart, periodEnd);
|
|
||||||
long periodSpend = signedNet < 0 ? -signedNet : 0L;
|
|
||||||
Long cap = billing.monthlyCapDocUnits();
|
|
||||||
eval = CapEvaluator.evaluate(periodSpend, cap, warnAtPct, degradeAtPct, degradedSet);
|
|
||||||
snapshotSpend = periodSpend;
|
|
||||||
snapshotCap = cap;
|
|
||||||
} else {
|
|
||||||
// Unsubscribed: gate on the one-time lifetime free grant. Exhausted (remaining ≤ 0, or
|
|
||||||
// no grant configured) → DEGRADED so billable categories hard-stop; otherwise evaluate
|
|
||||||
// the warn/degrade band on used-of-grant.
|
|
||||||
long grant = billing.freeGrantUnits();
|
|
||||||
long remaining = billing.freeRemainingUnits();
|
|
||||||
long used = Math.max(0L, grant - remaining);
|
|
||||||
if (remaining <= 0L) {
|
|
||||||
eval =
|
|
||||||
new Evaluation(
|
|
||||||
EntitlementState.DEGRADED,
|
|
||||||
degradedSet,
|
|
||||||
CapEvaluator.gatesFor(degradedSet));
|
|
||||||
} else {
|
|
||||||
eval = CapEvaluator.evaluate(used, grant, warnAtPct, degradeAtPct, degradedSet);
|
|
||||||
}
|
|
||||||
snapshotSpend = used;
|
|
||||||
snapshotCap = grant;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new EntitlementSnapshot(
|
|
||||||
eval.state(),
|
|
||||||
eval.featureSet(),
|
|
||||||
List.copyOf(eval.enabledGates()),
|
|
||||||
snapshotSpend,
|
|
||||||
snapshotCap,
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
billing.subscribed());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inclusive-start / exclusive-end window for the calendar-month period. Test seam — takes a
|
|
||||||
* clock value so tests don't race the calendar boundary. The live snapshot window comes from
|
|
||||||
* {@link TeamBillingService}; this remains for the forthcoming {@code BILLING_CYCLE} work.
|
|
||||||
*/
|
|
||||||
static LocalDateTime[] currentMonthWindow(LocalDateTime now) {
|
|
||||||
YearMonth ym = YearMonth.from(now);
|
|
||||||
LocalDateTime start = ym.atDay(1).atStartOfDay();
|
|
||||||
LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay();
|
|
||||||
return new LocalDateTime[] {start, end};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
package stirling.software.saas.payg.entitlement;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import stirling.software.saas.payg.model.EntitlementState;
|
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
|
||||||
import stirling.software.saas.payg.model.FeatureSet;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable snapshot of a team's entitlement state as of a single point in time. Returned by {@link
|
|
||||||
* EntitlementService#getSnapshot(Long)} and consumed by {@code EntitlementGuard}.
|
|
||||||
*
|
|
||||||
* <p>Contrast with {@link WalletEntitlementSnapshot}: the JPA entity is the <em>persisted</em>
|
|
||||||
* snapshot that the recompute path writes (one row per team, optionally per member). This record is
|
|
||||||
* the <em>computed-now</em> view the hot-path guard reads — backed by a 30s Caffeine cache so a
|
|
||||||
* request burst doesn't hammer the ledger SUM.
|
|
||||||
*
|
|
||||||
* @param state aggregate state — FULL, WARNED, or DEGRADED.
|
|
||||||
* @param featureSet bundle name in effect (FULL on no-cap / warn band; degraded set on DEGRADED).
|
|
||||||
* @param enabledGates the gates the guard checks against — request proceeds only if every required
|
|
||||||
* gate is in this list.
|
|
||||||
* @param periodSpendUnits sum of debited units in {@code [periodStart, periodEnd)}, in canonical
|
|
||||||
* doc-units (positive).
|
|
||||||
* @param periodCapUnits the cap applied — free-tier units for un-subscribed teams, {@code
|
|
||||||
* wallet_policy.cap_units} for subscribed teams. {@code null} means uncapped.
|
|
||||||
* @param periodStart inclusive start of the current cap period.
|
|
||||||
* @param periodEnd exclusive end of the current cap period.
|
|
||||||
* @param subscribed whether the team has an active PAYG subscription. Drives the messaging when a
|
|
||||||
* billable call is hard-stopped: an un-subscribed team is told to subscribe; a subscribed team
|
|
||||||
* that hit its self-set spending cap is told to raise it.
|
|
||||||
*/
|
|
||||||
public record EntitlementSnapshot(
|
|
||||||
EntitlementState state,
|
|
||||||
FeatureSet featureSet,
|
|
||||||
List<FeatureGate> enabledGates,
|
|
||||||
long periodSpendUnits,
|
|
||||||
Long periodCapUnits,
|
|
||||||
LocalDateTime periodStart,
|
|
||||||
LocalDateTime periodEnd,
|
|
||||||
boolean subscribed) {
|
|
||||||
|
|
||||||
public boolean isDegraded() {
|
|
||||||
return state == EntitlementState.DEGRADED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+10
-128
@@ -12,7 +12,6 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.core.annotation.AnnotationUtils;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -38,15 +37,11 @@ import stirling.software.common.util.TempFileManager;
|
|||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.saas.payg.cap.AiToolRoutes;
|
|
||||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
|
||||||
import stirling.software.saas.payg.charge.ChargeContext;
|
import stirling.software.saas.payg.charge.ChargeContext;
|
||||||
import stirling.software.saas.payg.charge.ChargeOutcome;
|
import stirling.software.saas.payg.charge.ChargeOutcome;
|
||||||
import stirling.software.saas.payg.charge.JobChargeService;
|
import stirling.software.saas.payg.charge.JobChargeService;
|
||||||
import stirling.software.saas.payg.charge.JobInput;
|
import stirling.software.saas.payg.charge.JobInput;
|
||||||
import stirling.software.saas.payg.job.JobService;
|
import stirling.software.saas.payg.job.JobService;
|
||||||
import stirling.software.saas.payg.model.BillingCategory;
|
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
|
||||||
import stirling.software.saas.payg.model.JobSource;
|
import stirling.software.saas.payg.model.JobSource;
|
||||||
import stirling.software.saas.payg.model.JobStepStatus;
|
import stirling.software.saas.payg.model.JobStepStatus;
|
||||||
import stirling.software.saas.payg.model.ProcessType;
|
import stirling.software.saas.payg.model.ProcessType;
|
||||||
@@ -57,13 +52,10 @@ import stirling.software.saas.util.AuthenticationUtils;
|
|||||||
* after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste
|
* after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste
|
||||||
* work hashing inputs.
|
* work hashing inputs.
|
||||||
*
|
*
|
||||||
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the
|
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping}, reads the parsed multipart parts,
|
||||||
* latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the
|
* materialises each input to a {@code TempFile}, and asks {@link JobChargeService#openProcess} to
|
||||||
* parsed multipart parts, materialises each input to a {@code TempFile}, and asks {@link
|
* open (or join) a process. The resulting {@link ChargeOutcome} plus input temp-files are stashed
|
||||||
* JobChargeService#openProcess} to open (or join) a process. The resulting {@link ChargeOutcome}
|
* as request attributes for {@code afterCompletion}.
|
||||||
* plus input temp-files are stashed as request attributes for {@code afterCompletion}. Routes
|
|
||||||
* without multipart inputs short-circuit inside {@code doPreHandle} without touching the charge
|
|
||||||
* service.
|
|
||||||
*
|
*
|
||||||
* <p>{@code afterCompletion}: branches on HTTP status — 2xx hashes the response body for OUTPUT
|
* <p>{@code afterCompletion}: branches on HTTP status — 2xx hashes the response body for OUTPUT
|
||||||
* lineage; 4xx records a step append for audit; 5xx triggers refund-and-close (OPENED) or
|
* lineage; 4xx records a step append for audit; 5xx triggers refund-and-close (OPENED) or
|
||||||
@@ -113,7 +105,6 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
private final Counter callsOpened;
|
private final Counter callsOpened;
|
||||||
private final Counter callsJoined;
|
private final Counter callsJoined;
|
||||||
private final Counter callsShortCircuit;
|
private final Counter callsShortCircuit;
|
||||||
private final Counter callsBypassed;
|
|
||||||
private final Counter refundsCounter;
|
private final Counter refundsCounter;
|
||||||
|
|
||||||
/** preHandle wall-clock per request. Separate from afterCompletion — different populations. */
|
/** preHandle wall-clock per request. Separate from afterCompletion — different populations. */
|
||||||
@@ -153,11 +144,6 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
Counter.builder("payg.filter.calls")
|
Counter.builder("payg.filter.calls")
|
||||||
.tag("disposition", "SHORT_CIRCUIT")
|
.tag("disposition", "SHORT_CIRCUIT")
|
||||||
.register(meterRegistry);
|
.register(meterRegistry);
|
||||||
this.callsBypassed =
|
|
||||||
Counter.builder("payg.filter.bypassed")
|
|
||||||
.description(
|
|
||||||
"Manual UI tool calls that skipped openProcess (BillingCategory.BYPASSED)")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.refundsCounter =
|
this.refundsCounter =
|
||||||
Counter.builder("payg.filter.refunds")
|
Counter.builder("payg.filter.refunds")
|
||||||
.description("First-step 5xx refunds applied to shadow rows")
|
.description("First-step 5xx refunds applied to shadow rows")
|
||||||
@@ -182,42 +168,13 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
if (!properties.isEnabled()) {
|
if (!properties.isEnabled()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!(handler instanceof HandlerMethod hm)) {
|
if (!(handler instanceof HandlerMethod hm)
|
||||||
|
|| hm.getMethodAnnotation(AutoJobPostMapping.class) == null) {
|
||||||
callsShortCircuit.increment();
|
callsShortCircuit.increment();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// In-scope when the handler carries @AutoJobPostMapping (multipart tool POSTs) OR
|
|
||||||
// @RequiresFeature (AI controllers, future non-multipart gated routes). Without one of
|
|
||||||
// these the interceptor short-circuits — admin / info / static routes never run
|
|
||||||
// determineCategory.
|
|
||||||
boolean hasAutoJobPostMapping =
|
|
||||||
AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null
|
|
||||||
|| AnnotationUtils.findAnnotation(
|
|
||||||
hm.getBeanType(), AutoJobPostMapping.class)
|
|
||||||
!= null;
|
|
||||||
boolean hasRequiresFeature =
|
|
||||||
AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null
|
|
||||||
|| AnnotationUtils.findAnnotation(
|
|
||||||
hm.getBeanType(), RequiresFeature.class)
|
|
||||||
!= null;
|
|
||||||
// AI document tools (/api/v1/ai/tools/**) live in the proprietary module and can't
|
|
||||||
// carry @RequiresFeature, so they're recognised by path — see AiToolRoutes.
|
|
||||||
boolean aiToolRoute = AiToolRoutes.matches(request);
|
|
||||||
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) {
|
|
||||||
callsShortCircuit.increment();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Bypass fast-path: determine the BillingCategory BEFORE any multipart
|
|
||||||
// materialisation or openProcess call. Manual UI tool calls (BYPASSED) skip the
|
|
||||||
// entire ledger/shadow pipeline — no temp files, no DB writes.
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
BillingCategory category = determineCategory(hm, request, auth);
|
|
||||||
if (category == BillingCategory.BYPASSED) {
|
|
||||||
callsBypassed.increment();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
doPreHandle(request, auth, category);
|
doPreHandle(request);
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.warn("PAYG preHandle failed; passing through unbilled", e);
|
log.warn("PAYG preHandle failed; passing through unbilled", e);
|
||||||
errorsCounter.increment();
|
errorsCounter.increment();
|
||||||
@@ -230,8 +187,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void doPreHandle(
|
private void doPreHandle(HttpServletRequest request) {
|
||||||
HttpServletRequest request, Authentication auth, BillingCategory category) {
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
User currentUser = resolveUser(auth);
|
User currentUser = resolveUser(auth);
|
||||||
if (currentUser == null) {
|
if (currentUser == null) {
|
||||||
callsShortCircuit.increment();
|
callsShortCircuit.increment();
|
||||||
@@ -291,8 +248,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
currentUser.getId(),
|
currentUser.getId(),
|
||||||
currentUser.getTeam() == null ? null : currentUser.getTeam().getId(),
|
currentUser.getTeam() == null ? null : currentUser.getTeam().getId(),
|
||||||
determineSource(request, auth),
|
determineSource(request, auth),
|
||||||
ProcessType.SINGLE_TOOL,
|
ProcessType.SINGLE_TOOL);
|
||||||
category);
|
|
||||||
|
|
||||||
ChargeOutcome outcome;
|
ChargeOutcome outcome;
|
||||||
try {
|
try {
|
||||||
@@ -375,38 +331,12 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
}
|
}
|
||||||
if (status >= 400) {
|
if (status >= 400) {
|
||||||
// 4xx: customer paid for the attempt. No OUTPUT recording, no refund.
|
// 4xx: customer paid for the attempt. No OUTPUT recording, no refund.
|
||||||
// Still a successful-from-billing-standpoint OPENED process — meter it below.
|
|
||||||
meterIfOpened(jobId, disposition);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success: this is the moment the billable work finished, so this is when we tell Stripe.
|
|
||||||
// Only the OPENED request meters — JOINED follow-up steps (chained tools on the same
|
|
||||||
// document) added no units and must not re-meter. The process stays OPEN for further
|
|
||||||
// lineage joins; StaleJobCloser closing it later is a no-op at Stripe thanks to the shared
|
|
||||||
// idempotency key. metering is best-effort and must never break the response teardown.
|
|
||||||
meterIfOpened(jobId, disposition);
|
|
||||||
recordOutputs(request, response, jobId);
|
recordOutputs(request, response, jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire the Stripe meter for a just-finished process, but only when this request OPENED it. Runs
|
|
||||||
* on the request-teardown thread (the response is already flushed to the client); {@code
|
|
||||||
* meterJobUsage} is best-effort and swallows its own failures, but we still guard here so a
|
|
||||||
* meter hiccup can't disturb lineage/cleanup that follows.
|
|
||||||
*/
|
|
||||||
private void meterIfOpened(UUID jobId, ChargeOutcome.Disposition disposition) {
|
|
||||||
if (disposition != ChargeOutcome.Disposition.OPENED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
chargeService.meterJobUsage(jobId);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
log.warn("Meter-on-completion failed for job {}: {}", jobId, e.getMessage());
|
|
||||||
errorsCounter.increment();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void recordOutputs(
|
private void recordOutputs(
|
||||||
HttpServletRequest request, HttpServletResponse response, UUID jobId) {
|
HttpServletRequest request, HttpServletResponse response, UUID jobId) {
|
||||||
PaygResponseBodyWrapper wrapper =
|
PaygResponseBodyWrapper wrapper =
|
||||||
@@ -515,54 +445,6 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
return JobSource.WEB;
|
return JobSource.WEB;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the {@link BillingCategory} for this request. Precedence: {@code
|
|
||||||
* X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION;
|
|
||||||
* {@code @RequiresFeature(AI_SUPPORT)} → AI; an AI document-tool route ({@link AiToolRoutes}) →
|
|
||||||
* AI; API-key auth → API; otherwise BYPASSED (manual UI tool — short-circuited in {@link
|
|
||||||
* #preHandle}).
|
|
||||||
*
|
|
||||||
* <p>Method-level {@code @RequiresFeature} wins over class-level. Multiple gates: AUTOMATION
|
|
||||||
* dominates AI within a single annotation. The AI-tool path check sits below the automation
|
|
||||||
* header on purpose: an AI tool dispatched inside a policy / AI workflow bills as AUTOMATION,
|
|
||||||
* while a direct call to it bills as AI.
|
|
||||||
*/
|
|
||||||
private static BillingCategory determineCategory(
|
|
||||||
HandlerMethod handler, HttpServletRequest request, Authentication auth) {
|
|
||||||
String automationHeader = request.getHeader(AUTOMATION_HEADER);
|
|
||||||
if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) {
|
|
||||||
return BillingCategory.AUTOMATION;
|
|
||||||
}
|
|
||||||
RequiresFeature ann =
|
|
||||||
AnnotationUtils.findAnnotation(handler.getMethod(), RequiresFeature.class);
|
|
||||||
if (ann == null) {
|
|
||||||
ann = AnnotationUtils.findAnnotation(handler.getBeanType(), RequiresFeature.class);
|
|
||||||
}
|
|
||||||
if (ann != null) {
|
|
||||||
boolean ai = false;
|
|
||||||
for (FeatureGate gate : ann.value()) {
|
|
||||||
if (gate == FeatureGate.AUTOMATION) {
|
|
||||||
return BillingCategory.AUTOMATION;
|
|
||||||
}
|
|
||||||
if (gate == FeatureGate.AI_SUPPORT) {
|
|
||||||
ai = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ai) {
|
|
||||||
return BillingCategory.AI;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// AI document tools (proprietary module, recognised by path). A direct call bills as AI; an
|
|
||||||
// orchestrator-dispatched call already returned AUTOMATION above via the automation header.
|
|
||||||
if (AiToolRoutes.matches(request)) {
|
|
||||||
return BillingCategory.AI;
|
|
||||||
}
|
|
||||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
|
||||||
return BillingCategory.API;
|
|
||||||
}
|
|
||||||
return BillingCategory.BYPASSED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the {@code tool_id} value stored on {@code processing_job_step}. Prefers the route
|
* Resolves the {@code tool_id} value stored on {@code processing_job_step}. Prefers the route
|
||||||
* pattern (e.g. {@code /api/v1/security/add-password}) over the raw URI so audit rollups
|
* pattern (e.g. {@code /api/v1/security/add-password}) over the raw URI so audit rollups
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
import stirling.software.saas.payg.entitlement.EntitlementGuard;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wires the PAYG filter + interceptor into Spring MVC. Two registrations:
|
* Wires the PAYG filter + interceptor into Spring MVC. Two registrations:
|
||||||
*
|
*
|
||||||
@@ -30,7 +28,6 @@ import stirling.software.saas.payg.entitlement.EntitlementGuard;
|
|||||||
public class PaygWebMvcConfig implements WebMvcConfigurer {
|
public class PaygWebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
private final PaygChargeInterceptor paygChargeInterceptor;
|
private final PaygChargeInterceptor paygChargeInterceptor;
|
||||||
private final EntitlementGuard entitlementGuard;
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public FilterRegistrationBean<PaygResponseBodyWrapperFilter>
|
public FilterRegistrationBean<PaygResponseBodyWrapperFilter>
|
||||||
@@ -42,27 +39,13 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard} (and
|
* Interceptor ordering: the legacy {@code UnifiedCreditInterceptor} (registered with default
|
||||||
* after the legacy {@code UnifiedCreditInterceptor}, default order 0), so {@code openProcess}
|
* order = 0 in {@code CreditInterceptorConfig}) must run BEFORE this one so credit rejections
|
||||||
* only fires for requests the guard has admitted. See {@link #ENTITLEMENT_GUARD_ORDER} for the
|
* short-circuit before we hash inputs. Explicit positive order guarantees this regardless of
|
||||||
* full ordering rationale.
|
* {@code WebMvcConfigurer} bean discovery order.
|
||||||
*/
|
*/
|
||||||
public static final int INTERCEPTOR_ORDER = 1000;
|
public static final int INTERCEPTOR_ORDER = 1000;
|
||||||
|
|
||||||
/**
|
|
||||||
* The {@code EntitlementGuard} runs BEFORE the charge interceptor. Spring runs interceptors in
|
|
||||||
* ascending order on the way in and skips a later interceptor's {@code preHandle} (and its
|
|
||||||
* {@code afterCompletion}) entirely once an earlier one returns {@code false} — so a request
|
|
||||||
* the guard refuses (over its free allowance / spending cap, or with no subscription to bill)
|
|
||||||
* short-circuits with its 402 before the charge interceptor ever runs. A blocked request
|
|
||||||
* therefore never opens a process, materialises inputs, or writes a charge: a refused operation
|
|
||||||
* must not bill, and running the guard first guarantees that structurally rather than by
|
|
||||||
* compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default
|
|
||||||
* order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection
|
|
||||||
* still wins.
|
|
||||||
*/
|
|
||||||
public static final int ENTITLEMENT_GUARD_ORDER = 900;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
registry.addInterceptor(paygChargeInterceptor)
|
registry.addInterceptor(paygChargeInterceptor)
|
||||||
@@ -73,14 +56,5 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
|||||||
"/api/v1/info/**",
|
"/api/v1/info/**",
|
||||||
"/api/v1/admin/**")
|
"/api/v1/admin/**")
|
||||||
.order(INTERCEPTOR_ORDER);
|
.order(INTERCEPTOR_ORDER);
|
||||||
|
|
||||||
registry.addInterceptor(entitlementGuard)
|
|
||||||
.addPathPatterns("/api/**")
|
|
||||||
.excludePathPatterns(
|
|
||||||
"/api/v1/credits/**",
|
|
||||||
"/api/v1/config/**",
|
|
||||||
"/api/v1/info/**",
|
|
||||||
"/api/v1/admin/**")
|
|
||||||
.order(ENTITLEMENT_GUARD_ORDER);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,20 +229,6 @@ public class JobService {
|
|||||||
return new JoinOrOpenResult(saved, JoinOrOpenResult.Disposition.JOINED);
|
return new JoinOrOpenResult(saved, JoinOrOpenResult.Disposition.JOINED);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a standalone process with no lineage inputs, for a billable action that isn't
|
|
||||||
* file/lineage-driven (e.g. an AI Create session). Because no input signatures are recorded,
|
|
||||||
* nothing downstream can lineage-join it — each such charge stands alone. {@code docUnits} is
|
|
||||||
* persisted so the charge service's shadow + ledger rows agree with the job.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public ProcessingJob open(JobContext ctx, int docUnits) {
|
|
||||||
Objects.requireNonNull(ctx, "ctx");
|
|
||||||
ProcessingJob job = openFresh(ctx, Map.of()).job();
|
|
||||||
job.setDocUnits(docUnits);
|
|
||||||
return jobRepository.save(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
private JoinOrOpenResult openFresh(
|
private JoinOrOpenResult openFresh(
|
||||||
JobContext ctx, Map<Path, Set<LineageSignature>> signaturesByInput) {
|
JobContext ctx, Map<Path, Set<LineageSignature>> signaturesByInput) {
|
||||||
ProcessingJob fresh = new ProcessingJob();
|
ProcessingJob fresh = new ProcessingJob();
|
||||||
|
|||||||
@@ -1,69 +1,34 @@
|
|||||||
package stirling.software.saas.payg.job;
|
package stirling.software.saas.payg.job;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.saas.payg.charge.JobChargeService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-closes {@code OPEN} jobs whose {@code last_step_at} is older than the workflow window. Runs
|
* Auto-closes {@code OPEN} jobs whose {@code last_step_at} is older than the workflow window. Runs
|
||||||
* every minute. API users never have to call {@code close()} explicitly — this scheduler is the
|
* every minute. API users never have to call {@code close()} explicitly — this scheduler is the
|
||||||
* safety net, and (for metered teams) the point at which the Stripe meter event is posted.
|
* safety net.
|
||||||
*
|
|
||||||
* <p>Each stale job is closed individually through {@link JobChargeService#close(java.util.UUID)}
|
|
||||||
* rather than {@code JobService.closeStale()} (a bulk status flip). That routing matters: {@code
|
|
||||||
* JobChargeService.close} registers the {@code afterCommit} hook that posts the billable usage to
|
|
||||||
* Stripe via {@code PaygMeterReportingService}. A bulk flip would close the rows but never meter
|
|
||||||
* them — usage would accrue in the wallet ledger yet never reach the customer's invoice.
|
|
||||||
*
|
|
||||||
* <p>Per-job transactions + failure isolation: each {@code chargeService.close(id)} runs in its own
|
|
||||||
* transaction (cross-bean proxied call from this non-transactional scheduled method), so the
|
|
||||||
* afterCommit meter POST fires once per job and one job's failure can't abort the rest of the
|
|
||||||
* sweep. The meter event's idempotency key ({@code process:<id>:close}) makes a re-run on the next
|
|
||||||
* tick safe even if a close half-completed.
|
|
||||||
*
|
*
|
||||||
* <p>Single-fire only at V1: not {@code @SchedulerLock}'d, consistent with the other
|
* <p>Single-fire only at V1: not {@code @SchedulerLock}'d, consistent with the other
|
||||||
* {@code @Scheduled} tasks in {@code :saas}. Multi-pod cluster-correctness for all schedulers is
|
* {@code @Scheduled} tasks in {@code :saas} (none of them are guarded against multi-pod
|
||||||
* tracked in design § 9 as a separate cleanup; the per-job close + meter idempotency key mean a
|
* double-fires today either). Multi-pod cluster-correctness for all schedulers is tracked in design
|
||||||
* double-fire across pods reads a shrinking stale set and never double-bills.
|
* § 9 as a separate cleanup. The underlying {@code closeStale()} call is idempotent — duplicate
|
||||||
|
* firings read an empty stale set on the second pod, no data corruption risk.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Profile("saas")
|
@Profile("saas")
|
||||||
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class StaleJobCloser {
|
public class StaleJobCloser {
|
||||||
|
|
||||||
private final JobService jobService;
|
private final JobService jobService;
|
||||||
private final JobChargeService chargeService;
|
|
||||||
|
|
||||||
public StaleJobCloser(JobService jobService, JobChargeService chargeService) {
|
|
||||||
this.jobService = jobService;
|
|
||||||
this.chargeService = chargeService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedRateString = "${payg.job.stale-close-interval-ms:60000}")
|
@Scheduled(fixedRateString = "${payg.job.stale-close-interval-ms:60000}")
|
||||||
public void closeStale() {
|
public void closeStale() {
|
||||||
List<ProcessingJob> stale = jobService.findStale();
|
int closed = jobService.closeStale();
|
||||||
if (stale.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int closed = 0;
|
|
||||||
for (ProcessingJob job : stale) {
|
|
||||||
try {
|
|
||||||
// Routes through the charge service so the afterCommit meter hook fires for
|
|
||||||
// metered teams. Idempotent: a job already closed by a racing tick no-ops.
|
|
||||||
chargeService.close(job.getId());
|
|
||||||
closed++;
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// Isolate per job — a single bad row (or a transient meter-path issue) must not
|
|
||||||
// strand the rest of the stale set open. Next tick retries.
|
|
||||||
log.warn("StaleJobCloser failed to close job {}: {}", job.getId(), e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (closed > 0) {
|
if (closed > 0) {
|
||||||
log.info("StaleJobCloser closed {} job(s) idle past the workflow window.", closed);
|
log.info("StaleJobCloser closed {} job(s) idle past the workflow window.", closed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package stirling.software.saas.payg.meter;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backend-side audit row for one Stripe meter-event POST attempt ({@code payg_meter_event_log},
|
|
||||||
* V15). A row is written <em>pending</em> ({@code posted_to_stripe_at} NULL) just before the POST
|
|
||||||
* and stamped on success; a failed POST leaves it unposted with the Stripe error captured. Rows
|
|
||||||
* still unposted after a short delay are retried by {@link PaygMeterReconcileScheduler} — this is
|
|
||||||
* the durability mechanism behind the fail-open meter path, so a Stripe blip never silently
|
|
||||||
* under-bills.
|
|
||||||
*
|
|
||||||
* <p>{@code idempotency_key} is UNIQUE and identical to the key sent to Stripe ({@code
|
|
||||||
* process:<jobId>:close}); the unique constraint gives safe at-least-once semantics across the dual
|
|
||||||
* meter triggers (completion + stale-close) and reconcile retries.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "payg_meter_event_log")
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class PaygMeterEventLog {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
@Column(name = "event_id")
|
|
||||||
private Long eventId;
|
|
||||||
|
|
||||||
@Column(name = "team_id", nullable = false)
|
|
||||||
private Long teamId;
|
|
||||||
|
|
||||||
@Column(name = "job_id")
|
|
||||||
private UUID jobId;
|
|
||||||
|
|
||||||
@Column(name = "idempotency_key", nullable = false, unique = true, length = 128)
|
|
||||||
private String idempotencyKey;
|
|
||||||
|
|
||||||
@Column(name = "units", nullable = false)
|
|
||||||
private Integer units;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert time; the DB column defaults to {@code CURRENT_TIMESTAMP} (set by {@code
|
|
||||||
* insertPending}).
|
|
||||||
*/
|
|
||||||
@Column(name = "occurred_at", nullable = false, insertable = false, updatable = false)
|
|
||||||
private LocalDateTime occurredAt;
|
|
||||||
|
|
||||||
/** NULL while pending; stamped when the meter-payg-units edge fn returns success. */
|
|
||||||
@Column(name = "posted_to_stripe_at")
|
|
||||||
private LocalDateTime postedToStripeAt;
|
|
||||||
|
|
||||||
@Column(name = "stripe_error_code", length = 64)
|
|
||||||
private String stripeErrorCode;
|
|
||||||
|
|
||||||
@Column(name = "stripe_error_body", columnDefinition = "text")
|
|
||||||
private String stripeErrorBody;
|
|
||||||
}
|
|
||||||
-139
@@ -1,139 +0,0 @@
|
|||||||
package stirling.software.saas.payg.meter;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import io.micrometer.core.instrument.Counter;
|
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
|
||||||
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
|
|
||||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retries PAYG meter events that were logged but never confirmed posted to Stripe — the durability
|
|
||||||
* half of the fail-open meter path. {@link PaygMeterReportingService} writes a pending {@code
|
|
||||||
* payg_meter_event_log} row before each POST and stamps it on success; anything left unposted
|
|
||||||
* (Stripe blip, pod crash between POST and stamp, edge-fn outage) is picked up here and re-sent
|
|
||||||
* under the <em>same</em> idempotency key, so Stripe dedups rather than double-charging.
|
|
||||||
*
|
|
||||||
* <p>Only retries rows inside Stripe's 24h idempotency window — past that a same-key retry is no
|
|
||||||
* longer guaranteed to dedup, so stuck rows are logged for manual reconciliation rather than
|
|
||||||
* risking a double charge. Skips teams that have since unsubscribed (nothing to bill). Like {@link
|
|
||||||
* stirling.software.saas.payg.lineage.LineagePruneScheduler} it is not {@code @SchedulerLock}'d: a
|
|
||||||
* duplicate firing on a multi-pod deploy re-sends the same keys, which dedup at Stripe —
|
|
||||||
* idempotent, wasted IO at worst.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@Profile("saas")
|
|
||||||
@Slf4j
|
|
||||||
public class PaygMeterReconcileScheduler {
|
|
||||||
|
|
||||||
/** Stripe's meter-event idempotency window — a same-key retry past this may double-charge. */
|
|
||||||
private static final Duration STRIPE_IDEMPOTENCY_WINDOW = Duration.ofHours(24);
|
|
||||||
|
|
||||||
private final PaygMeterEventLogRepository eventLogRepository;
|
|
||||||
private final PaygTeamExtensionsRepository teamExtensionsRepository;
|
|
||||||
private final PaygMeterReportingService meterReportingService;
|
|
||||||
private final boolean enabled;
|
|
||||||
private final Duration retryDelay;
|
|
||||||
private final int batchSize;
|
|
||||||
private final Counter retriedCounter;
|
|
||||||
|
|
||||||
public PaygMeterReconcileScheduler(
|
|
||||||
PaygMeterEventLogRepository eventLogRepository,
|
|
||||||
PaygTeamExtensionsRepository teamExtensionsRepository,
|
|
||||||
PaygMeterReportingService meterReportingService,
|
|
||||||
@Value("${payg.meter.reconcile.enabled:true}") boolean enabled,
|
|
||||||
@Value("${payg.meter.reconcile.retry-delay:PT5M}") Duration retryDelay,
|
|
||||||
@Value("${payg.meter.reconcile.batch-size:100}") int batchSize,
|
|
||||||
MeterRegistry meterRegistry) {
|
|
||||||
this.eventLogRepository = Objects.requireNonNull(eventLogRepository, "eventLogRepository");
|
|
||||||
this.teamExtensionsRepository =
|
|
||||||
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
|
|
||||||
this.meterReportingService =
|
|
||||||
Objects.requireNonNull(meterReportingService, "meterReportingService");
|
|
||||||
this.enabled = enabled;
|
|
||||||
this.retryDelay = Objects.requireNonNull(retryDelay, "retryDelay");
|
|
||||||
this.batchSize = batchSize > 0 ? batchSize : 100;
|
|
||||||
this.retriedCounter =
|
|
||||||
Counter.builder("payg.meter.reconcile.retried")
|
|
||||||
.description("PAYG meter events re-posted to Stripe by the reconcile job")
|
|
||||||
.register(meterRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(cron = "${payg.meter.reconcile-cron:0 */15 * * * *}", zone = "UTC")
|
|
||||||
public void reconcile() {
|
|
||||||
if (!enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
// Give the live POST a moment to land before retrying; stay inside the 24h dedup window.
|
|
||||||
LocalDateTime cutoff = now.minus(retryDelay);
|
|
||||||
LocalDateTime floor = now.minus(STRIPE_IDEMPOTENCY_WINDOW);
|
|
||||||
|
|
||||||
List<PaygMeterEventLog> retryable =
|
|
||||||
eventLogRepository.findRetryable(cutoff, floor, PageRequest.of(0, batchSize));
|
|
||||||
|
|
||||||
// Batch-fetch this page's team extensions in one query (keyed by team id) rather than a
|
|
||||||
// findById per row — avoids an N+1 when the page spans several teams.
|
|
||||||
List<Long> teamIds =
|
|
||||||
retryable.stream().map(PaygMeterEventLog::getTeamId).distinct().toList();
|
|
||||||
Map<Long, PaygTeamExtensions> extById =
|
|
||||||
teamExtensionsRepository.findAllById(teamIds).stream()
|
|
||||||
.collect(Collectors.toMap(PaygTeamExtensions::getTeamId, ext -> ext));
|
|
||||||
|
|
||||||
int retried = 0;
|
|
||||||
for (PaygMeterEventLog row : retryable) {
|
|
||||||
PaygTeamExtensions ext = extById.get(row.getTeamId());
|
|
||||||
if (ext == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String subscriptionId = ext.getPaygSubscriptionId();
|
|
||||||
String stripeCustomerId = ext.getStripeCustomerId();
|
|
||||||
if (subscriptionId == null
|
|
||||||
|| subscriptionId.isBlank()
|
|
||||||
|| stripeCustomerId == null
|
|
||||||
|| stripeCustomerId.isBlank()) {
|
|
||||||
// Team unsubscribed since the event was logged — nothing to bill; leave the row.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Same idempotency key → Stripe dedups if the original actually landed. recordUsage
|
|
||||||
// re-inserts pending as a no-op, re-POSTs, and stamps the row on success. Category is
|
|
||||||
// not re-derived (analytics metadata only); units + key are what bill.
|
|
||||||
meterReportingService.recordUsage(
|
|
||||||
row.getTeamId(),
|
|
||||||
stripeCustomerId,
|
|
||||||
row.getUnits() == null ? 0 : row.getUnits(),
|
|
||||||
null,
|
|
||||||
row.getIdempotencyKey(),
|
|
||||||
row.getJobId());
|
|
||||||
retried++;
|
|
||||||
}
|
|
||||||
if (retried > 0) {
|
|
||||||
retriedCounter.increment(retried);
|
|
||||||
log.info("PaygMeterReconcileScheduler retried {} unposted meter event(s).", retried);
|
|
||||||
}
|
|
||||||
|
|
||||||
long stuck = eventLogRepository.countStuck(floor);
|
|
||||||
if (stuck > 0) {
|
|
||||||
log.warn(
|
|
||||||
"{} PAYG meter event(s) stuck unposted past Stripe's {}h idempotency window —"
|
|
||||||
+ " manual reconciliation needed.",
|
|
||||||
stuck,
|
|
||||||
STRIPE_IDEMPOTENCY_WINDOW.toHours());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-221
@@ -1,221 +0,0 @@
|
|||||||
package stirling.software.saas.payg.meter;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.http.HttpEntity;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpMethod;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
import io.micrometer.core.instrument.Counter;
|
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.saas.payg.model.BillingCategory;
|
|
||||||
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POSTs PAYG billable usage to the Supabase {@code meter-payg-units} edge function. Called from
|
|
||||||
* {@code JobChargeService.close()} in an {@code afterCommit} hook, so the wallet ledger DEBIT (the
|
|
||||||
* customer's authoritative bill) is already durable before we tell Stripe about it.
|
|
||||||
*
|
|
||||||
* <p>Stripe is on a single flat-priced meter forever. {@link BillingCategory} ships as metadata for
|
|
||||||
* analytics — pricing never reads it. Free-tier teams (no Stripe subscription) skip this call
|
|
||||||
* entirely; the ledger entry is the only record needed.
|
|
||||||
*
|
|
||||||
* <p>Failure mode: we owe Stripe an event but the customer's bill via the ledger is correct. Log
|
|
||||||
* WARN, bump {@code payg.meter.errors}, and swallow — durability comes from the {@code
|
|
||||||
* payg_meter_event_log} row written around every attempt (pending → posted/failed) and {@link
|
|
||||||
* PaygMeterReconcileScheduler}, which retries unposted rows, not from retries here. Caller's {@code
|
|
||||||
* close()} must not roll back because Stripe wobbled.
|
|
||||||
*
|
|
||||||
* <p>Both config keys default to empty so unit tests / local dev never crash on missing env. When
|
|
||||||
* blank, this service no-ops at WARN-debug level — useful for SaaS smoke tests that don't want to
|
|
||||||
* touch the real edge function.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@Profile("saas")
|
|
||||||
@Slf4j
|
|
||||||
public class PaygMeterReportingService {
|
|
||||||
|
|
||||||
private final String endpoint;
|
|
||||||
private final String authToken;
|
|
||||||
private final RestTemplate restTemplate;
|
|
||||||
private final PaygMeterEventLogRepository eventLogRepository;
|
|
||||||
private final Counter errorsCounter;
|
|
||||||
|
|
||||||
/** Stripe error bodies can be large; the column is TEXT but we cap to keep rows sane. */
|
|
||||||
private static final int MAX_ERROR_BODY = 4000;
|
|
||||||
|
|
||||||
public PaygMeterReportingService(
|
|
||||||
@Value("${payg.meter.endpoint:}") String endpoint,
|
|
||||||
@Value("${payg.meter.auth-token:}") String authToken,
|
|
||||||
RestTemplate saasRestTemplate,
|
|
||||||
PaygMeterEventLogRepository eventLogRepository,
|
|
||||||
MeterRegistry meterRegistry) {
|
|
||||||
this.endpoint = endpoint;
|
|
||||||
this.authToken = authToken;
|
|
||||||
this.restTemplate = saasRestTemplate;
|
|
||||||
this.eventLogRepository = eventLogRepository;
|
|
||||||
this.errorsCounter =
|
|
||||||
Counter.builder("payg.meter.errors")
|
|
||||||
.description("Failures POSTing PAYG meter events to Supabase edge function")
|
|
||||||
.register(meterRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort POST of a single billable event, wrapped in a durable audit row. Idempotency on
|
|
||||||
* the Supabase side is keyed on {@code idempotency_key} — supply a deterministic value (e.g.
|
|
||||||
* {@code "process:<uuid>:close"}) so a retry, a reconcile replay, or a double-fire from two
|
|
||||||
* pods never charges twice.
|
|
||||||
*
|
|
||||||
* <p>Flow: write a pending {@code payg_meter_event_log} row (idempotent), POST to the edge fn,
|
|
||||||
* then stamp the row posted or record the Stripe error. The row is what {@link
|
|
||||||
* PaygMeterReconcileScheduler} retries, so a failed POST is recoverable rather than silently
|
|
||||||
* dropped.
|
|
||||||
*
|
|
||||||
* <p>Never throws. The wallet ledger entry is the source of truth for what the customer is
|
|
||||||
* billed; if the POST fails the only loss is that Stripe doesn't see this event until reconcile
|
|
||||||
* retries it.
|
|
||||||
*/
|
|
||||||
public void recordUsage(
|
|
||||||
Long teamId,
|
|
||||||
String stripeCustomerId,
|
|
||||||
int units,
|
|
||||||
BillingCategory category,
|
|
||||||
String idempotencyKey,
|
|
||||||
UUID jobId) {
|
|
||||||
if (endpoint == null || endpoint.isBlank()) {
|
|
||||||
log.debug(
|
|
||||||
"payg.meter.endpoint not configured; skipping meter event for team {} key {}",
|
|
||||||
teamId,
|
|
||||||
idempotencyKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (units <= 0) {
|
|
||||||
// Zero-unit events would inflate event count without changing the bill — defensive.
|
|
||||||
log.debug(
|
|
||||||
"Skipping meter event with units={} for team {} key {}",
|
|
||||||
units,
|
|
||||||
teamId,
|
|
||||||
idempotencyKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Durable pending row before the POST so a failure leaves a record the reconcile scheduler
|
|
||||||
// can retry. Idempotent insert (ON CONFLICT DO NOTHING) — the completion + stale-close
|
|
||||||
// triggers and reconcile retries all share the key. Best-effort: a logging failure must
|
|
||||||
// never stop us from actually metering.
|
|
||||||
try {
|
|
||||||
eventLogRepository.insertPending(teamId, jobId, idempotencyKey, units);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn(
|
|
||||||
"payg_meter_event_log pending insert failed for key {} (still metering): {}",
|
|
||||||
idempotencyKey,
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
PostOutcome outcome =
|
|
||||||
postToStripe(teamId, stripeCustomerId, units, category, idempotencyKey);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (outcome.success()) {
|
|
||||||
eventLogRepository.markPosted(idempotencyKey);
|
|
||||||
} else {
|
|
||||||
eventLogRepository.markFailed(
|
|
||||||
idempotencyKey, outcome.errorCode(), outcome.errorBody());
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn(
|
|
||||||
"payg_meter_event_log result update failed for key {}: {}",
|
|
||||||
idempotencyKey,
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST the event to the edge fn. Never throws; returns success / the captured Stripe error.
|
|
||||||
* Increments {@code payg.meter.errors} on any non-2xx or exception (unchanged metric contract).
|
|
||||||
*/
|
|
||||||
private PostOutcome postToStripe(
|
|
||||||
Long teamId,
|
|
||||||
String stripeCustomerId,
|
|
||||||
int units,
|
|
||||||
BillingCategory category,
|
|
||||||
String idempotencyKey) {
|
|
||||||
try {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
if (authToken != null && !authToken.isBlank()) {
|
|
||||||
headers.setBearerAuth(authToken);
|
|
||||||
}
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
|
|
||||||
Map<String, Object> body =
|
|
||||||
Map.of(
|
|
||||||
"team_id",
|
|
||||||
// JSON number — the edge fn type-checks and ignores strings.
|
|
||||||
teamId == null ? -1L : teamId,
|
|
||||||
"stripe_customer_id",
|
|
||||||
stripeCustomerId == null ? "" : stripeCustomerId,
|
|
||||||
"units",
|
|
||||||
units,
|
|
||||||
"idempotency_key",
|
|
||||||
idempotencyKey,
|
|
||||||
"metadata",
|
|
||||||
Map.of("category", category == null ? "UNKNOWN" : category.name()));
|
|
||||||
|
|
||||||
ResponseEntity<String> response =
|
|
||||||
restTemplate.exchange(
|
|
||||||
endpoint,
|
|
||||||
HttpMethod.POST,
|
|
||||||
new HttpEntity<>(body, headers),
|
|
||||||
String.class);
|
|
||||||
|
|
||||||
if (response.getStatusCode().is2xxSuccessful()) {
|
|
||||||
return PostOutcome.ok();
|
|
||||||
}
|
|
||||||
log.warn(
|
|
||||||
"Meter event POST returned {} for team {} key {}: {}",
|
|
||||||
response.getStatusCode(),
|
|
||||||
teamId,
|
|
||||||
idempotencyKey,
|
|
||||||
response.getBody());
|
|
||||||
errorsCounter.increment();
|
|
||||||
return PostOutcome.error(
|
|
||||||
String.valueOf(response.getStatusCode().value()), response.getBody());
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Catch-all by design: this method MUST NOT propagate. The customer's bill via the
|
|
||||||
// ledger is correct; we just owe Stripe an event the reconcile scheduler will retry.
|
|
||||||
log.warn(
|
|
||||||
"Meter event POST failed for team {} key {}: {}",
|
|
||||||
teamId,
|
|
||||||
idempotencyKey,
|
|
||||||
e.getMessage());
|
|
||||||
errorsCounter.increment();
|
|
||||||
return PostOutcome.error("exception", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Outcome of one edge-fn POST attempt. */
|
|
||||||
private record PostOutcome(boolean success, String errorCode, String errorBody) {
|
|
||||||
static PostOutcome ok() {
|
|
||||||
return new PostOutcome(true, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static PostOutcome error(String code, String body) {
|
|
||||||
String trimmedCode = code != null && code.length() > 64 ? code.substring(0, 64) : code;
|
|
||||||
String trimmedBody =
|
|
||||||
body != null && body.length() > MAX_ERROR_BODY
|
|
||||||
? body.substring(0, MAX_ERROR_BODY)
|
|
||||||
: body;
|
|
||||||
return new PostOutcome(false, trimmedCode, trimmedBody);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package stirling.software.saas.payg.model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analytics / in-app breakdown axis stamped on every billable ledger entry and shadow charge. PAYG
|
|
||||||
* stays on a single flat-priced Stripe meter — category is metadata only, never affects pricing.
|
|
||||||
*
|
|
||||||
* <p>Precedence at translation time (interceptor): AUTOMATION → AI → API → BYPASSED. {@link
|
|
||||||
* #BYPASSED} is the default for manual UI tool calls that never hit a billable code path.
|
|
||||||
*
|
|
||||||
* <p>Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream
|
|
||||||
* relies on {@code ordinal()}.
|
|
||||||
*/
|
|
||||||
public enum BillingCategory {
|
|
||||||
BYPASSED,
|
|
||||||
API,
|
|
||||||
AI,
|
|
||||||
AUTOMATION
|
|
||||||
}
|
|
||||||
@@ -71,16 +71,6 @@ public class PaygTeamExtensions implements Serializable {
|
|||||||
@Column(name = "payg_subscription_id", unique = true, length = 128)
|
@Column(name = "payg_subscription_id", unique = true, length = 128)
|
||||||
private String paygSubscriptionId;
|
private String paygSubscriptionId;
|
||||||
|
|
||||||
/**
|
|
||||||
* Remaining one-time free documents for this team (the lifetime grant). Seeded from the
|
|
||||||
* effective pricing policy's {@code free_tier_units} when this row is created (V14 trigger,
|
|
||||||
* updated in V19); decremented by the charge pipeline when a billable charge is written and
|
|
||||||
* restored on a first-step refund. Never replenishes; survives subscribing. This counter — not
|
|
||||||
* the wallet ledger — is the source of truth for the grant, so old ledger rows can be pruned.
|
|
||||||
*/
|
|
||||||
@Column(name = "free_units_remaining", nullable = false)
|
|
||||||
private Long freeUnitsRemaining = 0L;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(name = "created_at", updatable = false)
|
@Column(name = "created_at", updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -74,14 +74,18 @@ public class PricingPolicy implements Serializable {
|
|||||||
private Integer fileUnitCap = 1000;
|
private Integer fileUnitCap = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time lifetime free document grant handed to a team on creation. {@code 0} (default) means
|
* Free-tier allowance — doc units a team on this policy can consume per cycle before they must
|
||||||
* no free grant. NOT per-cycle: it never replenishes and a team keeps any unused portion after
|
* add a card. {@code 0} (default) means no free tier; the team is blocked at 402 from their
|
||||||
* subscribing. The value is copied into {@code payg_team_extensions.free_units_remaining} when
|
* very first tool call until they pay. New-signup defaults will set this to a sensible positive
|
||||||
* the team's sidecar row is created (V14 trigger, updated in V19); from then on the per-team
|
* value; the special launch policy used by the day-1 legacy migration script can override with
|
||||||
* counter is authoritative and this column is only the seed for new teams.
|
* a different size if product wants the original 10 customers to feel special.
|
||||||
|
*
|
||||||
|
* <p>Enforced by {@code PaygTeamUsageService} (PR-SB-4) on every tool call, gated on {@code
|
||||||
|
* payg_team_extensions.payg_subscription_id IS NULL} — teams with an active subscription bypass
|
||||||
|
* this check entirely.
|
||||||
*/
|
*/
|
||||||
@Column(name = "free_tier_units", nullable = false)
|
@Column(name = "free_tier_units_per_cycle", nullable = false)
|
||||||
private Long freeTierUnits = 0L;
|
private Long freeTierUnitsPerCycle = 0L;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
|
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user