Merge branch 'main' into feature/documentClassifierAgent

This commit is contained in:
EthanHealy01
2026-06-29 22:10:52 +01:00
committed by GitHub
314 changed files with 16942 additions and 4654 deletions
@@ -64,11 +64,19 @@ jobs:
gradle-version: 9.6.0
cache-disabled: true
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
# container builder can't see that store, so skip it here and let
# `docker buildx build` fall back to the default docker driver, which
# resolves the local base. The gha cache backend is also skipped (its
# runtime token isn't exposed) since the docker driver can't use it.
- name: Set up Docker Buildx
if: inputs.docker-base-changed != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
if: inputs.docker-base-changed != 'true'
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
- name: Install Docker Compose
+6
View File
@@ -41,6 +41,11 @@ jobs:
- name: Install all Playwright browsers
run: task e2e:install
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run E2E tests (all browsers)
run: task e2e:cross-browser
@@ -62,4 +67,5 @@ jobs:
uses: ./.github/workflows/tauri-build.yml
with:
platform: all
sign: false
secrets: inherit
+44 -13
View File
@@ -16,6 +16,11 @@ on:
required: false
type: string
default: "all"
sign:
description: "Sign and notarize the bundles."
required: false
type: boolean
default: true
workflow_dispatch:
inputs:
platform:
@@ -28,6 +33,11 @@ on:
- windows
- macos
- linux
sign:
description: "Sign and notarize the bundles."
required: false
default: true
type: boolean
permissions:
contents: read
@@ -177,7 +187,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -187,7 +197,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -222,7 +232,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -253,7 +263,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -274,7 +284,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -297,7 +307,7 @@ jobs:
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Preflight smctl
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -310,7 +320,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
- name: Configure Windows code signing
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -329,7 +339,7 @@ jobs:
EOF
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
@@ -346,7 +356,8 @@ jobs:
exit 1
fi
- name: Build Tauri app
- name: Build Tauri app (signed)
if: inputs.sign
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -380,6 +391,26 @@ jobs:
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
- name: Build Tauri app (Linux AppImage)
@@ -388,7 +419,7 @@ jobs:
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -403,7 +434,7 @@ jobs:
args: --bundles appimage
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
@@ -413,7 +444,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15'
if: inputs.sign && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
@@ -451,7 +482,7 @@ jobs:
# Verify the MSI AND the inner exe extracted from it are signed.
# The inner exe is what gets installed on users' machines and what AV scans.
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
$allSigned = $true
+16 -1
View File
@@ -155,6 +155,19 @@ jobs:
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
fi
# Base-changed PRs build the embedded image with the local docker driver
# so the locally-built stirling-pdf-base:pr-test (in the daemon image
# store) resolves. A buildx container builder cannot see it and would try
# to pull it from a registry, which fails. Single-platform, no gha cache.
- name: Build ${{ matrix.docker-rev }} against local base (PR base change)
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
run: |
DOCKER_BUILDKIT=1 docker build \
--build-arg BASE_IMAGE=${{ steps.build-params.outputs.base_image }} \
--file ./${{ matrix.docker-rev }} \
--tag stirling-pdf-embedded:pr-test \
.
- name: Build ${{ matrix.docker-rev }} (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
@@ -169,8 +182,10 @@ jobs:
provenance: true
sbom: true
# Fork PRs that did NOT change the base use the buildx container builder
# (multi-platform + gha cache) against the published base image.
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
if: env.USE_DEPOT != 'true'
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
+8 -2
View File
@@ -26,9 +26,14 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`. Local overrides in
# .env.proprietary.local win over the committed .env.proprietary defaults.
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
@@ -36,12 +41,13 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
+39 -10
View File
@@ -305,17 +305,26 @@ tasks:
cmds:
- task: typecheck:proprietary
typecheck:_run:
internal: true
env:
CI: '{{ .CI | default "false" }}'
cmds:
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
typecheck:core:
desc: "Typecheck core build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/core/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/core/tsconfig.json }
typecheck:proprietary:
desc: "Typecheck proprietary build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/proprietary/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
typecheck:saas:
desc: "Typecheck SaaS build variant"
@@ -323,7 +332,8 @@ tasks:
- task: prepare
vars: { MODE: saas }
cmds:
- npx tsc --noEmit --project editor/src/saas/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/saas/tsconfig.json }
typecheck:desktop:
desc: "Typecheck desktop build variant"
@@ -331,38 +341,45 @@ tasks:
- task: prepare
vars: { MODE: desktop }
cmds:
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/desktop/tsconfig.json }
typecheck:cloud:
desc: "Typecheck cloud shared layer (standalone)"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/cloud/tsconfig.json }
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- npx tsc --noEmit --project scripts/tsconfig.json
- npx tsc --noEmit --project editor/scripts/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: scripts/tsconfig.json }
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
typecheck:prototypes:
desc: "Typecheck prototypes build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [install]
cmds:
- npx tsc --noEmit --project portal/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: portal/tsconfig.json }
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- npx tsc --noEmit --project shared/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: shared/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -425,10 +442,22 @@ tasks:
test:
desc: "Run tests"
cmds:
- task: test:editor
- task: test:portal
test:editor:
desc: "Run editor tests"
deps: [prepare]
cmds:
- npx vitest run --root editor
test:portal:
desc: "Run portal tests"
deps: [prepare]
cmds:
- npx vitest run --root portal
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
+4
View File
@@ -90,6 +90,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
@@ -109,6 +110,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
@@ -135,6 +137,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:proprietary
vars:
PORT: '{{.EDITOR_PORT}}'
@@ -210,6 +213,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
+8
View File
@@ -0,0 +1,8 @@
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
# Flip to true in app/.env.proprietary.local to test linking locally.
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
# SaaS base URL the linked instance calls (register + entitlement).
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
+1
View File
@@ -1,3 +1,4 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
!.env.proprietary
@@ -206,6 +206,11 @@ public class ApplicationProperties {
@Data
public static class Policies {
/**
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
*/
private boolean enabled = false;
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
@@ -590,7 +595,7 @@ public class ApplicationProperties {
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = false;
private Boolean autoCreateUser = true;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@@ -667,7 +672,7 @@ public class ApplicationProperties {
private String issuer;
private String clientId;
@ToString.Exclude private String clientSecret;
private Boolean autoCreateUser = false;
private Boolean autoCreateUser = true;
private Boolean blockRegistration = false;
private String useAsUsername;
private Collection<String> scopes = new ArrayList<>();
@@ -738,7 +743,6 @@ public class ApplicationProperties {
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
/**
@@ -842,8 +846,8 @@ public class ApplicationProperties {
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useSystemTrust = true;
private boolean useMozillaBundle = true;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@@ -886,10 +890,10 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Boolean enableDesktopInstallSlide = true;
private Datasource datasource;
private boolean disableSanitize;
private int maxDPI;
private int maxDPI = 500;
private boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
@@ -903,8 +907,9 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
@Data
public static class MobileScannerSettings {
@@ -914,6 +919,16 @@ public class ApplicationProperties {
private boolean stretchToFit = false; // Whether to stretch image to fill page
}
@Data
public static class ServerCertificate {
private boolean enabled =
true; // Enable server-side "Sign with Stirling-PDF" certificate
private String organizationName = "Stirling PDF Inc";
private int validity = 365; // Certificate validity in days
private boolean regenerateOnStartup =
false; // Generate a new certificate on each startup
}
public boolean isAnalyticsEnabled() {
return this.enableAnalytics != null && this.enableAnalytics;
}
@@ -998,7 +1013,7 @@ public class ApplicationProperties {
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = false;
private boolean linkEnabled = true;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@@ -1172,7 +1187,7 @@ public class ApplicationProperties {
@Data
public static class Metrics {
private boolean enabled;
private boolean enabled = true;
}
@Data
@@ -1224,7 +1239,7 @@ public class ApplicationProperties {
private boolean enableInvites = false;
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
private String host;
private int port;
private int port = 587;
private String username;
@ToString.Exclude private String password;
private String from;
@@ -1251,10 +1266,10 @@ public class ApplicationProperties {
@ToString.Exclude private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private Boolean customFolderSuffix = false;
private Boolean enableAllowUserIDs = false;
private Boolean customFolderSuffix = true;
private Boolean enableAllowUserIDs = true;
private List<Long> allowUserIDs = new ArrayList<>();
private Boolean enableAllowChannelIDs = false;
private Boolean enableAllowChannelIDs = true;
private List<Long> allowChannelIDs = new ArrayList<>();
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
@@ -85,7 +85,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
"/pdfjs-legacy/**",
@@ -0,0 +1,30 @@
package stirling.software.SPDF.controller.web;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.common.model.ApplicationProperties;
/**
* Serves /robots.txt dynamically so the system.googlevisibility flag actually controls
* search-engine indexing. 'true' returns an allow-all policy; 'false' returns a disallow-all policy
* to keep the instance out of search engines (useful for embedded/internal deployments).
*/
@RestController
public class RobotsController {
private final ApplicationProperties applicationProperties;
public RobotsController(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
public String robotsTxt() {
boolean allowIndexing = applicationProperties.getSystem().isGooglevisibility();
return "User-agent: *\n" + (allowIndexing ? "Allow: /\n" : "Disallow: /\n");
}
}
@@ -62,8 +62,6 @@ security:
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
@@ -141,10 +139,10 @@ telegram:
botUsername: "" # Telegram bot username (without @)
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs. NOTE: only takes effect when allowUserIDs is non-empty; with an empty list every user is still allowed even when this is 'true'
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users (the enableAllowUserIDs toggle has no effect until this list is populated).
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs. NOTE: only takes effect when allowChannelIDs is non-empty; with an empty list every channel is still allowed even when this is 'true'
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels (the enableAllowChannelIDs toggle has no effect until this list is populated).
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
feedback:
@@ -172,7 +170,7 @@ legal:
system:
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
googlevisibility: false # 'true' serves an allow-all /robots.txt; 'false' serves a disallow-all /robots.txt to keep the instance out of search engines
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: true # see when a new update is available
showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
@@ -186,7 +184,7 @@ system:
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
@@ -197,7 +195,7 @@ system:
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
organizationName: Stirling PDF Inc # Organization name for generated certificates
validity: 365 # Certificate validity in days
regenerateOnStartup: false # Generate new certificate on each startup
html:
@@ -304,7 +302,7 @@ autoPipeline:
allowedExtensions: [] # Optional extension allow-list (case-insensitive, without the leading dot). Empty list = accept all extensions. Example: ["pdf", "tiff"]
ui:
appNameNavbar: "" # name displayed on the navigation bar
appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF"
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
@@ -89,9 +89,13 @@ class CertificateValidationServiceMoreTest {
}
private static ApplicationProperties defaultProps() {
// Real POJO defaults: trust all off, revocation "none".
// Test baseline: every trust source explicitly off so each test enables only what it
// exercises (the shipped POJO defaults now enable system + Mozilla trust).
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().getValidation().getTrust().setServerAsAnchor(false);
var trust = props.getSecurity().getValidation().getTrust();
trust.setServerAsAnchor(false);
trust.setUseSystemTrust(false);
trust.setUseMozillaBundle(false);
return props;
}
@@ -0,0 +1,265 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Two calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
* endpoint.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkClient {
static final String HEADER_DEVICE_ID = "X-Device-Id";
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
private final AccountLinkProperties properties;
private final ObjectMapper mapper;
private final HttpClient httpClient;
@Autowired
public AccountLinkClient(AccountLinkProperties properties, ObjectMapper mapper) {
this(
properties,
mapper,
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(properties.getRequestTimeoutSeconds()))
.build());
}
/** Package-private: lets tests inject a stub {@link HttpClient}. */
AccountLinkClient(
AccountLinkProperties properties, ObjectMapper mapper, HttpClient httpClient) {
this.properties = properties;
this.mapper = mapper;
this.httpClient = httpClient;
}
/** The device credential a successful {@link #register} returns. */
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
/**
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
* map auth failures (401/403) through rather than masking everything as a 502.
*/
public static class UpstreamException extends IOException {
private final int status;
public UpstreamException(int status, String body) {
super("SaaS account-link returned HTTP " + status + ": " + body);
this.status = status;
}
public int status() {
return status;
}
}
/**
* Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
* try/catch.
*/
public static final class RevokedException extends RuntimeException {
private final int status;
public RevokedException(int status) {
super("SaaS entitlement denied (credential revoked/invalid): HTTP " + status);
this.status = status;
}
public int status() {
return status;
}
}
/**
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
* credential.
*
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
* admin).
*/
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
String body =
instanceName == null || instanceName.isBlank()
? "{}"
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/register"))
.header("Authorization", "Bearer " + supabaseJwt)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = send(request);
if (response.statusCode() / 100 != 2) {
throw new UpstreamException(response.statusCode(), response.body());
}
JsonNode root = mapper.readTree(response.body());
String deviceId = text(root, "deviceId");
String deviceSecret = text(root, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
throw new IOException("SaaS register response missing deviceId/deviceSecret");
}
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
return new RegisterResult(deviceId, deviceSecret, teamId);
}
/**
* Revokes this instance's own credential on the SaaS side ({@code POST
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/revoke-self"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = send(request);
if (response.statusCode() / 100 != 2) {
log.debug("Self-revoke returned HTTP {}", response.statusCode());
return false;
}
return true;
} catch (Exception e) {
log.debug("Self-revoke failed: {}", e.getMessage());
return false;
}
}
/**
* Fetches the current entitlement using the stored device credential. Three outcomes:
*
* <ul>
* <li>2xx → the parsed snapshot.
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
* the caller must BLOCK, not fail open.
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
* ("unknown" — the caller fails open).
* </ul>
*/
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
HttpResponse<String> response;
try {
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/entitlement"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Accept", "application/json")
.timeout(timeout())
.GET()
.build();
response = send(request);
} catch (Exception e) {
// Transport failure (timeout / connection refused / interrupted) → unknown, fail open.
log.debug("Entitlement fetch failed: {}", e.getMessage());
return null;
}
int status = response.statusCode();
if (status == 401 || status == 403) {
// Authoritative deny — the SaaS side rejected the credential (revoked/invalid).
throw new RevokedException(status);
}
if (status / 100 != 2) {
// Server / transient error → unknown, fail open (do NOT treat as a deny).
log.debug("Entitlement fetch returned HTTP {}", status);
return null;
}
try {
return parseEntitlement(response.body());
} catch (IOException e) {
log.debug("Entitlement parse failed: {}", e.getMessage());
return null;
}
}
private InstanceEntitlement parseEntitlement(String body) throws IOException {
JsonNode root = mapper.readTree(body);
boolean subscribed = root.path("subscribed").asBoolean(false);
long freeRemaining = root.path("freeRemainingUnits").asLong(0);
long periodSpend = root.path("periodSpendUnits").asLong(0);
Long periodCap =
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
EntitlementState state = mapState(root.path("state").asText(null));
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
}
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
private static EntitlementState mapState(String raw) {
if (raw == null) {
return EntitlementState.UNKNOWN;
}
return switch (raw) {
case "OK", "ACTIVE", "SUBSCRIBED", "FREE" -> EntitlementState.OK;
case "OVER_LIMIT", "PAYG_LIMIT_REACHED", "BLOCKED" -> EntitlementState.OVER_LIMIT;
default -> EntitlementState.UNKNOWN;
};
}
private HttpResponse<String> send(HttpRequest request) throws IOException {
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted calling SaaS account-link", e);
}
}
private URI uri(String path) {
String base = properties.getSaasBaseUrl().strip().replaceAll("/+$", "");
return URI.create(base + path);
}
private Duration timeout() {
return Duration.ofSeconds(properties.getRequestTimeoutSeconds());
}
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.get(field).asText() : null;
}
}
@@ -0,0 +1,88 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
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 lombok.extern.slf4j.Slf4j;
/**
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
*
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card.
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/account-link")
@Profile("!saas")
@PreAuthorize("hasRole('ADMIN')")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkController {
private final AccountLinkService service;
public AccountLinkController(AccountLinkService service) {
this.service = service;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
public record LinkRequest(String supabaseJwt, String name) {}
@PostMapping("/link")
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
return ResponseEntity.badRequest()
.body(java.util.Map.of("error", "supabaseJwt is required"));
}
try {
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
} catch (AccountLinkClient.UpstreamException e) {
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
// the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
// raw upstream body back to the browser.
HttpStatus status =
e.status() == HttpStatus.UNAUTHORIZED.value()
|| e.status() == HttpStatus.FORBIDDEN.value()
? HttpStatus.valueOf(e.status())
: HttpStatus.BAD_GATEWAY;
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
} catch (IOException e) {
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
// configured SaaS host/IP. Log it server-side; return the same opaque body the
// UpstreamException branch does.
log.warn("Account-link failed (transport): {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "LINK_FAILED"));
}
}
@GetMapping("/status")
public ResponseEntity<AccountLinkService.LinkStatus> status() {
return ResponseEntity.ok(service.status());
}
@PostMapping("/unlink")
public ResponseEntity<Void> unlink() {
service.unlink();
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,39 @@
package stirling.software.proprietary.accountlink;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/**
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
*
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "stirling.billing.account-link")
public class AccountLinkProperties {
/** Master switch. When {@code false} (default) the feature is fully inert. */
private boolean enabled = false;
/**
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
*
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
*/
private String saasBaseUrl = "https://stirling.com/app";
/** Cached entitlement is reused for this long before a refresh is attempted. */
private long entitlementCacheSeconds = 300;
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
}
@@ -0,0 +1,92 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
*
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
* The credential — not the JWT — authenticates all later unattended entitlement calls.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkService {
private final AccountLinkClient client;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
public AccountLinkService(
AccountLinkClient client,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache) {
this.client = client;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
}
/** Status of this instance's link, for the portal's "Account link" card. */
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
/**
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
* credential.
*
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
*/
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
entitlementCache.invalidate();
log.info("Account-link: instance linked to team {}", result.teamId());
return status();
}
/**
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
*/
public void unlink() {
credentialStore
.get()
.ifPresent(
c -> {
boolean ok = client.revokeSelf(c.getDeviceId(), c.getDeviceSecret());
if (!ok) {
log.warn(
"Account-link: SaaS self-revoke failed for device {};"
+ " clearing locally anyway (admin can revoke"
+ " from the portal).",
c.getDeviceId());
}
});
credentialStore.clear();
entitlementCache.invalidate();
log.info("Account-link: instance unlinked");
}
public LinkStatus status() {
Optional<DeviceCredential> cred = credentialStore.get();
return cred.map(
c ->
new LinkStatus(
true,
c.getDeviceId(),
c.getTeamId(),
c.getLinkedAt() != null
? c.getLinkedAt().toString()
: null))
.orElseGet(() -> new LinkStatus(false, null, null, null));
}
}
@@ -0,0 +1,36 @@
package stirling.software.proprietary.accountlink;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Registers the account-link entitlement gate. Path patterns cover the billable API surface; the
* interceptor itself re-checks billability (and short-circuits manual tools), but scoping here
* keeps the gate off the bulk of interactive endpoints entirely.
*
* <p>Whole config is gated behind {@code stirling.billing.account-link.enabled} +
* {@code @Profile("!saas")}; absent when off, so no interceptor is registered.
*/
@Configuration
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkWebMvcConfig implements WebMvcConfigurer {
private final InstanceEntitlementInterceptor gateInterceptor;
public AccountLinkWebMvcConfig(InstanceEntitlementInterceptor gateInterceptor) {
this.gateInterceptor = gateInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual
// calls be gated too, while the interceptor lets genuine manual tools through.
registry.addInterceptor(gateInterceptor)
.addPathPatterns("/api/v1/**")
.excludePathPatterns("/api/v1/account-link/**");
}
}
@@ -0,0 +1,38 @@
package stirling.software.proprietary.accountlink;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.service.InternalApiClient;
/**
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
*
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
* sub-steps). Everything else — interactive manual PDF tools — is always free.
*/
public final class BillableOperationClassifier {
private static final String AI_PATH_PREFIX = "/api/v1/ai/";
private BillableOperationClassifier() {}
public static boolean isBillable(HttpServletRequest request) {
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
return true;
}
String uri = request.getRequestURI();
if (uri == null) {
return false;
}
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
String ctx = request.getContextPath();
String path =
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
? uri.substring(ctx.length())
: uri;
return path.startsWith(AI_PATH_PREFIX);
}
}
@@ -0,0 +1,53 @@
package stirling.software.proprietary.accountlink;
import java.io.Serializable;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* The device credential this self-hosted instance received when it linked a SaaS account
* (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
*
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
* database (the same store that already holds API-key material and the license signature), so it is
* as secure-at-rest as the rest of the instance's secrets.
*/
@Entity
@Table(name = "account_link_device_credential")
@NoArgsConstructor
@Getter
@Setter
public class DeviceCredential implements Serializable {
private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@Id
@Column(name = "id")
private Long id = SINGLETON_ID;
/** Public identifier minted by the SaaS register call; sent as {@code X-Device-Id}. */
@Column(name = "device_id", nullable = false, length = 64)
private String deviceId;
/** High-entropy secret returned once by register; sent as {@code X-Device-Secret}. */
@Column(name = "device_secret", nullable = false, length = 128)
private String deviceSecret;
/** SaaS team this instance is linked to; informational on the instance side. */
@Column(name = "team_id")
private Long teamId;
@Column(name = "linked_at", nullable = false)
private LocalDateTime linkedAt;
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.accountlink;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DeviceCredentialRepository extends JpaRepository<DeviceCredential, Long> {
/** The singleton credential, if this instance has linked. */
default Optional<DeviceCredential> findCredential() {
return findById(DeviceCredential.SINGLETON_ID);
}
}
@@ -0,0 +1,55 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Secure-at-rest persistence for this instance's device credential. Thin wrapper over the
* singleton-row repository so the rest of the feature never touches JPA directly.
*
* <p>Gated + {@code @Profile("!saas")}: only the self-hosted profile links outward to a SaaS team.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class DeviceCredentialStore {
private final DeviceCredentialRepository repo;
public DeviceCredentialStore(DeviceCredentialRepository repo) {
this.repo = repo;
}
@Transactional(readOnly = true)
public Optional<DeviceCredential> get() {
return repo.findCredential();
}
@Transactional(readOnly = true)
public boolean isLinked() {
return repo.findCredential().isPresent();
}
/** Persists (or replaces) the credential returned by a SaaS register call. */
@Transactional
public void save(String deviceId, String deviceSecret, Long teamId) {
DeviceCredential cred = repo.findCredential().orElseGet(DeviceCredential::new);
cred.setId(DeviceCredential.SINGLETON_ID);
cred.setDeviceId(deviceId);
cred.setDeviceSecret(deviceSecret);
cred.setTeamId(teamId);
cred.setLinkedAt(LocalDateTime.now());
repo.save(cred);
}
/** Unlinks this instance locally (idempotent). */
@Transactional
public void clear() {
repo.findCredential().ifPresent(repo::delete);
}
}
@@ -0,0 +1,124 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
* every billable request. Single-slot (one instance = one linked team), TTL-based.
*
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
* allow").
*
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
* rather than serving a stale entitled snapshot.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class EntitlementCache {
private final DeviceCredentialStore credentialStore;
private final AccountLinkClient client;
private final Duration ttl;
/** Entitlement + fetch time, swapped atomically as one value so readers never tear. */
private record Snapshot(InstanceEntitlement entitlement, Instant fetchedAt) {}
private static final Snapshot EMPTY = new Snapshot(null, Instant.EPOCH);
/** Blocked entitlement synthesised on an authoritative deny (revoked/invalid credential). */
private static final InstanceEntitlement REVOKED =
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
private volatile Snapshot snapshot = EMPTY;
/** Single-flight guard: one thread refreshes while others serve the current snapshot. */
private final AtomicBoolean refreshing = new AtomicBoolean(false);
public EntitlementCache(
DeviceCredentialStore credentialStore,
AccountLinkClient client,
AccountLinkProperties properties) {
this.credentialStore = credentialStore;
this.client = client;
this.ttl = Duration.ofSeconds(properties.getEntitlementCacheSeconds());
}
/**
* Current entitlement, refreshing if stale. {@link Optional#empty()} means "unknown" — either
* not linked or the SaaS side is unreachable and we have no prior snapshot.
*/
public Optional<InstanceEntitlement> current() {
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
// call) while concurrent callers serve the last snapshot — no thundering herd of
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
try {
refresh();
} finally {
refreshing.set(false);
}
}
return Optional.ofNullable(snapshot.entitlement());
}
private boolean isStale(Snapshot snap) {
// fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
// fetch backs off for a full TTL instead of every billable request re-triggering a
// blocking round-trip against a dead/slow SaaS endpoint.
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
}
/**
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
*/
void refresh() {
Optional<DeviceCredential> cred = credentialStore.get();
if (cred.isEmpty()) {
// Unlinked: clear any stale snapshot so the gate sees "not linked".
snapshot = new Snapshot(null, Instant.now());
return;
}
try {
InstanceEntitlement fresh =
client.fetchEntitlement(cred.get().getDeviceId(), cred.get().getDeviceSecret());
if (fresh != null) {
snapshot = new Snapshot(fresh, Instant.now());
} else {
// Unreachable / server error: keep the last known entitlement (may be null) but
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
log.debug(
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
}
} catch (AccountLinkClient.RevokedException e) {
// Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately
// rather than serving the stale entitled snapshot until the next unlink.
log.info(
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
e.status());
snapshot = new Snapshot(REVOKED, Instant.now());
}
}
/** Forces a refresh on the next {@link #current()} (e.g. right after linking). */
public void invalidate() {
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.accountlink;
/**
* Coarse entitlement state the local gate enforces against. Proprietary-local (no coupling to the
* saas billing module): the SaaS entitlement response is parsed into this minimal shape.
*/
public enum EntitlementState {
/** Within free pool or covered by an active subscription — billable work allowed. */
OK,
/** Free pool exhausted and no subscription / over the period cap — billable work blocked. */
OVER_LIMIT,
/**
* Device credential revoked/invalid on the SaaS side (authoritative 401/403 deny) — billable
* work blocked. Synthesised locally by {@code EntitlementCache}, never sent by SaaS.
*/
REVOKED,
/** Unrecognised/malformed reply — the gate falls back to its numeric checks, not this flag. */
UNKNOWN
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.accountlink;
/**
* Outcome of {@link InstanceEntitlementGate}. {@link #allowed} is what the interceptor enforces;
* {@link #reason} carries the machine-readable signal the FE maps to a prompt (e.g. "link to
* activate"). Manual-tool and fail-open allows carry an informational reason but never block.
*/
public record GateDecision(boolean allowed, Reason reason) {
public enum Reason {
/** Feature flag is off — gate is fully inert. */
FLAG_OFF,
/** Operation is a manual tool — always free, never gated. */
MANUAL_FREE,
/** Linked + within entitlement — billable work allowed. */
ENTITLED,
/** Entitlement source unreachable — fail open, allow. */
FAIL_OPEN,
/** Not linked — block billable work; FE should prompt to link. */
NOT_LINKED,
/** Linked but over the limit / no subscription — block billable work. */
OVER_LIMIT,
/** Credential revoked/invalid on the SaaS side — block billable work. */
REVOKED
}
public static GateDecision allow(Reason reason) {
return new GateDecision(true, reason);
}
public static GateDecision block(Reason reason) {
return new GateDecision(false, reason);
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.accountlink;
/**
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response —
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
* saas types.
*
* @param subscribed team has an active subscription
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
* @param periodSpendUnits paid units spent this period
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
* @param state coarse state classification (see {@link EntitlementState})
*/
public record InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state) {}
@@ -0,0 +1,104 @@
package stirling.software.proprietary.accountlink;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
/**
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
*
* <p>Rules (in order):
*
* <ol>
* <li>Flag off → always allow (feature inert).
* <li>Manual tool → always allow (manual tools are free, never metered).
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
* <li>Billable + linked + entitled → allow.
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
* </ol>
*
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
* live flag / linked-state / entitlement. This is the unit-tested core.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementGate {
private final AccountLinkProperties properties;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
public InstanceEntitlementGate(
AccountLinkProperties properties,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache) {
this.properties = properties;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
}
/** Evaluates the gate for a request, resolving live state from the store + cache. */
public GateDecision evaluate(boolean billable) {
if (!properties.isEnabled()) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
if (!billable) {
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
}
boolean linked = credentialStore.isLinked();
Optional<InstanceEntitlement> entitlement =
linked ? entitlementCache.current() : Optional.empty();
return decide(true, true, linked, entitlement);
}
/**
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
* (unreachable): when linked, that fails open.
*/
public static GateDecision decide(
boolean flagEnabled,
boolean billable,
boolean linked,
Optional<InstanceEntitlement> entitlement) {
if (!flagEnabled) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
if (!billable) {
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
}
if (!linked) {
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
}
if (entitlement.isEmpty()) {
// Linked but entitlement source unreachable — never hard-block billable work on our
// inability to reach billing.
return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
}
InstanceEntitlement e = entitlement.get();
if (e.state() == EntitlementState.REVOKED) {
// Credential revoked/invalid (authoritative deny) — block, distinct from over-limit.
return GateDecision.block(GateDecision.Reason.REVOKED);
}
return entitled(e)
? GateDecision.allow(GateDecision.Reason.ENTITLED)
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
}
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
private static boolean entitled(InstanceEntitlement e) {
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
return false;
}
if (e.subscribed()) {
// Subscribed: allowed unless a period cap is set and exceeded.
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
}
// Unsubscribed: only the free pool covers billable work.
return e.freeRemainingUnits() > 0;
}
}
@@ -0,0 +1,65 @@
package stirling.software.proprietary.accountlink;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
*
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
* responses). Fail-open and flag-off both let the request continue.
*
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
*/
@Slf4j
@Component
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
private final InstanceEntitlementGate gate;
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
this.gate = gate;
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
GateDecision decision;
try {
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
log.debug("Account-link gate evaluation failed; allowing request", e);
return true;
}
if (decision.allowed()) {
return true;
}
log.debug("Account-link gate blocked {} ({})", request.getRequestURI(), decision.reason());
response.setStatus(HttpStatus.PAYMENT_REQUIRED.value());
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"ACCOUNT_LINK_REQUIRED\",\"reason\":\""
+ decision.reason().name()
+ "\"}");
return false;
}
}
@@ -4,14 +4,16 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.SourceStore;
/**
* Authority on which filesystem locations a policy may read/write. Checked at save time and again
@@ -28,7 +30,7 @@ import stirling.software.proprietary.policy.model.Policy;
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -36,12 +38,17 @@ public class FolderAccessGuard {
private final boolean saasActive;
private final List<Path> allowedRoots;
private final List<Path> protectedRoots;
private final SourceStore sourceStore;
public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) {
public FolderAccessGuard(
ApplicationProperties applicationProperties,
Environment environment,
SourceStore sourceStore) {
this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
this.allowedRoots =
normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots());
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
this.sourceStore = sourceStore;
}
/** Returns the normalised absolute path; throws if not permitted. */
@@ -72,7 +79,10 @@ public class FolderAccessGuard {
/** Whether this policy touches a folder source/sink, and so is subject to these rules. */
public boolean usesFolderAccess(Policy policy) {
boolean readsFolder =
policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type()));
policy.sourceIds().stream()
.map(sourceStore::get)
.flatMap(Optional::stream)
.anyMatch(source -> FOLDER_TYPE.equals(source.type()));
boolean writesFolder =
policy.output() != null && FOLDER_TYPE.equals(policy.output().type());
return readsFolder || writesFolder;
@@ -3,7 +3,7 @@ package stirling.software.proprietary.policy.config;
import java.util.List;
import java.util.Objects;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging
@@ -22,7 +23,7 @@ import stirling.software.proprietary.policy.model.Policy;
*/
@Component
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyAccessGuard {
private final UserServiceInterface userService;
@@ -47,13 +48,16 @@ public class PolicyAccessGuard {
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) {
/**
* The policies visible to the caller: their whole team's, loaded scoped rather than fetched
* globally and filtered, so on SaaS it never pulls another team's policies into memory. Login
* disabled (single-user) returns everything.
*/
public List<Policy> visibleFrom(PolicyStore store) {
if (!enforced()) {
return policies;
return store.all();
}
Long teamId = policyManagementAuthority.currentUserTeamId();
return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList();
return store.findByTeam(policyManagementAuthority.currentUserTeamId());
}
private boolean enforced() {
@@ -6,7 +6,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -53,7 +53,10 @@ import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
/**
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
@@ -65,15 +68,18 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyController {
private final PolicyRunner policyRunner;
private final PolicyRunRegistry runRegistry;
private final PolicyStore policyStore;
private final SourceStore sourceStore;
private final SourceAccessGuard sourceAccessGuard;
private final PolicyValidator policyValidator;
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final PolicyTriggerManager policyTriggerManager;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@@ -187,12 +193,33 @@ public class PolicyController {
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
requirePolicyEditingAllowed();
Policy owned = resolveOwnership(policy);
requireAccessibleSources(owned);
try {
policyValidator.validate(owned);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
return ResponseEntity.ok(policyStore.save(owned));
Policy saved = policyStore.save(owned);
// Re-sync trigger registrations now so a new/changed folder-watch policy starts being
// watched immediately instead of after the next reconcile sweep.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.ok(saved);
}
/**
* Every {@code sourceId} a policy references must resolve to a source in the caller's team, so
* a client can neither reference a non-existent source nor reach across teams to use another
* team's connection. A bad reference is a client error.
*/
private void requireAccessibleSources(Policy policy) {
for (String sourceId : policy.sourceIds()) {
boolean accessible =
sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).isPresent();
if (!accessible) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unknown or inaccessible source: " + sourceId);
}
}
}
/**
@@ -225,7 +252,7 @@ public class PolicyController {
owner,
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.sourceIds(),
policy.steps(),
policy.output(),
teamId);
@@ -257,7 +284,7 @@ public class PolicyController {
summary = "List policies",
description = "Lists the policies belonging to the caller's team.")
public List<Policy> listPolicies() {
return policyAccessGuard.visible(policyStore.all());
return policyAccessGuard.visibleFrom(policyStore);
}
@GetMapping("/{policyId}")
@@ -278,6 +305,9 @@ public class PolicyController {
boolean accessible =
policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent();
if (accessible && policyStore.delete(policyId)) {
// Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open
// until the next reconcile sweep.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
@@ -9,7 +9,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.slf4j.MDC;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -54,7 +54,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyEngine {
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
@@ -9,7 +9,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -29,7 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -4,7 +4,7 @@ import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -19,33 +19,51 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
/**
* Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide <em>when</em>
* and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
* Turns a policy's referenced sources into runs: each {@code sourceId} is resolved live to its
* persisted {@link Source}, then to an {@link InputSpec}. Triggers decide <em>when</em> and call
* {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunner {
private final PolicyEngine policyEngine;
private final List<InputSource> inputSources;
private final SourceStore sourceStore;
/**
* Trigger entry point. Pulls every configured source; each yielded unit becomes its own run so
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
* one failure does not affect the others. No sources means one run with no input (generator
* pipeline).
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
* rest.
*/
public void run(Policy policy) {
List<InputSpec> sources = policy.sources();
if (sources.isEmpty()) {
List<String> sourceIds = policy.sourceIds();
if (sourceIds.isEmpty()) {
startRun(policy, PolicyInputs.of(List.of()), unused -> {});
return;
}
for (InputSpec spec : sources) {
pullAndRun(policy, spec);
for (String sourceId : sourceIds) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null) {
log.warn("Policy {} references missing source {}; skipping", policy.id(), sourceId);
continue;
}
if (!source.enabled()) {
log.debug(
"Source {} ({}) is disabled; skipping for policy {}",
sourceId,
source.name(),
policy.id());
continue;
}
pullAndRun(policy, source.toInputSpec());
}
}
@@ -2,7 +2,7 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -13,31 +13,44 @@ import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
/**
* Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean
* that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger
* is a manual-only policy and skips trigger validation.
* is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} must
* resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts.
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
private final List<InputSource> inputSources;
private final List<PolicyOutputSink> outputSinks;
private final SourceStore sourceStore;
/**
* @throws IllegalArgumentException if any facet's type is unknown or its config is invalid
* @throws IllegalArgumentException if any facet's type is unknown, a referenced source does not
* exist, or any config is invalid
*/
public void validate(Policy policy) {
if (policy.trigger() != null) {
triggerFor(policy.trigger()).validate(policy);
}
for (InputSpec source : policy.sources()) {
inputSourceFor(source).validate(source);
for (String sourceId : policy.sourceIds()) {
Source source =
sourceStore
.get(sourceId)
.orElseThrow(
() ->
new IllegalArgumentException(
"unknown source: " + sourceId));
InputSpec spec = source.toInputSpec();
inputSourceFor(spec).validate(spec);
}
outputSinkFor(policy.output()).validate(policy.output());
}
@@ -9,7 +9,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -34,7 +34,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -6,8 +6,9 @@ import java.util.List;
* A stored automation: ordered tool steps, input sources, and an output destination.
*
* <p>Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code
* null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where
* files come from; a run pulls from every source.
* null} trigger means manual-only. Trigger decides when; {@code sourceIds} reference the persisted
* {@code Source} connections (resolved live at run time) that decide where files come from; a run
* pulls from every referenced source.
*/
public record Policy(
String id,
@@ -15,13 +16,13 @@ public record Policy(
String owner,
boolean enabled,
TriggerConfig trigger,
List<InputSpec> sources,
List<String> sourceIds,
List<PipelineStep> steps,
OutputSpec output,
Long teamId) {
public Policy {
sources = sources == null ? List.of() : List.copyOf(sources);
sourceIds = sourceIds == null ? List.of() : List.copyOf(sourceIds);
steps = steps == null ? List.of() : steps;
output = output == null ? OutputSpec.inline() : output;
}
@@ -36,10 +37,10 @@ public record Policy(
String owner,
boolean enabled,
TriggerConfig trigger,
List<InputSpec> sources,
List<String> sourceIds,
List<PipelineStep> steps,
OutputSpec output) {
this(id, name, owner, enabled, trigger, sources, steps, output, null);
this(id, name, owner, enabled, trigger, sourceIds, steps, output, null);
}
/** A policy with no configured sources (a generator, or files supplied directly to a run). */
@@ -9,7 +9,7 @@ import java.util.List;
import java.util.UUID;
import org.apache.commons.io.FilenameUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -31,7 +31,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderOutputSink implements PolicyOutputSink {
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -5,7 +5,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -23,7 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
@@ -0,0 +1,58 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory {@link SourceStore} for tests and any future no-database mode. {@link JpaSourceStore}
* is the runtime bean.
*/
public class InProcessSourceStore implements SourceStore {
private final Map<String, Source> sources = new ConcurrentHashMap<>();
@Override
public Source save(Source source) {
String id =
source.id() == null || source.id().isBlank()
? UUID.randomUUID().toString()
: source.id();
Source stored =
new Source(
id,
source.name(),
source.type(),
source.options(),
source.enabled(),
source.owner(),
source.teamId());
sources.put(id, stored);
return stored;
}
@Override
public Optional<Source> get(String id) {
return Optional.ofNullable(sources.get(id));
}
@Override
public List<Source> all() {
return List.copyOf(sources.values());
}
@Override
public List<Source> findByTeam(Long teamId) {
return sources.values().stream()
.filter(source -> Objects.equals(source.teamId(), teamId))
.toList();
}
@Override
public boolean delete(String id) {
return sources.remove(id) != null;
}
}
@@ -0,0 +1,81 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link SourceStore} backed by JPA; the runtime store. Sources are persisted as JSON via
* {@link SourceEntity}, with scalar columns kept in sync for querying.
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaSourceStore implements SourceStore {
private final SourceRepository repository;
private final ObjectMapper objectMapper;
@Override
public Source save(Source source) {
String id =
source.id() == null || source.id().isBlank()
? UUID.randomUUID().toString()
: source.id();
Source stored =
new Source(
id,
source.name(),
source.type(),
source.options(),
source.enabled(),
source.owner(),
source.teamId());
SourceEntity entity = new SourceEntity();
entity.setId(id);
entity.setName(stored.name());
entity.setType(stored.type());
entity.setOwner(stored.owner());
entity.setTeamId(stored.teamId());
entity.setEnabled(stored.enabled());
entity.setSourceJson(objectMapper.writeValueAsString(stored));
repository.save(entity);
return stored;
}
@Override
public Optional<Source> get(String id) {
return repository.findById(id).map(this::toSource);
}
@Override
public List<Source> all() {
return repository.findAll().stream().map(this::toSource).toList();
}
@Override
public List<Source> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream().map(this::toSource).toList();
}
@Override
public boolean delete(String id) {
if (!repository.existsById(id)) {
return false;
}
repository.deleteById(id);
return true;
}
private Source toSource(SourceEntity entity) {
return objectMapper.readValue(entity.getSourceJson(), Source.class);
}
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.policy.source;
import java.util.Map;
import stirling.software.proprietary.policy.model.InputSpec;
/**
* A persisted, reusable input connection: the instantiation of a source definition. Policies
* reference sources by {@code id} rather than embedding their config, so one connection is
* configured once and can feed many policies.
*
* <p>{@code type} keys an {@link stirling.software.proprietary.policy.input.InputSource} bean,
* matching {@link InputSpec#type()}; {@code options} is that source's config. {@code owner} and
* {@code teamId} scope the source to a team, mirroring {@link
* stirling.software.proprietary.policy.model.Policy}.
*/
public record Source(
String id,
String name,
String type,
Map<String, Object> options,
boolean enabled,
String owner,
Long teamId) {
public Source {
options = options == null ? Map.of() : options;
}
/** The runtime form the policy engine resolves and runs against. */
public InputSpec toInputSpec() {
return new InputSpec(type, options);
}
}
@@ -0,0 +1,63 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
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.config.PolicyManagementAuthority;
/**
* Sources are scoped to a team exactly like policies: a user may view, edit, and delete only the
* sources belonging to their own team (the team a source is stamped with at creation). Enforced
* only when login is enabled; single-user deployments (login disabled) pass every check. Mirrors
* {@link stirling.software.proprietary.policy.config.PolicyAccessGuard}.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceAccessGuard {
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
private final PolicyManagementAuthority policyManagementAuthority;
/** Owner for a new source: the current user, or {@code null} when login is disabled. */
public String ownerForNewSource() {
return enforced() ? userService.getCurrentUsername() : null;
}
/** Team a new source is stamped with: the creator's team. {@code null} when login disabled. */
public Long teamForNewSource() {
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
}
/** Whether the source belongs to the current user's team (so they may view/edit it). */
public boolean canAccess(Source source) {
if (!enforced()) {
return true;
}
return Objects.equals(source.teamId(), policyManagementAuthority.currentUserTeamId());
}
/**
* The sources visible to the caller: their whole team's, loaded scoped rather than fetched
* globally and filtered, so on SaaS it never pulls another team's sources into memory. Login
* disabled (single-user) returns everything.
*/
public List<Source> visibleFrom(SourceStore store) {
if (!enforced()) {
return store.all();
}
return store.findByTeam(policyManagementAuthority.currentUserTeamId());
}
private boolean enforced() {
return applicationProperties.getSecurity().isEnableLogin();
}
}
@@ -0,0 +1,191 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
/**
* CRUD for persisted, reusable input connections plus the Sources overview for the admin portal. A
* source is configured once here and referenced by id from any number of policies; the overview
* reports how many reference each one. Editing follows the same team-leader rule as policies, and
* everything is scoped to the caller's team.
*/
@RestController
@RequestMapping("/api/v1/sources")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Sources", description = "Reusable policy input connections")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceController {
private final SourceStore sourceStore;
private final SourceAccessGuard sourceAccessGuard;
private final SourceOverviewService overviewService;
private final PolicyStore policyStore;
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final PolicyTriggerManager policyTriggerManager;
private final ApplicationProperties applicationProperties;
private final List<InputSource> inputSources;
@GetMapping
@Operation(
summary = "Sources overview",
description =
"Returns the KPI strip plus one row per source the caller's team owns, each with"
+ " how many policies reference it and which.")
public SourcesResponse list() {
return overviewService.overview();
}
@GetMapping("/{sourceId}")
@Operation(summary = "Get a source by id")
public ResponseEntity<Source> get(@PathVariable String sourceId) {
return sourceStore
.get(sourceId)
.filter(sourceAccessGuard::canAccess)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Create or update a source",
description =
"Stores an input connection (type + config). A blank id is assigned; owner and"
+ " team are stamped server-side. The config is validated against the"
+ " matching source type.")
public ResponseEntity<Source> save(@RequestBody Source source) {
requireSourceEditingAllowed();
Source owned = resolveOwnership(source);
try {
validateConfig(owned);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
Source saved = sourceStore.save(owned);
// An edited folder source can change which directory needs watching, so re-sync trigger
// registrations now instead of waiting for the next reconcile.
policyTriggerManager.notifyPoliciesChanged();
return ResponseEntity.ok(saved);
}
@DeleteMapping("/{sourceId}")
@Operation(
summary = "Delete a source",
description =
"Removes a source that no policy references. A source still in use returns 409"
+ " so the connection can't be pulled out from under a live policy.")
public ResponseEntity<Void> delete(@PathVariable String sourceId) {
requireSourceEditingAllowed();
Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null);
if (source == null) {
return ResponseEntity.notFound().build();
}
List<String> referencing = referencingPolicyNames(sourceId);
if (!referencing.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Source is referenced by "
+ referencing.size()
+ " policy(ies): "
+ String.join(", ", referencing));
}
sourceStore.delete(sourceId);
return ResponseEntity.noContent().build();
}
/**
* Stamp owner + team server-side. Create stamps the current user and their team; update
* preserves the existing owner and team after verifying the source belongs to the caller's
* team, so the client can neither forge ownership on create nor reach across teams on update (a
* source in another team reads as not-found).
*/
private Source resolveOwnership(Source incoming) {
String id = incoming.id();
if (id != null && !id.isBlank()) {
Source existing = sourceStore.get(id).orElse(null);
if (existing != null) {
if (!sourceAccessGuard.canAccess(existing)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No source: " + id);
}
return withOwnerAndTeam(incoming, existing.owner(), existing.teamId());
}
}
return withOwnerAndTeam(
incoming,
sourceAccessGuard.ownerForNewSource(),
sourceAccessGuard.teamForNewSource());
}
private static Source withOwnerAndTeam(Source source, String owner, Long teamId) {
return new Source(
source.id(),
source.name(),
source.type(),
source.options(),
source.enabled(),
owner,
teamId);
}
/** Validate the config against the bean that handles the source's type, as the engine will. */
private void validateConfig(Source source) {
InputSpec spec = source.toInputSpec();
inputSources.stream()
.filter(inputSource -> inputSource.supports(spec))
.findFirst()
.orElseThrow(
() -> new IllegalArgumentException("unknown source type: " + source.type()))
.validate(spec);
}
/**
* Editing sources requires the editor role for the caller's team (a team leader on SaaS), the
* same rule as policies. Single-user deployments (login disabled) trust the local operator.
*/
private void requireSourceEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Sources may only be created or modified by a team leader");
}
}
/** Names of the caller's visible policies that reference the given source. */
private List<String> referencingPolicyNames(String sourceId) {
return policyAccessGuard.visibleFrom(policyStore).stream()
.filter(policy -> policy.sourceIds().contains(sourceId))
.map(Policy::name)
.toList();
}
}
@@ -0,0 +1,50 @@
package stirling.software.proprietary.policy.source;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative
* on read); the scalar columns are denormalized copies for querying. {@code owner} and {@code
* teamId} are plain values, not foreign keys, to stay decoupled from the security entities -
* matching {@link stirling.software.proprietary.policy.store.PolicyEntity}.
*/
@Entity
@Table(name = "policy_sources")
@NoArgsConstructor
@Getter
@Setter
public class SourceEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "id")
private String id;
@Column(name = "name")
private String name;
@Column(name = "type")
private String type;
@Column(name = "owner")
private String owner;
@Column(name = "team_id")
private Long teamId;
@Column(name = "enabled")
private boolean enabled;
@Column(name = "source_json", columnDefinition = "text")
private String sourceJson;
}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.policy.source;
/** One headline figure in the Sources overview strip. */
public record SourceKpi(long value, String description) {}
@@ -0,0 +1,118 @@
package stirling.software.proprietary.policy.source;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Builds the Sources overview: every persisted source the caller's team owns, shown exactly once,
* each annotated with the policies that reference it. Reference counts are derived by scanning the
* team's policies in memory rather than persisted on the source - fine at admin-dashboard scale and
* always consistent with the live policy set.
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceOverviewService {
private final SourceStore sourceStore;
private final PolicyStore policyStore;
private final SourceAccessGuard sourceAccessGuard;
private final PolicyAccessGuard policyAccessGuard;
public SourcesResponse overview() {
List<Source> sources = sourceAccessGuard.visibleFrom(sourceStore);
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
Map<String, List<Policy>> referencesBySource = referencesBySource(policies);
List<SourceView> views =
sources.stream()
.map(
source ->
toView(
source,
referencesBySource.getOrDefault(
source.id(), List.of())))
.sorted(
Comparator.comparingInt(SourceView::referenceCount)
.reversed()
.thenComparing(SourceView::name))
.toList();
return new SourcesResponse(buildKpis(views), views);
}
/** Policies referencing each source id, across the caller's visible policies. */
private static Map<String, List<Policy>> referencesBySource(List<Policy> policies) {
Map<String, List<Policy>> bySource = new HashMap<>();
for (Policy policy : policies) {
for (String sourceId : policy.sourceIds()) {
bySource.computeIfAbsent(sourceId, key -> new ArrayList<>()).add(policy);
}
}
return bySource;
}
private static SourceView toView(Source source, List<Policy> referencingPolicies) {
List<SourceView.PolicyRef> refs =
referencingPolicies.stream()
.map(policy -> new SourceView.PolicyRef(policy.id(), policy.name()))
.toList();
return new SourceView(
source.id(),
source.name(),
source.type(),
deriveStatus(source, refs.size()),
refs.size(),
refs,
configRows(source),
null);
}
/** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */
private static String deriveStatus(Source source, int referenceCount) {
if (!source.enabled()) {
return "disabled";
}
return referenceCount == 0 ? "unused" : "active";
}
/** Generic key/value view of the source's config - works for any source type. */
private static List<SourceView.DetailRow> configRows(Source source) {
return source.options().entrySet().stream()
.map(
entry ->
new SourceView.DetailRow(
humanize(entry.getKey()), String.valueOf(entry.getValue())))
.toList();
}
private static String humanize(String key) {
if (key == null || key.isBlank()) {
return key;
}
return Character.toUpperCase(key.charAt(0)) + key.substring(1);
}
private static List<SourceKpi> buildKpis(List<SourceView> sources) {
long total = sources.size();
long inUse = sources.stream().filter(source -> source.referenceCount() > 0).count();
long orphaned = total - inUse;
return List.of(
new SourceKpi(total, "connections"),
new SourceKpi(inUse, "referenced by a policy"),
new SourceKpi(orphaned, "unused"));
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface SourceRepository extends JpaRepository<SourceEntity, String> {
/**
* Sources belonging to a team, loaded without scanning every team's rows. A {@code null} teamId
* matches the rows with no team (login-disabled / pre-team data), mirroring the in-memory team
* filter rather than the empty result a plain {@code = null} would give.
*/
@Query(
"select s from SourceEntity s where (:teamId is null and s.teamId is null) or"
+ " s.teamId = :teamId")
List<SourceEntity> findByTeam(@Param("teamId") Long teamId);
}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Optional;
/** Stores {@link Source} definitions (persisted, reusable input connections). */
public interface SourceStore {
/** Create or update; a blank/absent id is assigned. Returns the stored source. */
Source save(Source source);
Optional<Source> get(String id);
List<Source> all();
/** Sources owned by the given team, loaded scoped rather than fetched globally. */
List<Source> findByTeam(Long teamId);
/** Returns whether the source existed. */
boolean delete(String id);
}
@@ -0,0 +1,25 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
/**
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
* policies reference it (and which). {@code docsTotal} is {@code null} - per-source document volume
* is not tracked yet; the field is reserved so a later doc-accounting pass is additive.
*/
public record SourceView(
String id,
String name,
String type,
String status,
int referenceCount,
List<PolicyRef> referencingPolicies,
List<DetailRow> config,
Long docsTotal) {
/** A policy that references this source. */
public record PolicyRef(String id, String name) {}
/** A key/value line summarising the source's config for display. */
public record DetailRow(String label, String value) {}
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.policy.source;
import java.util.List;
/** The Sources overview payload: a KPI strip plus one row per source. */
public record SourcesResponse(List<SourceKpi> kpis, List<SourceView> sources) {}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.store;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -29,7 +30,7 @@ public class InProcessPolicyStore implements PolicyStore {
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.sourceIds(),
policy.steps(),
policy.output(),
policy.teamId());
@@ -47,6 +48,13 @@ public class InProcessPolicyStore implements PolicyStore {
return List.copyOf(policies.values());
}
@Override
public List<Policy> findByTeam(Long teamId) {
return policies.values().stream()
.filter(policy -> Objects.equals(policy.teamId(), teamId))
.toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return policies.values().stream()
@@ -4,7 +4,7 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -19,7 +19,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaPolicyStore implements PolicyStore {
private final PolicyRepository repository;
@@ -38,7 +38,7 @@ public class JpaPolicyStore implements PolicyStore {
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.sources(),
policy.sourceIds(),
policy.steps(),
policy.output(),
policy.teamId());
@@ -49,6 +49,7 @@ public class JpaPolicyStore implements PolicyStore {
entity.setOwner(stored.owner());
entity.setEnabled(stored.enabled());
entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type());
entity.setTeamId(stored.teamId());
entity.setPolicyJson(objectMapper.writeValueAsString(stored));
repository.save(entity);
return stored;
@@ -64,6 +65,11 @@ public class JpaPolicyStore implements PolicyStore {
return repository.findAll().stream().map(this::toPolicy).toList();
}
@Override
public List<Policy> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream().map(this::toPolicy).toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream()
@@ -15,8 +15,9 @@ import lombok.Setter;
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
* 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
* their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the
* security entities.
* their policies, and {@code teamId} so the caller's team can be loaded without scanning every
* team's rows. {@code owner} and {@code teamId} are plain values, not foreign keys, to stay
* decoupled from the security entities.
*/
@Entity
@Table(name = "policies")
@@ -43,6 +44,9 @@ public class PolicyEntity implements Serializable {
@Column(name = "trigger_type")
private String triggerType;
@Column(name = "team_id")
private Long teamId;
@Column(name = "policy_json", columnDefinition = "text")
private String policyJson;
}
@@ -3,6 +3,8 @@ package stirling.software.proprietary.policy.store;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
@@ -10,4 +12,14 @@ public interface PolicyRepository extends JpaRepository<PolicyEntity, String> {
/** Enabled policies of a given trigger type, for background triggers to activate. */
List<PolicyEntity> findByTriggerTypeAndEnabledTrue(String triggerType);
/**
* Policies belonging to a team, loaded without scanning every team's rows. A {@code null}
* teamId matches the rows with no team (login-disabled / pre-team data), mirroring the
* in-memory team filter rather than the empty result a plain {@code = null} would give.
*/
@Query(
"select p from PolicyEntity p where (:teamId is null and p.teamId is null) or"
+ " p.teamId = :teamId")
List<PolicyEntity> findByTeam(@Param("teamId") Long teamId);
}
@@ -15,6 +15,9 @@ public interface PolicyStore {
List<Policy> all();
/** Policies owned by the given team, loaded scoped rather than fetched globally. */
List<Policy> findByTeam(Long teamId);
/** Enabled policies with the given trigger type, for background triggers. */
List<Policy> findByTriggerType(String triggerType);
@@ -20,7 +20,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -31,6 +31,8 @@ import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
@@ -46,7 +48,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
@@ -54,6 +56,7 @@ public class FolderWatchTrigger implements PolicyTrigger {
private final PolicyStore policyStore;
private final PolicyRunner policyRunner;
private final List<InputSource> inputSources;
private final SourceStore sourceStore;
private final ApplicationProperties applicationProperties;
private final Map<Path, WatchKey> keysByDir = new ConcurrentHashMap<>();
@@ -120,6 +123,14 @@ public class FolderWatchTrigger implements PolicyTrigger {
dirByKey.clear();
}
@Override
public void onPoliciesChanged() {
// A created/updated/deleted policy may add or drop a watched directory: register/cancel now
// instead of waiting up to watchReconcileSeconds for the next reconcile. A no-op until the
// trigger is started (watchService null), where the first reconcile picks everything up.
syncRegistrations();
}
private void watchLoop() {
// Capture once: stop() may null the field; close() still wakes take()/poll() on this local.
WatchService watcher = watchService;
@@ -269,12 +280,17 @@ public class FolderWatchTrigger implements PolicyTrigger {
// the path was configured.
private List<Path> watchDirsOf(Policy policy) {
List<Path> dirs = new ArrayList<>();
for (InputSpec spec : policy.sources()) {
InputSource source = sourceFor(spec);
for (String sourceId : policy.sourceIds()) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null) {
continue;
}
for (Path dir : source.watchTargets(spec)) {
InputSpec spec = source.toInputSpec();
InputSource inputSource = sourceFor(spec);
if (inputSource == null) {
continue;
}
for (Path dir : inputSource.watchTargets(spec)) {
dirs.add(dir.toAbsolutePath().normalize());
}
}
@@ -20,4 +20,12 @@ public interface PolicyTrigger {
default void start() {}
default void stop() {}
/**
* React to a policy being created, updated, or deleted. A trigger that caches per-policy state
* (folder-watch tracks which directories to watch) re-syncs it now, so a new policy is acted on
* immediately and a deleted one stops at once, rather than waiting for the next periodic sweep.
* Default no-op for triggers that read the store fresh on every fire.
*/
default void onPoliciesChanged() {}
}
@@ -2,8 +2,8 @@ package stirling.software.proprietary.policy.trigger;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -13,7 +13,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyTriggerManager implements SmartLifecycle {
private final List<PolicyTrigger> triggers;
@@ -48,4 +48,26 @@ public class PolicyTriggerManager implements SmartLifecycle {
public boolean isRunning() {
return running;
}
/**
* Tell every trigger that the policy set changed so cached registrations refresh promptly
* instead of waiting for the next periodic reconcile. Best-effort and idempotent: a failing
* trigger is logged and the rest still run; a no-op before the subsystem has started.
*/
public void notifyPoliciesChanged() {
if (!running) {
return;
}
for (PolicyTrigger trigger : triggers) {
try {
trigger.onPoliciesChanged();
} catch (RuntimeException e) {
log.error(
"Failed to refresh trigger '{}' after policy change: {}",
trigger.type(),
e.getMessage(),
e);
}
}
}
}
@@ -10,7 +10,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -32,7 +32,7 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ScheduleTrigger implements PolicyTrigger {
private static final String TYPE = "schedule";
@@ -32,14 +32,18 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.repository",
"stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository",
"stirling.software.proprietary.policy.store"
"stirling.software.proprietary.policy.store",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.policy.source"
})
@EntityScan({
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model",
"stirling.software.proprietary.policy.store"
"stirling.software.proprietary.policy.store",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.policy.source"
})
public class DatabaseConfig {
@@ -22,12 +22,12 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@@ -40,22 +40,22 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
@Value("${system.serverCertificate.enabled:false}")
private boolean enabled;
@Value("${system.serverCertificate.organizationName:Stirling-PDF}")
private String organizationName;
@Value("${system.serverCertificate.validity:365}")
private int validityDays;
@Value("${system.serverCertificate.regenerateOnStartup:false}")
private boolean regenerateOnStartup;
private final LicenseKeyChecker licenseKeyChecker;
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
public ServerCertificateService(
LicenseKeyChecker licenseKeyChecker, ApplicationProperties applicationProperties) {
this.licenseKeyChecker = licenseKeyChecker;
ApplicationProperties.System.ServerCertificate config =
applicationProperties.getSystem().getServerCertificate();
this.enabled = config.isEnabled();
this.organizationName = config.getOrganizationName();
this.validityDays = config.getValidity();
this.regenerateOnStartup = config.isRegenerateOnStartup();
}
static {
@@ -81,7 +81,7 @@ public class UserServerCertificateService {
// Certificate details with username
String username = user.getUsername();
X500Name subject = new X500Name("CN=" + username + ", O=Stirling-PDF User, C=US");
X500Name subject = new X500Name("CN=" + username + ", OU=User, O=Stirling PDF Inc, C=US");
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
Date notBefore = new Date();
Date notAfter =
@@ -0,0 +1,192 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.net.ConnectException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tools.jackson.databind.ObjectMapper;
/**
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
* unreachable) behaviour hold.
*/
class AccountLinkClientTest {
private AccountLinkProperties properties;
private HttpClient httpClient;
private AccountLinkClient client;
@BeforeEach
void setUp() {
properties = new AccountLinkProperties();
properties.setEnabled(true);
properties.setSaasBaseUrl("https://saas.example.com");
httpClient = mock(HttpClient.class);
client = new AccountLinkClient(properties, new ObjectMapper(), httpClient);
}
@SuppressWarnings("unchecked")
private HttpResponse<String> response(int status, String body) {
HttpResponse<String> resp = mock(HttpResponse.class);
when(resp.statusCode()).thenReturn(status);
when(resp.body()).thenReturn(body);
return resp;
}
@Test
@SuppressWarnings("unchecked")
void registerRelaysJwtAndParsesCredential() throws Exception {
// Build the stub response first: nesting response() inside when() trips Mockito's
// unfinished-stubbing check (inner when() runs mid outer when()).
HttpResponse<String> resp =
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
assertEquals("dev-1", result.deviceId());
assertEquals("sec-1", result.deviceSecret());
assertEquals(42L, result.teamId());
HttpRequest sent = captor.getValue();
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
assertEquals(
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
}
@Test
@SuppressWarnings("unchecked")
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.UpstreamException ex =
assertThrows(
AccountLinkClient.UpstreamException.class,
() -> client.register("jwt", null));
assertEquals(401, ex.status());
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementParsesSnapshotAndSendsDeviceHeaders() throws Exception {
HttpResponse<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":10,\"periodCapUnits\":100,\"state\":\"OK\"}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1");
assertNotNull(e);
assertEquals(true, e.subscribed());
assertEquals(10, e.periodSpendUnits());
assertEquals(100L, e.periodCapUnits());
assertEquals(EntitlementState.OK, e.state());
HttpRequest sent = captor.getValue();
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementMapsOverLimitState() throws Exception {
// Pins the consume side of the wire contract: InstanceController emits "OVER_LIMIT" (for a
// DEGRADED team) and the client must map it to the gate-blocking state.
HttpResponse<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":1300,\"periodCapUnits\":1250,\"state\":\"OVER_LIMIT\"}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1");
assertNotNull(e);
assertEquals(EntitlementState.OVER_LIMIT, e.state());
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementReturnsNullWhenUnreachable() throws Exception {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
.thenThrow(new ConnectException("refused"));
// Null = unknown → the cache/gate fail open.
assertNull(client.fetchEntitlement("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementReturnsNullOnServerError() throws Exception {
// 5xx is a transient/server failure, not a credential deny → null, the cache fails open.
HttpResponse<String> resp = response(503, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
assertNull(client.fetchEntitlement("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementThrowsRevokedOnDeny() throws Exception {
// 401/403 = authoritative deny (revoked/invalid credential) → RevokedException, NOT null:
// the cache must block billable work rather than fail open on a stale snapshot.
for (int status : new int[] {401, 403}) {
HttpResponse<String> resp = response(status, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.RevokedException ex =
assertThrows(
AccountLinkClient.RevokedException.class,
() -> client.fetchEntitlement("dev-1", "sec-1"));
assertEquals(status, ex.status());
}
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfSendsDeviceHeadersAndReturnsTrueOn2xx() throws Exception {
HttpResponse<String> resp = response(204, "");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
assertEquals(true, client.revokeSelf("dev-1", "sec-1"));
HttpRequest sent = captor.getValue();
assertEquals("https://saas.example.com/api/v1/instance/revoke-self", sent.uri().toString());
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
assertEquals("POST", sent.method());
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfReturnsFalseOnErrorStatus() throws Exception {
HttpResponse<String> resp = response(403, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfReturnsFalseWhenUnreachable() throws Exception {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
.thenThrow(new ConnectException("refused"));
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
}
@@ -0,0 +1,68 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
/**
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
* faults are a 502.
*/
class AccountLinkControllerTest {
private AccountLinkService service;
private AccountLinkController controller;
@BeforeEach
void setUp() {
service = mock(AccountLinkService.class);
controller = new AccountLinkController(service);
}
@Test
void link_missingJwt_returns400() {
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void link_upstreamUnauthorized_maps401() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void link_upstreamForbidden_maps403() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void link_upstreamServerError_maps502() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
@Test
void link_transportFailure_maps502() throws Exception {
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
}
@@ -0,0 +1,111 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class AccountLinkServiceTest {
private AccountLinkClient client;
private DeviceCredentialStore store;
private EntitlementCache cache;
private AccountLinkService service;
@BeforeEach
void setUp() {
client = mock(AccountLinkClient.class);
store = mock(DeviceCredentialStore.class);
cache = mock(EntitlementCache.class);
service = new AccountLinkService(client, store, cache);
}
@Test
void link_storesCredentialAndInvalidatesCache() throws IOException {
when(client.register("jwt", "name"))
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
DeviceCredential stored = new DeviceCredential();
stored.setDeviceId("dev-1");
stored.setTeamId(7L);
stored.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(stored));
AccountLinkService.LinkStatus status = service.link("jwt", "name");
verify(store).save("dev-1", "sec-1", 7L);
verify(cache).invalidate();
assertTrue(status.linked());
assertEquals("dev-1", status.deviceId());
assertEquals(7L, status.teamId());
}
@Test
void link_propagatesRegisterFailure() throws IOException {
when(client.register(any(), any())).thenThrow(new IOException("boom"));
org.junit.jupiter.api.Assertions.assertThrows(
IOException.class, () -> service.link("jwt", null));
verify(cache, org.mockito.Mockito.never()).invalidate();
}
@Test
void status_unlinkedWhenNoCredential() {
when(store.get()).thenReturn(Optional.empty());
AccountLinkService.LinkStatus status = service.status();
assertFalse(status.linked());
}
@Test
void unlink_callsSaasRevokeBeforeClearingLocally() {
DeviceCredential cred = new DeviceCredential();
cred.setDeviceId("dev-1");
cred.setDeviceSecret("sec-1");
cred.setTeamId(7L);
cred.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(cred));
when(client.revokeSelf("dev-1", "sec-1")).thenReturn(true);
service.unlink();
verify(client).revokeSelf("dev-1", "sec-1");
verify(store).clear();
verify(cache).invalidate();
}
@Test
void unlink_clearsLocallyEvenWhenSaasRevokeFails() {
DeviceCredential cred = new DeviceCredential();
cred.setDeviceId("dev-1");
cred.setDeviceSecret("sec-1");
cred.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(cred));
// SaaS unreachable / returns non-2xx.
when(client.revokeSelf("dev-1", "sec-1")).thenReturn(false);
service.unlink();
// Local clear MUST still happen — admin's intent wins; orphan row is a follow-up.
verify(store).clear();
verify(cache).invalidate();
}
@Test
void unlink_whenAlreadyUnlinked_skipsSaasRevoke() {
when(store.get()).thenReturn(Optional.empty());
service.unlink();
org.mockito.Mockito.verifyNoInteractions(client);
verify(store).clear();
verify(cache).invalidate();
}
}
@@ -0,0 +1,49 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.common.service.InternalApiClient;
class BillableOperationClassifierTest {
@Test
void aiPathIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
assertTrue(BillableOperationClassifier.isBillable(req));
}
@Test
void automationHeaderIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1");
assertTrue(BillableOperationClassifier.isBillable(req));
}
@Test
void plainManualToolIsFree() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
assertFalse(BillableOperationClassifier.isBillable(req));
}
@Test
void aiSegmentNotAtPathStartIsFree() {
// Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a
// proxy prefix) must NOT classify a manual tool as billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo");
assertFalse(BillableOperationClassifier.isBillable(req));
}
@Test
void aiPathUnderContextPathIsBillable() {
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
req.setContextPath("/stirling");
assertTrue(BillableOperationClassifier.isBillable(req));
}
}
@@ -0,0 +1,114 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class EntitlementCacheTest {
private DeviceCredentialStore store;
private AccountLinkClient client;
private AccountLinkProperties properties;
private EntitlementCache cache;
@BeforeEach
void setUp() {
store = mock(DeviceCredentialStore.class);
client = mock(AccountLinkClient.class);
properties = new AccountLinkProperties();
properties.setEntitlementCacheSeconds(300);
cache = new EntitlementCache(store, client, properties);
}
private DeviceCredential cred() {
DeviceCredential c = new DeviceCredential();
c.setDeviceId("dev-1");
c.setDeviceSecret("sec-1");
c.setTeamId(1L);
c.setLinkedAt(LocalDateTime.now());
return c;
}
@Test
void unlinked_returnsEmpty() {
when(store.get()).thenReturn(Optional.empty());
assertTrue(cache.current().isEmpty());
}
@Test
void linked_fetchesAndCachesWithinTtl() {
InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
assertEquals(snap, cache.current().orElseThrow());
// Second read within TTL must not re-fetch.
assertEquals(snap, cache.current().orElseThrow());
verify(client, times(1)).fetchEntitlement(any(), any());
}
@Test
void linked_unreachable_keepsLastKnownSnapshot_failOpenFriendly() {
InstanceEntitlement snap = new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
assertEquals(snap, cache.current().orElseThrow());
// TTL elapsed → refresh attempted, but the SaaS side is now unreachable (null).
cache.invalidate();
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null);
assertEquals(snap, cache.current().orElseThrow(), "stale snapshot retained on failure");
}
@Test
void linked_neverFetched_unreachable_backsOffWithinTtl() {
// No prior snapshot + SaaS unreachable: the gate fails open (empty), but a failed
// attempt stamps the TTL so a second read within the window does NOT re-fetch —
// no sustained hammer of blocking round-trips against a dead endpoint.
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null);
assertTrue(cache.current().isEmpty());
assertTrue(cache.current().isEmpty());
verify(client, times(1)).fetchEntitlement(any(), any());
}
@Test
void linked_revoked_blocksAndDropsStaleEntitlement() {
InstanceEntitlement entitled =
new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(entitled);
assertEquals(entitled, cache.current().orElseThrow());
// Credential revoked: the next refresh is an authoritative deny. The cache must NOT keep
// serving the stale entitled snapshot — it replaces it with a blocked REVOKED one.
cache.invalidate();
when(client.fetchEntitlement(anyString(), anyString()))
.thenThrow(new AccountLinkClient.RevokedException(401));
assertEquals(EntitlementState.REVOKED, cache.current().orElseThrow().state());
}
@Test
void invalidate_forcesRefetch() {
InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
cache.current();
cache.invalidate();
cache.current();
verify(client, times(2)).fetchEntitlement(any(), any());
}
}
@@ -0,0 +1,117 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.accountlink.GateDecision.Reason;
/**
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and
* over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is
* needed.
*/
class InstanceEntitlementGateTest {
private static InstanceEntitlement free() {
return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK);
}
private static InstanceEntitlement exhaustedUnsubscribed() {
return new InstanceEntitlement(false, 0, 0, null, EntitlementState.OVER_LIMIT);
}
private static InstanceEntitlement subscribedWithinCap() {
return new InstanceEntitlement(true, 0, 10, 100L, EntitlementState.OK);
}
private static InstanceEntitlement subscribedOverCap() {
return new InstanceEntitlement(true, 0, 100, 100L, EntitlementState.OK);
}
@Test
void flagOff_allowsEverything_evenBillableUnlinked() {
GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.FLAG_OFF, d.reason());
}
@Test
void manualTool_alwaysFree_evenUnlinked() {
GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.MANUAL_FREE, d.reason());
}
@Test
void billable_notLinked_blocksWithLinkSignal() {
GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty());
assertFalse(d.allowed());
assertEquals(Reason.NOT_LINKED, d.reason());
}
@Test
void billable_linked_entitlementUnreachable_failsOpen() {
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.FAIL_OPEN, d.reason());
}
@Test
void billable_linked_freePoolAvailable_allows() {
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free()));
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(exhaustedUnsubscribed()));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_subscribedWithinCap_allows() {
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(subscribedWithinCap()));
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_subscribedOverCap_blocks() {
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap()));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_revoked_blocksWithRevokedSignal() {
// Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED —
// blocks distinctly from over-limit, even though the snapshot is "present".
InstanceEntitlement revoked =
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked));
assertFalse(d.allowed());
assertEquals(Reason.REVOKED, d.reason());
}
@Test
void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() {
// Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive.
InstanceEntitlement conflicting =
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT);
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
}
@@ -0,0 +1,71 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Verifies {@link InstanceEntitlementGate#evaluate} resolves live state from store + cache. */
class InstanceEntitlementGateWiringTest {
private AccountLinkProperties properties;
private DeviceCredentialStore store;
private EntitlementCache cache;
private InstanceEntitlementGate gate;
@BeforeEach
void setUp() {
properties = new AccountLinkProperties();
properties.setEnabled(true);
store = mock(DeviceCredentialStore.class);
cache = mock(EntitlementCache.class);
gate = new InstanceEntitlementGate(properties, store, cache);
}
@Test
void manualNeverConsultsStoreOrCache() {
GateDecision d = gate.evaluate(false);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.MANUAL_FREE, d.reason());
verify(store, never()).isLinked();
verify(cache, never()).current();
}
@Test
void billableUnlinkedDoesNotHitCache() {
when(store.isLinked()).thenReturn(false);
GateDecision d = gate.evaluate(true);
assertFalse(d.allowed());
assertEquals(GateDecision.Reason.NOT_LINKED, d.reason());
verify(cache, never()).current();
}
@Test
void billableLinkedConsultsCache() {
when(store.isLinked()).thenReturn(true);
when(cache.current())
.thenReturn(
Optional.of(
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK)));
GateDecision d = gate.evaluate(true);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.ENTITLED, d.reason());
}
@Test
void flagOffShortCircuits() {
properties.setEnabled(false);
GateDecision d = gate.evaluate(true);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.FLAG_OFF, d.reason());
verify(store, never()).isLinked();
}
}
@@ -0,0 +1,61 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
@ExtendWith(MockitoExtension.class)
class InstanceEntitlementInterceptorTest {
@Mock private InstanceEntitlementGate gate;
private boolean preHandle(MockHttpServletResponse response) throws Exception {
return new InstanceEntitlementInterceptor(gate)
.preHandle(
new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object());
}
@Test
void allowsWhenGateAllows() throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
MockHttpServletResponse response = new MockHttpServletResponse();
assertTrue(preHandle(response));
assertEquals(200, response.getStatus());
}
@Test
void blocksWith402AndLinkSignalWhenGateBlocks() throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED));
MockHttpServletResponse response = new MockHttpServletResponse();
assertFalse(preHandle(response));
assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), response.getStatus());
assertEquals("application/json", response.getContentType());
assertTrue(response.getContentAsString().contains("ACCOUNT_LINK_REQUIRED"));
assertTrue(response.getContentAsString().contains("NOT_LINKED"));
}
@Test
void failsOpenWhenGateThrows() throws Exception {
// A DB / SaaS blip while resolving entitlement must never hard-block billable work.
when(gate.evaluate(anyBoolean()))
.thenThrow(new RuntimeException("entitlement source down"));
MockHttpServletResponse response = new MockHttpServletResponse();
assertTrue(preHandle(response));
assertEquals(200, response.getStatus());
}
}
@@ -17,6 +17,9 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
/**
* Tests for {@link FolderAccessGuard}: folder access is fail-closed, confined to the configured
@@ -26,12 +29,14 @@ class FolderAccessGuardTest {
@TempDir Path tempDir;
private final SourceStore sourceStore = new InProcessSourceStore();
private FolderAccessGuard guard(List<String> allowedRoots, String... activeProfiles) {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(allowedRoots);
StandardEnvironment environment = new StandardEnvironment();
environment.setActiveProfiles(activeProfiles);
return new FolderAccessGuard(properties, environment);
return new FolderAccessGuard(properties, environment, sourceStore);
}
@Test
@@ -93,7 +98,23 @@ class FolderAccessGuardTest {
assertFalse(guard.usesFolderAccess(policy(List.of(), OutputSpec.inline())));
}
private static Policy policy(List<InputSpec> sources, OutputSpec output) {
return new Policy("p1", "p", "owner", true, null, sources, List.of(), output);
private Policy policy(List<InputSpec> sources, OutputSpec output) {
List<String> sourceIds =
sources.stream()
.map(
spec ->
sourceStore
.save(
new Source(
null,
"src",
spec.type(),
spec.options(),
true,
"owner",
null))
.id())
.toList();
return new Policy("p1", "p", "owner", true, null, sourceIds, List.of(), output);
}
}
@@ -17,6 +17,8 @@ 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;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only
@@ -36,18 +38,27 @@ class PolicyAccessGuardTest {
}
@Test
void visibleFiltersToTheCallersTeam() {
void visibleFromLoadsOnlyTheCallersTeam() {
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);
PolicyStore store = new InProcessPolicyStore();
store.save(inTeam(1L));
store.save(inTeam(2L));
store.save(inTeam(1L));
store.save(inTeam(null));
List<Policy> visible = guard(true).visibleFrom(store);
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));
void visibleFromReturnsEverythingWhenLoginDisabled() {
PolicyStore store = new InProcessPolicyStore();
store.save(inTeam(1L));
store.save(inTeam(2L));
assertEquals(2, guard(false).visibleFrom(store).size());
}
@Test
@@ -79,6 +90,6 @@ class PolicyAccessGuardTest {
private static Policy inTeam(Long teamId) {
return new Policy(
"p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId);
null, "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId);
}
}
@@ -40,6 +40,9 @@ import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
@ExtendWith(MockitoExtension.class)
@DisplayName("PolicyController")
@@ -48,9 +51,12 @@ class PolicyControllerTest {
@Mock private PolicyRunner policyRunner;
@Mock private PolicyRunRegistry runRegistry;
@Mock private stirling.software.proprietary.policy.store.PolicyStore policyStore;
@Mock private SourceStore sourceStore;
@Mock private SourceAccessGuard sourceAccessGuard;
@Mock private PolicyValidator policyValidator;
@Mock private PolicyAccessGuard policyAccessGuard;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private PolicyTriggerManager policyTriggerManager;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
@@ -65,9 +71,12 @@ class PolicyControllerTest {
policyRunner,
runRegistry,
policyStore,
sourceStore,
sourceAccessGuard,
policyValidator,
policyAccessGuard,
policyManagementAuthority,
policyTriggerManager,
applicationProperties,
tempFileManager,
jobOwnershipService);
@@ -214,6 +223,7 @@ class PolicyControllerTest {
assertThat(response.getBody().owner()).isEqualTo("alice");
assertThat(response.getBody().teamId()).isEqualTo(7L);
verify(policyValidator).validate(any());
verify(policyTriggerManager).notifyPoliciesChanged();
}
@Test
@@ -229,6 +239,7 @@ class PolicyControllerTest {
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
verify(policyStore, never()).save(any());
verify(policyTriggerManager, never()).notifyPoliciesChanged();
}
@Test
@@ -295,8 +306,7 @@ class PolicyControllerTest {
@DisplayName("listPolicies returns team-visible policies")
void listVisible() {
List<Policy> all = List.of(policy("a", 1L), policy("b", 1L));
when(policyStore.all()).thenReturn(all);
when(policyAccessGuard.visible(all)).thenReturn(all);
when(policyAccessGuard.visibleFrom(policyStore)).thenReturn(all);
List<Policy> result = controller.listPolicies();
@@ -355,6 +365,7 @@ class PolicyControllerTest {
ResponseEntity<Void> response = controller.deletePolicy("a");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
verify(policyTriggerManager).notifyPoliciesChanged();
}
@Test
@@ -369,6 +380,7 @@ class PolicyControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(policyStore, never()).delete(any());
verify(policyTriggerManager, never()).notifyPoliciesChanged();
}
@Test
@@ -33,6 +33,9 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
/**
* Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs. Verifies
@@ -45,11 +48,12 @@ class PolicyRunnerTest {
@Mock private PolicyEngine policyEngine;
@Mock private InputSource folderSource;
private final SourceStore sourceStore = new InProcessSourceStore();
private PolicyRunner runner;
@BeforeEach
void setUp() {
runner = new PolicyRunner(policyEngine, List.of(folderSource));
runner = new PolicyRunner(policyEngine, List.of(folderSource), sourceStore);
}
@Test
@@ -145,15 +149,22 @@ class PolicyRunnerTest {
verifyNoInteractions(folderSource);
}
private static Policy policy(List<InputSpec> sources) {
/** Persists each spec as a source and returns a policy referencing them by id. */
private Policy policy(List<InputSpec> sources) {
List<String> sourceIds =
sources.stream().map(spec -> sourceStore.save(sourceFrom(spec)).id()).toList();
return new Policy(
"p1",
"p",
"owner",
true,
null,
sources,
sourceIds,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
private static Source sourceFrom(InputSpec spec) {
return new Source(null, "src", spec.type(), spec.options(), true, "owner", null);
}
}
@@ -23,6 +23,9 @@ import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.output.PolicyOutputSink;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
/** Tests for {@link PolicyValidator}: routes each facet to its handler and surfaces failures. */
@@ -33,12 +36,14 @@ class PolicyValidatorTest {
@Mock private InputSource inputSource;
@Mock private PolicyOutputSink outputSink;
private final SourceStore sourceStore = new InProcessSourceStore();
private PolicyValidator validator;
@BeforeEach
void setUp() {
validator =
new PolicyValidator(List.of(trigger), List.of(inputSource), List.of(outputSink));
new PolicyValidator(
List.of(trigger), List.of(inputSource), List.of(outputSink), sourceStore);
}
@Test
@@ -51,7 +56,7 @@ class PolicyValidatorTest {
validator.validate(policy);
verify(trigger).validate(policy);
verify(inputSource).validate(policy.sources().get(0));
verify(inputSource).validate(InputSpec.folder("/in"));
verify(outputSink).validate(policy.output());
}
@@ -88,27 +93,35 @@ class PolicyValidatorTest {
assertTrue(ex.getMessage().contains("unknown trigger type"));
}
private static Policy policy(String triggerType) {
private Policy policy(String triggerType) {
return new Policy(
"p1",
"p",
"owner",
true,
new TriggerConfig(triggerType, Map.of()),
List.of(InputSpec.folder("/in")),
List.of(folderSourceId()),
List.of(),
OutputSpec.inline());
}
private static Policy manualOnly() {
private Policy manualOnly() {
return new Policy(
"p1",
"p",
"owner",
true,
null,
List.of(InputSpec.folder("/in")),
List.of(folderSourceId()),
List.of(),
OutputSpec.inline());
}
/** Persists a folder source ("/in") and returns its id for a policy to reference. */
private String folderSourceId() {
InputSpec spec = InputSpec.folder("/in");
return sourceStore
.save(new Source(null, "src", spec.type(), spec.options(), true, "owner", null))
.id();
}
}
@@ -25,6 +25,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.FileReadinessChecker;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
/** Tests for {@link FolderInputSource}: consume (claim + route) and snapshot (read-only) modes. */
@ExtendWith(MockitoExtension.class)
@@ -40,7 +41,9 @@ class FolderInputSourceTest {
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString()));
FolderAccessGuard guard = new FolderAccessGuard(properties, new StandardEnvironment());
FolderAccessGuard guard =
new FolderAccessGuard(
properties, new StandardEnvironment(), new InProcessSourceStore());
source = new FolderInputSource(readinessChecker, guard);
// Lenient: the missing-dir / nonexistent-dir cases return before any readiness check.
lenient().when(readinessChecker.isReady(any())).thenReturn(true);
@@ -22,6 +22,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.ResultFile;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
/** Tests for {@link FolderOutputSink}: outputs are written to the configured directory on disk. */
class FolderOutputSinkTest {
@@ -34,7 +35,10 @@ class FolderOutputSinkTest {
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString()));
sink = new FolderOutputSink(new FolderAccessGuard(properties, new StandardEnvironment()));
sink =
new FolderOutputSink(
new FolderAccessGuard(
properties, new StandardEnvironment(), new InProcessSourceStore()));
}
@Test
@@ -0,0 +1,114 @@
package stirling.software.proprietary.policy.source;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Tests for {@link JpaSourceStore}'s entity mapping. The repository is mocked; real Hibernate/H2
* persistence is exercised at application boot, mirroring {@link
* stirling.software.proprietary.policy.store.JpaPolicyStore}'s test convention.
*/
@ExtendWith(MockitoExtension.class)
class JpaSourceStoreTest {
@Mock private SourceRepository repository;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private JpaSourceStore store;
@BeforeEach
void setUp() {
store = new JpaSourceStore(repository, objectMapper);
}
@Test
void saveAssignsAnIdAndPersistsTheSourceAsJson() {
Source saved =
store.save(
new Source(
null,
"Claims intake",
"folder",
Map.of("directory", "/in/claims"),
true,
"alice",
7L));
assertNotNull(saved.id());
ArgumentCaptor<SourceEntity> captor = ArgumentCaptor.forClass(SourceEntity.class);
verify(repository).save(captor.capture());
SourceEntity entity = captor.getValue();
assertEquals(saved.id(), entity.getId());
assertEquals("folder", entity.getType());
assertEquals("alice", entity.getOwner());
assertEquals(Long.valueOf(7L), entity.getTeamId());
assertTrue(entity.isEnabled());
// The stored JSON round-trips back to an equal source.
assertEquals(saved, objectMapper.readValue(entity.getSourceJson(), Source.class));
}
@Test
void getDeserializesTheSourceFromJson() {
Source source =
new Source("s1", "Claims", "folder", Map.of("directory", "/in"), true, "alice", 1L);
when(repository.findById("s1")).thenReturn(Optional.of(entityFor(source)));
assertEquals(source, store.get("s1").orElseThrow());
}
@Test
void allDeserializesEverySource() {
Source a = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 1L);
when(repository.findAll()).thenReturn(List.of(entityFor(a)));
assertEquals(List.of(a), store.all());
}
@Test
void findByTeamDelegatesToTheScopedQuery() {
Source mine = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 7L);
when(repository.findByTeam(7L)).thenReturn(List.of(entityFor(mine)));
assertEquals(List.of(mine), store.findByTeam(7L));
}
@Test
void deleteReturnsWhetherTheSourceExisted() {
when(repository.existsById("s1")).thenReturn(true);
assertTrue(store.delete("s1"));
verify(repository).deleteById("s1");
when(repository.existsById("missing")).thenReturn(false);
assertFalse(store.delete("missing"));
}
private SourceEntity entityFor(Source source) {
SourceEntity entity = new SourceEntity();
entity.setId(source.id());
entity.setName(source.name());
entity.setType(source.type());
entity.setOwner(source.owner());
entity.setTeamId(source.teamId());
entity.setEnabled(source.enabled());
entity.setSourceJson(objectMapper.writeValueAsString(source));
return entity;
}
}
@@ -0,0 +1,123 @@
package stirling.software.proprietary.policy.source;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
/**
* Tests for {@link SourceController}'s delete guard: a source still referenced by a policy is
* protected (409), while an unreferenced one is removed. Login is disabled, so editing and team
* scoping pass through and the reference check is exercised on its own.
*/
class SourceControllerTest {
private final SourceStore sourceStore = new InProcessSourceStore();
private final PolicyStore policyStore = new InProcessPolicyStore();
private PolicyTriggerManager triggerManager;
private SourceController controller;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(false);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
SourceOverviewService overviewService =
new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
triggerManager = mock(PolicyTriggerManager.class);
// A permissive input source so config validation passes and save can be exercised.
InputSource folderInput = mock(InputSource.class);
when(folderInput.supports(any())).thenReturn(true);
controller =
new SourceController(
sourceStore,
sourceGuard,
overviewService,
policyStore,
policyGuard,
authority,
triggerManager,
properties,
List.of(folderInput));
}
@Test
void deletingAReferencedSourceConflicts() {
Source source = sourceStore.save(folderSource());
policyStore.save(policyReferencing("Redact incoming", source.id()));
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> controller.delete(source.id()));
assertEquals(409, ex.getStatusCode().value());
assertTrue(sourceStore.get(source.id()).isPresent());
}
@Test
void deletingAnUnreferencedSourceSucceeds() {
Source source = sourceStore.save(folderSource());
ResponseEntity<Void> response = controller.delete(source.id());
assertEquals(204, response.getStatusCode().value());
assertTrue(sourceStore.get(source.id()).isEmpty());
// A deletable source is referenced by no policy, so no watch registration can change.
verify(triggerManager, never()).notifyPoliciesChanged();
}
@Test
void savingASourceReSyncsTriggerRegistrations() {
controller.save(folderSource());
verify(triggerManager).notifyPoliciesChanged();
}
@Test
void deletingAMissingSourceIsNotFound() {
assertEquals(404, controller.delete("nope").getStatusCode().value());
}
private static Source folderSource() {
return new Source(
null, "Claims intake", "folder", Map.of("directory", "/in"), true, "owner", null);
}
private static Policy policyReferencing(String name, String sourceId) {
return new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceId),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
}
@@ -0,0 +1,187 @@
package stirling.software.proprietary.policy.source;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link SourceOverviewService}: each source appears exactly once, annotated with the
* policies that reference it. Login is disabled, so the team guards pass everything through and the
* reference counting is exercised directly.
*/
class SourceOverviewServiceTest {
private final SourceStore sourceStore = new InProcessSourceStore();
private final PolicyStore policyStore = new InProcessPolicyStore();
private SourceOverviewService service;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(false);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
service = new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
}
@Test
void eachSourceAppearsOnceWithItsReferenceCount() {
Source a = source("A", "/a");
Source b = source("B", "/b");
Source c = source("C unused", "/c");
policyReferencing("P1", a.id());
policyReferencing("P2", a.id(), b.id());
SourcesResponse response = service.overview();
assertEquals(3, response.sources().size());
// Sorted most-referenced first, so the shared source A leads.
assertEquals(a.id(), response.sources().get(0).id());
SourceView av = find(response, a.id());
assertEquals(2, av.referenceCount());
assertEquals("active", av.status());
assertTrue(
av.referencingPolicies().stream()
.map(SourceView.PolicyRef::name)
.toList()
.containsAll(List.of("P1", "P2")));
assertTrue(
av.config().stream()
.anyMatch(
row ->
row.label().equals("Directory")
&& row.value().equals("/a")));
assertEquals(1, find(response, b.id()).referenceCount());
SourceView cv = find(response, c.id());
assertEquals(0, cv.referenceCount());
assertEquals("unused", cv.status());
// KPI strip: total, in-use, orphaned.
assertEquals(List.of(3L, 2L, 1L), response.kpis().stream().map(SourceKpi::value).toList());
}
@Test
void aDisabledSourceReadsAsDisabled() {
Source disabled =
sourceStore.save(
new Source(
null,
"Paused",
"folder",
Map.of("directory", "/d"),
false,
"owner",
null));
assertEquals("disabled", find(service.overview(), disabled.id()).status());
}
@Test
void overviewLoadsOnlyTheCallersTeam() {
// Login on, caller is on team 1. Another team's source and policy must be invisible, and a
// cross-team policy referencing our source must not inflate its reference count.
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(true);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
when(authority.currentUserTeamId()).thenReturn(1L);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
SourceOverviewService scoped =
new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
Source ours = teamSource("Ours", "/ours", 1L);
teamSource("Theirs", "/theirs", 2L);
teamPolicy("Our policy", 1L, ours.id());
teamPolicy("Their policy", 2L, ours.id());
SourcesResponse response = scoped.overview();
assertEquals(1, response.sources().size());
SourceView view = response.sources().get(0);
assertEquals(ours.id(), view.id());
assertEquals(1, view.referenceCount());
assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(SourceKpi::value).toList());
}
@Test
void documentVolumeIsNotTrackedYet() {
Source a = source("A", "/a");
assertNull(find(service.overview(), a.id()).docsTotal());
}
private Source source(String name, String directory) {
return sourceStore.save(
new Source(
null, name, "folder", Map.of("directory", directory), true, "owner", null));
}
private Source teamSource(String name, String directory, Long teamId) {
return sourceStore.save(
new Source(
null,
name,
"folder",
Map.of("directory", directory),
true,
"owner",
teamId));
}
private void policyReferencing(String name, String... sourceIds) {
policyStore.save(
new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceIds),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
}
private void teamPolicy(String name, Long teamId, String... sourceIds) {
policyStore.save(
new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceIds),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
teamId));
}
private static SourceView find(SourcesResponse response, String id) {
return response.sources().stream()
.filter(view -> view.id().equals(id))
.findFirst()
.orElseThrow();
}
}
@@ -18,7 +18,6 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
@@ -55,7 +54,7 @@ class JpaPolicyStoreTest {
"alice",
true,
new TriggerConfig("schedule", Map.of()),
List.of(InputSpec.folder("/in")),
List.of("src-in"),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
@@ -88,6 +87,46 @@ class JpaPolicyStoreTest {
assertEquals(policy, store.get("p1").orElseThrow());
}
@Test
void saveDenormalizesTeamIdForScopedQueries() {
store.save(
new Policy(
"p1",
"scoped",
"alice",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
9L));
ArgumentCaptor<PolicyEntity> captor = ArgumentCaptor.forClass(PolicyEntity.class);
verify(repository).save(captor.capture());
assertEquals(Long.valueOf(9L), captor.getValue().getTeamId());
}
@Test
void findByTeamDelegatesToTheScopedQuery() {
Policy policy =
new Policy(
"p1",
"ours",
"alice",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
9L);
when(repository.findByTeam(9L)).thenReturn(List.of(entityFor(policy)));
List<Policy> mine = store.findByTeam(9L);
assertEquals(1, mine.size());
assertEquals("p1", mine.get(0).id());
}
@Test
void findByTriggerTypeUsesTheEnabledQuery() {
Policy policy =
@@ -125,6 +164,7 @@ class JpaPolicyStoreTest {
entity.setOwner(policy.owner());
entity.setEnabled(policy.enabled());
entity.setTriggerType(policy.trigger() == null ? null : policy.trigger().type());
entity.setTeamId(policy.teamId());
entity.setPolicyJson(objectMapper.writeValueAsString(policy));
return entity;
}
@@ -32,6 +32,9 @@ import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
@@ -50,6 +53,7 @@ class FolderWatchTriggerTest {
@TempDir Path tempDir;
private final SourceStore sourceStore = new InProcessSourceStore();
private FolderWatchTrigger trigger;
@BeforeEach
@@ -59,6 +63,7 @@ class FolderWatchTriggerTest {
policyStore,
policyRunner,
List.of(folderSource),
sourceStore,
new ApplicationProperties());
lenient().when(folderSource.supports(any())).thenReturn(true);
lenient()
@@ -160,18 +165,59 @@ class FolderWatchTriggerTest {
}
}
@Test
void onPoliciesChangedSyncsRegistrationsImmediately() throws Exception {
Path dir = Files.createDirectories(tempDir.resolve("watched"));
Policy p = folderWatch("p", List.of(InputSpec.folder(dir.toString())));
WatchService service = FileSystems.getDefault().newWatchService();
try {
trigger.watchService = service;
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(p));
// The mutation hook registers the new policy's directory without waiting for a
// reconcile.
trigger.onPoliciesChanged();
assertEquals(Set.of(normalized(dir.toString())), trigger.watchedDirs());
// Once the policy is gone, the same hook cancels its registration.
when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of());
trigger.onPoliciesChanged();
assertEquals(Set.of(), trigger.watchedDirs());
} finally {
service.close();
}
}
private static Path normalized(String dir) {
return Path.of(dir).toAbsolutePath().normalize();
}
private static Policy folderWatch(String id, List<InputSpec> sources) {
/** Persists each spec as a source and returns a folder-watch policy referencing them by id. */
private Policy folderWatch(String id, List<InputSpec> sources) {
List<String> sourceIds =
sources.stream()
.map(
spec ->
sourceStore
.save(
new Source(
null,
"src",
spec.type(),
spec.options(),
true,
"owner",
null))
.id())
.toList();
return new Policy(
id,
"watcher",
"owner",
true,
new TriggerConfig("folder-watch", Map.of()),
sources,
sourceIds,
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline());
}
@@ -25,6 +25,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@@ -48,7 +49,7 @@ class ServerCertificateServiceTest {
@BeforeEach
void setUp() {
service = new ServerCertificateService(licenseKeyChecker);
service = new ServerCertificateService(licenseKeyChecker, new ApplicationProperties());
// default: feature enabled, validity 365, org Stirling-PDF, no regenerate
ReflectionTestUtils.setField(service, "enabled", true);
ReflectionTestUtils.setField(service, "organizationName", "Stirling-PDF");
@@ -0,0 +1,161 @@
package stirling.software.saas.accountlink;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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 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.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Account-link registration surface (combined-billing "Mode A").
*
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
* JWT.
*
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
* 404. Leader-only, and the team is always derived from the caller (never the request body).
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/account-link")
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkController {
private final AccountLinkService service;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public AccountLinkController(
AccountLinkService service,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
this.service = service;
this.memberRepo = memberRepo;
this.userRepository = userRepository;
}
/** Optional display name for the instance (hostname / label). */
public record RegisterRequest(String name) {}
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
public record RegisterResponse(
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
public record InstanceRow(
Long instanceId,
String deviceId,
String name,
String createdAt,
String lastSeenAt,
boolean revoked) {}
@PostMapping("/register")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<RegisterResponse> register(
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
String name = req != null ? req.name() : null;
AccountLinkService.RegisteredInstance reg =
service.register(lt.teamId(), lt.userId(), name);
return ResponseEntity.status(HttpStatus.CREATED)
.body(
new RegisterResponse(
reg.instanceId(),
lt.teamId(),
reg.deviceId(),
reg.deviceSecret(),
reg.name()));
}
@GetMapping("/instances")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
List<InstanceRow> rows =
service.list(lt.teamId()).stream()
.map(
i ->
new InstanceRow(
i.getInstanceId(),
i.getDeviceId(),
i.getName(),
i.getCreatedAt() != null
? i.getCreatedAt().toString()
: null,
i.getLastSeenAt() != null
? i.getLastSeenAt().toString()
: null,
i.getRevokedAt() != null))
.toList();
return ResponseEntity.ok(rows);
}
@PostMapping("/instances/{instanceId}/revoke")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
boolean ok = service.revoke(lt.teamId(), instanceId);
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
// ---------------------------------------------------------------------------------------
// Helpers — team always derived from the caller; instance linking is a leader (billing) action.
// ---------------------------------------------------------------------------------------
/**
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
*/
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
private LeaderTeam resolveLeaderTeam(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
TeamMembership m = rows.get(0);
if (m.getRole() != TeamRole.LEADER) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
}
}
@@ -0,0 +1,117 @@
package stirling.software.saas.accountlink;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
/**
* Account-link instance registration + lifecycle (combined-billing "Mode A").
*
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
* unattended entitlement reads with that credential.
*
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkService {
/** 32 bytes of entropy → URL-safe secret; high enough that an unsalted SHA-256 hash is fine. */
private static final int SECRET_BYTES = 32;
private final LinkedInstanceRepository repo;
private final SecureRandom random = new SecureRandom();
public AccountLinkService(LinkedInstanceRepository repo) {
this.repo = repo;
}
/** Result of {@link #register}; {@code deviceSecret} is plaintext and returned exactly once. */
public record RegisteredInstance(
Long instanceId, String deviceId, String deviceSecret, String name) {}
/**
* Creates a new linked instance for {@code teamId}, returning the one-time plaintext secret.
*/
@Transactional
public RegisteredInstance register(Long teamId, Long createdByUserId, String name) {
String deviceId = UUID.randomUUID().toString();
String deviceSecret = randomSecret();
LinkedInstance instance = new LinkedInstance();
instance.setTeamId(teamId);
instance.setCreatedByUserId(createdByUserId);
instance.setDeviceId(deviceId);
instance.setDeviceSecretHash(sha256Hex(deviceSecret));
instance.setName(name);
repo.save(instance);
log.info(
"Account-link: registered instance {} (device {}) for team {}",
instance.getInstanceId(),
deviceId,
teamId);
return new RegisteredInstance(instance.getInstanceId(), deviceId, deviceSecret, name);
}
/**
* All instances for a team, newest first (includes revoked, for the "Linked instances" list).
*/
@Transactional(readOnly = true)
public List<LinkedInstance> list(Long teamId) {
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
}
/**
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
* a different team (so a caller can never revoke another team's instance). Idempotent.
*/
@Transactional
public boolean revoke(Long teamId, Long instanceId) {
Optional<LinkedInstance> found = repo.findById(instanceId);
if (found.isEmpty() || !found.get().getTeamId().equals(teamId)) {
return false;
}
LinkedInstance instance = found.get();
if (instance.getRevokedAt() == null) {
instance.setRevokedAt(LocalDateTime.now());
repo.save(instance);
log.info("Account-link: revoked instance {} for team {}", instanceId, teamId);
}
return true;
}
private String randomSecret() {
byte[] buf = new byte[SECRET_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
static String sha256Hex(String value) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
}
@@ -0,0 +1,108 @@
package stirling.software.saas.accountlink;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
*
* <p>Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
* LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
* stored hash. On a match it sets a {@link LinkedInstanceAuthenticationToken} (team-scoped, {@code
* ROLE_LINKED_INSTANCE}); otherwise it does nothing and lets the chain continue (→ 401 on a
* protected endpoint).
*
* <p>Read-only and <b>path-scoped to {@code /api/v1/instance/**}</b>: the device principal is never
* established for user-facing endpoints, so a leaked secret can only reach the instance surface.
* Gated behind {@code stirling.billing.account-link.enabled}; absent when the flag is off, so
* {@code SupabaseSecurityConfig} never wires it in.
*/
@Slf4j
@Component
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class DeviceCredentialAuthenticationFilter extends OncePerRequestFilter {
static final String HEADER_DEVICE_ID = "X-Device-Id";
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
static final String INSTANCE_PATH_PREFIX = "/api/v1/instance/";
private final LinkedInstanceRepository repo;
public DeviceCredentialAuthenticationFilter(LinkedInstanceRepository repo) {
this.repo = repo;
}
/** Only the instance surface uses the device credential; everything else skips this filter. */
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith(INSTANCE_PATH_PREFIX);
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String deviceId = request.getHeader(HEADER_DEVICE_ID);
String secret = request.getHeader(HEADER_DEVICE_SECRET);
if (deviceId != null
&& secret != null
&& SecurityContextHolder.getContext().getAuthentication() == null) {
repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
.ifPresent(
instance -> {
if (constantTimeEquals(
AccountLinkService.sha256Hex(secret),
instance.getDeviceSecretHash())) {
SecurityContextHolder.getContext()
.setAuthentication(
new LinkedInstanceAuthenticationToken(
instance.getInstanceId(),
instance.getTeamId()));
// Stamp liveness, best-effort. Auth is already set above; a
// transient write failure must NOT 500 an otherwise-valid
// request, so swallow it. Targeted single-column UPDATE (not a
// full save) so a concurrent revoke between the read above and
// this write can't be clobbered back to active.
try {
repo.touchLastSeen(
instance.getInstanceId(), LocalDateTime.now());
} catch (RuntimeException e) {
log.debug(
"last_seen_at update failed for device {}: {}",
deviceId,
e.getMessage());
}
} else {
log.debug("Device credential mismatch for device {}", deviceId);
}
});
}
chain.doFilter(request, response);
}
private static boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
return MessageDigest.isEqual(
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,131 @@
package stirling.software.saas.accountlink;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
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.EntitlementState;
/**
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
* credential</b> — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
* credential is scoped here and nowhere else.
*
* <p>{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
* device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
* /entitlement} is the read the local gate consumes — the same team-scoped snapshot the FE wallet
* sees, trimmed to the fields the gate needs (subscription, free pool, period spend/cap, state),
* and built on the same device-credential auth.
*
* <p>Gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → 404.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/instance")
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceController {
private final EntitlementService entitlementService;
private final TeamBillingService billingService;
private final AccountLinkService accountLinkService;
public InstanceController(
EntitlementService entitlementService,
TeamBillingService billingService,
AccountLinkService accountLinkService) {
this.entitlementService = entitlementService;
this.billingService = billingService;
this.accountLinkService = accountLinkService;
}
public record WhoAmIResponse(Long instanceId, Long teamId) {}
/**
* Minimal entitlement view the local gate enforces against. {@code periodCapUnits} null =
* uncapped. {@code state} is the coarse OK / OVER_LIMIT vocabulary the instance gate parses
* (see {@link #coarseState}), not the SaaS feature-state enum.
*/
public record EntitlementResponse(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
String state) {}
@GetMapping("/whoami")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
public ResponseEntity<WhoAmIResponse> whoami(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
// Belt-and-braces: hasRole already guarantees this, but never leak a non-instance
// principal.
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(new WhoAmIResponse(token.getInstanceId(), token.getTeamId()));
}
/**
* Revokes this instance's own credential — a credential can mark itself revoked the same way a
* session logs itself out. Called by the proprietary backend on local unlink so the SaaS row
* gets {@code revoked_at} set; idempotent (already-revoked → still 204).
*/
@PostMapping("/revoke-self")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
public ResponseEntity<Void> revokeSelf(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
accountLinkService.revoke(token.getTeamId(), token.getInstanceId());
return ResponseEntity.noContent().build();
}
@GetMapping("/entitlement")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
@Transactional(readOnly = true)
public ResponseEntity<EntitlementResponse> entitlement(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Long teamId = token.getTeamId();
// Same composition the FE wallet uses: billing facts (subscription, free pool) from
// TeamBillingService, period spend/cap + state from the entitlement snapshot.
TeamBillingContext billing = billingService.forTeam(teamId);
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
return ResponseEntity.ok(
new EntitlementResponse(
billing.subscribed(),
billing.freeRemainingUnits(),
snap.periodSpendUnits(),
snap.periodCapUnits(),
coarseState(snap.state())));
}
/**
* Collapses the SaaS feature-state machine into the OK / OVER_LIMIT vocabulary the instance
* gate parses. DEGRADED means automation + AI are gated off — which, for a gate that governs
* only billable work (manual tools are free-pathed before it), is exactly OVER_LIMIT; FULL and
* WARNED are OK.
*/
private static String coarseState(EntitlementState state) {
return state == EntitlementState.DEGRADED ? "OVER_LIMIT" : "OK";
}
}
@@ -0,0 +1,77 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
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;
/**
* One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
* linked_instance}, V22).
*
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
* short-lived Supabase JWT. Registration mints a {@code device_id} (public) plus a {@code
* device_secret} (high-entropy, returned once and stored only on the instance — we keep an unsalted
* SHA-256 hash, the same posture as API keys). The instance authenticates its unattended
* entitlement reads with that device credential, so no long-lived user JWT lives on the server
* side.
*
* <p>{@code revoked_at IS NULL} means active; revoking sets it and the credential stops
* authenticating. The whole surface is gated behind {@code stirling.billing.account-link.enabled}.
*/
@Entity
@Table(name = "linked_instance")
@Getter
@Setter
@NoArgsConstructor
public class LinkedInstance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "instance_id")
private Long instanceId;
@Column(name = "team_id", nullable = false)
private Long teamId;
/**
* Admin who registered the instance; informational (no FK, so a user delete never offlines it).
*/
@Column(name = "created_by_user_id")
private Long createdByUserId;
/** Public, non-secret identifier the instance presents on every request. */
@Column(name = "device_id", nullable = false, unique = true, length = 64)
private String deviceId;
/** SHA-256 hex of the device secret; the secret itself is never stored. */
@Column(name = "device_secret_hash", nullable = false, length = 64)
private String deviceSecretHash;
/** Operator-set display label (hostname etc.) for the "Linked instances" list. */
@Column(name = "name", length = 255)
private String name;
/** Insert time; Hibernate populates this on persist (DB DEFAULT is belt-and-braces). */
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/** Stamped when the device credential last authenticated; powers staleness display. */
@Column(name = "last_seen_at")
private LocalDateTime lastSeenAt;
/** NULL = active. Set on unlink/revoke; a revoked credential fails authentication. */
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
}
@@ -0,0 +1,45 @@
package stirling.software.saas.accountlink;
import java.util.List;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
/**
* Authentication for a linked self-hosted instance (combined-billing "Mode A").
*
* <p>Deliberately <em>not</em> a user: the principal is the instance ({@code instanceId}) bound to
* a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
* User} and creates no user row — a device credential can never act as a person, only as its team's
* instance, and only on the instance-facing endpoints.
*/
public class LinkedInstanceAuthenticationToken extends AbstractAuthenticationToken {
private final Long instanceId;
private final Long teamId;
public LinkedInstanceAuthenticationToken(Long instanceId, Long teamId) {
super(List.of(new SimpleGrantedAuthority("ROLE_LINKED_INSTANCE")));
this.instanceId = instanceId;
this.teamId = teamId;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null; // the secret is never retained on the authentication
}
@Override
public Object getPrincipal() {
return instanceId;
}
public Long getInstanceId() {
return instanceId;
}
public Long getTeamId() {
return teamId;
}
}
@@ -0,0 +1,43 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
/**
* Data access for {@link LinkedInstance}. Plain Spring Data JPA against {@code stirling_pdf} —
* native schema access, no RPC, consistent with the rest of the SaaS backend.
*/
public interface LinkedInstanceRepository extends JpaRepository<LinkedInstance, Long> {
/**
* Active-credential lookup for the device-credential auth filter (revoked rows never match).
*/
Optional<LinkedInstance> findByDeviceIdAndRevokedAtIsNull(String deviceId);
/** Backs the portal "Linked instances" list (includes revoked, newest first). */
List<LinkedInstance> findByTeamIdOrderByCreatedAtDesc(Long teamId);
/** Active (non-revoked) linked instances on a team — the orphan guard's count. */
long countByTeamIdAndRevokedAtIsNull(Long teamId);
/**
* Stamps liveness on a single instance. A targeted single-column UPDATE rather than a
* full-entity {@code save}: the auth filter loads the instance outside a transaction, so a full
* save would write back the stale (in-memory {@code null}) {@code revoked_at} and could
* silently un-revoke a credential that was revoked between the read and the write. The {@code
* revoked_at IS NULL} guard makes this a no-op once revoked.
*/
@Modifying
@Transactional
@Query(
"UPDATE LinkedInstance li SET li.lastSeenAt = :now "
+ "WHERE li.instanceId = :instanceId AND li.revokedAt IS NULL")
int touchLastSeen(@Param("instanceId") Long instanceId, @Param("now") LocalDateTime now);
}
@@ -14,12 +14,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Profile("saas")
@EnableJpaRepositories(
basePackages = {
"stirling.software.saas.accountlink",
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository"
})
@EntityScan({
"stirling.software.saas.accountlink",
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
@@ -0,0 +1,150 @@
package stirling.software.saas.payg.api;
import java.time.LocalDateTime;
import java.util.List;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
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.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.stripe.StripeInvoiceDao;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Read-only Stripe-invoices surface for the linked org's billing page.
*
* <p>{@code GET /api/v1/payg/invoices?limit=N} returns the team's most recent Stripe invoices,
* sourced from the {@code stripe.invoices} table the Sync Engine maintains. The caller's team is
* resolved from the authenticated principal (same pattern as {@link PaygWalletController}); we
* never trust a team id from the request.
*
* <p>Defensive: when the team has no {@code stripe_customer_id} (not subscribed, or pre-checkout)
* or the {@code stripe} schema isn't synced (H2 tests, sync engine off), we return {@code 200} with
* an empty list rather than 500 — the UI renders "no invoices yet". This keeps the page working
* through every link/subscription state.
*
* <p>{@code hostedInvoiceUrl} + {@code invoicePdf} are Stripe-hosted links the portal can deep-link
* from. We don't proxy the PDF ourselves; Stripe handles auth + caching.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/payg")
@Profile("saas")
public class PaygInvoicesController {
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private final StripeInvoiceDao invoiceDao;
private final PaygTeamExtensionsRepository extRepo;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public PaygInvoicesController(
StripeInvoiceDao invoiceDao,
PaygTeamExtensionsRepository extRepo,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
this.invoiceDao = Objects.requireNonNull(invoiceDao, "invoiceDao");
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
}
/** The shape the portal renders. Trimmed; never echoes raw Stripe object fields verbatim. */
public record InvoiceResponse(
String id,
String number,
String status,
Long totalMinor,
String currency,
String createdAt,
String periodStart,
String periodEnd,
String hostedInvoiceUrl,
String invoicePdf,
String description,
Long pdfsProcessed) {}
@GetMapping("/invoices")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public ResponseEntity<List<InvoiceResponse>> list(
@RequestParam(name = "limit", required = false) Integer limit, Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Resolve the caller's team from their primary membership — same pattern as
// PaygWalletController. The team id NEVER comes from the request.
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return ResponseEntity.ok(List.of());
}
Long teamId = rows.get(0).getTeam().getId();
// No PAYG extension row OR no Stripe customer id → team has never subscribed → no
// invoices. Empty list, not 404 — the UI distinguishes "no invoices yet" from a
// genuine error and we don't want to error a happy free team.
Optional<PaygTeamExtensions> ext = extRepo.findById(teamId);
if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
return ResponseEntity.ok(List.of());
}
int safeLimit = clampLimit(limit);
List<InvoiceResponse> body =
invoiceDao.findRecentByCustomer(ext.get().getStripeCustomerId(), safeLimit).stream()
.map(PaygInvoicesController::toResponse)
.toList();
return ResponseEntity.ok(body);
}
private static int clampLimit(Integer requested) {
if (requested == null) return DEFAULT_LIMIT;
return Math.max(1, Math.min(requested, MAX_LIMIT));
}
private static InvoiceResponse toResponse(StripeInvoiceDao.InvoiceRow r) {
return new InvoiceResponse(
r.id(),
r.number(),
r.status(),
r.totalMinor(),
r.currency(),
iso(r.createdAt()),
iso(r.periodStart()),
iso(r.periodEnd()),
r.hostedInvoiceUrl(),
r.invoicePdf(),
r.description(),
r.pdfsProcessed());
}
private static String iso(LocalDateTime ldt) {
return ldt == null ? null : ldt.toString();
}
}
@@ -0,0 +1,108 @@
package stirling.software.saas.payg.api;
import java.util.List;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
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.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.stripe.StripePaymentMethodDao;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Read-only default-payment-method surface for the subscribed billing page.
*
* <p>{@code GET /api/v1/payg/payment-method} returns the team's default card (brand / last4 /
* expiry), sourced from {@code stripe.payment_methods} (Sync Engine mirror). The caller's team is
* resolved from the authenticated principal — never trusted from the request — exactly as {@link
* PaygInvoicesController} does.
*
* <p>Defensive: no team, no {@code stripe_customer_id} (free / pre-checkout), or the card simply
* not in the mirror all degrade to {@code 200 present=false} rather than an error. Card edits never
* happen here; the portal deep-links to Stripe's hosted customer portal for that.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/payg")
@Profile("saas")
public class PaygPaymentMethodController {
/** Trimmed default-card shape. {@code present=false} carries no card fields. */
public record PaymentMethodResponse(
boolean present, String brand, String last4, Integer expMonth, Integer expYear) {
static PaymentMethodResponse absent() {
return new PaymentMethodResponse(false, null, null, null, null);
}
}
private final StripePaymentMethodDao paymentMethodDao;
private final PaygTeamExtensionsRepository extRepo;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public PaygPaymentMethodController(
StripePaymentMethodDao paymentMethodDao,
PaygTeamExtensionsRepository extRepo,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
this.paymentMethodDao = Objects.requireNonNull(paymentMethodDao, "paymentMethodDao");
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
}
@GetMapping("/payment-method")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public ResponseEntity<PaymentMethodResponse> get(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return ResponseEntity.ok(PaymentMethodResponse.absent());
}
Long teamId = rows.get(0).getTeam().getId();
Optional<PaygTeamExtensions> ext = extRepo.findById(teamId);
if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
return ResponseEntity.ok(PaymentMethodResponse.absent());
}
return ResponseEntity.ok(
paymentMethodDao
.findDefaultCard(ext.get().getStripeCustomerId())
.map(
c ->
new PaymentMethodResponse(
true,
c.brand(),
c.last4(),
c.expMonth(),
c.expYear()))
.orElseGet(PaymentMethodResponse::absent));
}
}
@@ -15,7 +15,9 @@ import stirling.software.saas.payg.model.FeatureSet;
* <p>State transitions:
*
* <ul>
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally.
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} (uncapped).
* <li>{@code capUnits <= 0} (an explicit $0 cap) → {@code DEGRADED}: metered work blocked, only
* the free grant + manual tools run.
* <li>{@code spend / cap &lt; 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
@@ -49,9 +51,19 @@ public final class CapEvaluator {
int degradeAtPct,
FeatureSet degradedFeatureSet) {
if (capUnits == null || capUnits <= 0) {
if (capUnits == null) {
// No cap configured → uncapped, full feature set.
return full();
}
if (capUnits <= 0) {
// An explicit cap that buys zero paid documents (a $0 cap, or one set
// below the per-document rate): metered work is blocked outright —
// only the free grant and manual tools run. DEGRADED, same as hitting
// a positive cap.
FeatureSet effective =
degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL;
return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective));
}
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
@@ -0,0 +1,215 @@
package stirling.software.saas.payg.stripe;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import lombok.extern.slf4j.Slf4j;
/**
* Read-only accessor for {@code stripe.invoices} (synced into Postgres by the Stripe Sync Engine).
*
* <p>Same defensive posture as {@link StripeSubscriptionDao}: when the {@code stripe} schema is
* absent (H2 unit tests, sync engine not yet provisioned, or invoices not in the Sync Engine's
* target list), the lookup degrades to an empty list with a WARN — the caller renders "no invoices
* yet" rather than 500ing the page.
*/
@Slf4j
@Repository
@Profile("saas")
public class StripeInvoiceDao {
/**
* One invoice row as the portal needs it. Money is in minor units of {@code currency} (e.g.
* cents for USD). {@code hostedInvoiceUrl} and {@code invoicePdf} are Stripe-hosted links that
* are stable for the lifetime of the invoice; safe to use as deep links from the UI.
*
* <p>{@code description} is the product name from the subscription chain — the portal renders
* this as the row label (matching Stripe's customer-portal row layout). Falls back to the
* invoice's own {@code description} field, then to null when neither is set.
*/
public record InvoiceRow(
String id,
String number,
String status,
Long totalMinor,
String currency,
LocalDateTime createdAt,
LocalDateTime periodStart,
LocalDateTime periodEnd,
String hostedInvoiceUrl,
String invoicePdf,
String description,
/** Billed units (PDFs) on this invoice — summed line-item quantity; null if unknown. */
Long pdfsProcessed) {}
// Drafts are excluded: Stripe's API returns null for both
// {@code hosted_invoice_url} and {@code invoice_pdf} on unfinalized
// invoices, and Stripe's own customer portal hides drafts too — there's no
// user-facing artefact to surface yet. The next finalize / webhook flips
// the status and the invoice shows up automatically.
//
// The LATERAL join walks the same subscription → subscription_items → prices
// → products chain {@link StripeSubscriptionDao} uses to get the per-doc
// rate; here we use it to get the product NAME (e.g. "Stirling Processor
// Plan") so the portal can render Stripe's row label rather than the
// monospace invoice id. Falls back to {@code i.description}, then null.
private static final String QUERY =
"SELECT i.id, i.number, i.status::text AS status,"
+ " i.total, i.currency,"
+ " i.created, i.period_start, i.period_end,"
+ " i.hosted_invoice_url, i.invoice_pdf,"
+ " COALESCE(prod.name, i.description) AS description"
+ " FROM stripe.invoices i"
+ " LEFT JOIN LATERAL ("
+ " SELECT si.price FROM stripe.subscription_items si"
+ " WHERE si.subscription = i.subscription"
+ " AND COALESCE(si.deleted, false) = false"
+ " ORDER BY si.created DESC NULLS LAST LIMIT 1"
+ " ) item ON true"
+ " LEFT JOIN stripe.prices p ON p.id = item.price"
+ " LEFT JOIN stripe.products prod ON prod.id = p.product"
+ " WHERE i.customer = ?"
+ " AND i.status::text <> 'draft'"
+ " ORDER BY i.created DESC NULLS LAST"
+ " LIMIT ?";
private final JdbcTemplate jdbcTemplate;
public StripeInvoiceDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
}
/**
* The most recent {@code limit} invoices for {@code stripeCustomerId}, newest first. Empty list
* on missing schema / no rows / connectivity blip — the controller surfaces this as 200 with an
* empty body rather than 500.
*/
public List<InvoiceRow> findRecentByCustomer(String stripeCustomerId, int limit) {
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
return List.of();
}
int safeLimit = Math.max(1, Math.min(limit, 100));
List<InvoiceRow> rows;
try {
rows =
jdbcTemplate.query(
QUERY,
(rs, i) ->
new InvoiceRow(
rs.getString("id"),
rs.getString("number"),
rs.getString("status"),
nullableLong(rs, "total"),
rs.getString("currency"),
toLocal(rs.getLong("created"), rs.wasNull()),
toLocal(rs.getLong("period_start"), rs.wasNull()),
toLocal(rs.getLong("period_end"), rs.wasNull()),
rs.getString("hosted_invoice_url"),
rs.getString("invoice_pdf"),
rs.getString("description"),
null),
stripeCustomerId,
safeLimit);
} catch (DataAccessException e) {
log.warn(
"stripe.invoices lookup failed for customer {}: {}",
stripeCustomerId,
e.getMessage());
return List.of();
}
if (rows.isEmpty()) {
return rows;
}
Map<String, Long> billed = sumBilledUnits(rows.stream().map(InvoiceRow::id).toList());
if (billed.isEmpty()) {
return rows;
}
return rows.stream()
.map(
r ->
new InvoiceRow(
r.id(),
r.number(),
r.status(),
r.totalMinor(),
r.currency(),
r.createdAt(),
r.periodStart(),
r.periodEnd(),
r.hostedInvoiceUrl(),
r.invoicePdf(),
r.description(),
billed.get(r.id())))
.toList();
}
/**
* Sums billed quantity (PDFs) per invoice from the {@code stripe.invoices.lines} JSONB the Sync
* Engine mirrors — line items live in {@code lines->'data'}, NOT a separate {@code
* invoice_line_items} table (the sync engine never creates one).
*
* <p>Only the <b>metered</b> usage line counts: a Processor invoice can also carry flat
* subscription-fee, proration and tax lines, each with its own {@code quantity}, so summing
* every line would inflate the headline PDF count (usage 500 + a fee line of 1 → "501"). We
* filter on {@code price.recurring.usage_type = 'metered'}. When no metered line is present the
* subquery is {@code NULL} and the invoice is <b>omitted</b> from the map, so {@code
* InvoiceRow.pdfsProcessed} stays {@code null} and the column renders "—" rather than "0".
*
* <p>Run SEPARATELY from the invoice query and defensively wrapped, so a missing/changed schema
* degrades to an empty map (every row renders "—") instead of failing the whole invoice list.
*/
private Map<String, Long> sumBilledUnits(List<String> invoiceIds) {
if (invoiceIds.isEmpty()) {
return Map.of();
}
String placeholders = invoiceIds.stream().map(id -> "?").collect(Collectors.joining(","));
String sql =
"SELECT i.id AS invoice_id,"
+ " (SELECT SUM((l->>'quantity')::int)"
+ " FROM jsonb_array_elements(COALESCE(i.lines->'data', '[]'::jsonb)) AS l"
+ " WHERE l->'price'->'recurring'->>'usage_type' = 'metered') AS qty"
+ " FROM stripe.invoices i"
+ " WHERE i.id IN ("
+ placeholders
+ ")";
try {
Map<String, Long> map = new HashMap<>();
jdbcTemplate.query(
sql,
(java.sql.ResultSet rs) -> {
long qty = rs.getLong("qty");
if (!rs.wasNull()) {
// null (no metered line) → leave the key absent → renders "—".
map.put(rs.getString("invoice_id"), qty);
}
},
invoiceIds.toArray());
return map;
} catch (DataAccessException e) {
log.warn("stripe.invoices line-quantity sum failed: {}", e.getMessage());
return Map.of();
}
}
private static Long nullableLong(java.sql.ResultSet rs, String column)
throws java.sql.SQLException {
long v = rs.getLong(column);
return rs.wasNull() ? null : v;
}
private static LocalDateTime toLocal(long epochSeconds, boolean wasNull) {
if (wasNull) return null;
return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault());
}
}
@@ -0,0 +1,91 @@
package stirling.software.saas.payg.stripe;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import lombok.extern.slf4j.Slf4j;
/**
* Read-only accessor for a team's default card off the Stripe Sync Engine schema ({@code
* stripe.payment_methods}). Prefers the customer's {@code invoice_settings.default_payment_method};
* falls back to their most recently created card. Card details (brand / last4 / expiry) live in the
* {@code card} JSONB column the sync engine mirrors.
*
* <p>Same defensive posture as {@link StripeInvoiceDao}/{@link StripeSubscriptionDao}: a missing
* schema or table — H2 unit tests, sync engine not provisioned, or {@code payment_methods} simply
* absent from the sync target list — degrades to {@link Optional#empty()} with a WARN, so the
* endpoint reports "no card on file" rather than 500ing the page. Editing always happens in
* Stripe's hosted portal; this never writes.
*/
@Slf4j
@Repository
@Profile("saas")
public class StripePaymentMethodDao {
/** Card brand (e.g. "visa"), last 4 digits, and numeric expiry; any field may be null. */
public record CardSummary(String brand, String last4, Integer expMonth, Integer expYear) {}
private static final String QUERY =
"SELECT pm.card->>'brand' AS brand, pm.card->>'last4' AS last4,"
+ " pm.card->>'exp_month' AS exp_month, pm.card->>'exp_year' AS exp_year"
+ " FROM stripe.payment_methods pm"
+ " WHERE pm.customer = ? AND pm.type = 'card'"
+ " ORDER BY (pm.id = ("
+ " SELECT c.invoice_settings->>'default_payment_method'"
+ " FROM stripe.customers c WHERE c.id = ?"
+ " )) DESC NULLS LAST, pm.created DESC NULLS LAST"
+ " LIMIT 1";
private final JdbcTemplate jdbcTemplate;
public StripePaymentMethodDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
}
/** The customer's default card; empty on missing schema / no card / connectivity blip. */
public Optional<CardSummary> findDefaultCard(String stripeCustomerId) {
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
return Optional.empty();
}
try {
List<CardSummary> rows =
jdbcTemplate.query(
QUERY,
(rs, i) ->
new CardSummary(
rs.getString("brand"),
rs.getString("last4"),
parseIntOrNull(rs, "exp_month"),
parseIntOrNull(rs, "exp_year")),
stripeCustomerId,
stripeCustomerId);
return rows.stream().filter(Objects::nonNull).findFirst();
} catch (DataAccessException e) {
log.warn(
"stripe.payment_methods lookup failed for customer {}: {}",
stripeCustomerId,
e.getMessage());
return Optional.empty();
}
}
private static Integer parseIntOrNull(ResultSet rs, String column) throws SQLException {
String raw = rs.getString(column);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return Integer.valueOf(raw.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -10,6 +10,7 @@ import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -49,6 +50,7 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.service.SupabaseUserService;
@@ -80,7 +82,10 @@ public class SupabaseSecurityConfig {
private long clockSkewSeconds;
@Bean
SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder)
SecurityFilterChain saasSecurityFilterChain(
HttpSecurity http,
JwtDecoder jwtDecoder,
ObjectProvider<DeviceCredentialAuthenticationFilter> deviceCredentialFilterProvider)
throws Exception {
// CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in
// Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no
@@ -135,6 +140,16 @@ public class SupabaseSecurityConfig {
.jwtAuthenticationConverter(
SupabaseSecurityConfig
::toAuthentication)));
// Device-credential auth for linked self-hosted instances (combined-billing Mode A).
// The filter bean exists only when stirling.billing.account-link.enabled=true; when off it
// is absent here, so the instance surface cannot authenticate at all until release.
DeviceCredentialAuthenticationFilter deviceFilter =
deviceCredentialFilterProvider.getIfAvailable();
if (deviceFilter != null) {
http.addFilterBefore(deviceFilter, BearerTokenAuthenticationFilter.class);
}
return http.build();
}
@@ -19,6 +19,7 @@ import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.saas.accountlink.LinkedInstanceRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamInvitation;
@@ -45,6 +46,7 @@ public class SaasTeamService {
private final UserRoleService userRoleService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
private final LinkedInstanceRepository linkedInstanceRepository;
private final stirling.software.proprietary.security.service.UserService userService;
public static final String DEFAULT_TEAM_NAME = "Default";
@@ -458,22 +460,42 @@ public class SaasTeamService {
* accept. The message points them at the right remedy — cancel the plan if the team is paid,
* otherwise transfer leadership first.
*
* <p>Linked self-hosted instances (combined-billing "Mode A") bind to a team via {@code
* linked_instance.team_id}, so they too orphan a team that is left memberless — a personal team
* that accept deletes, or a non-personal team left by its last leader. They're checked in that
* same orphaning branch (not for a non-leader leaving a team that lives on); the remedy is to
* revoke them.
*
* @param user the user attempting to accept an invitation
* @throws IllegalStateException if accepting would orphan a team the user leads
* @throws IllegalStateException if accepting would orphan a team the user leads or its
* instances
*/
private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
Team team = membership.getTeam();
if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) {
// Personal teams are deleted on accept; non-leaders leaving never orphans a team.
boolean personal = saasTeamExtensionService.isPersonal(team);
if (!personal && !membership.isLeader()) {
// A non-leader leaving a shared team never orphans it.
continue;
}
// Only reached for a non-personal team the user leads — at most one such team in the
// one-team-per-user model — so this count runs ~once, not per membership.
if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) {
if (!personal
&& membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER)
> 1) {
// Another leader remains, so the team keeps an owner.
continue;
}
// Leaving here orphans the team: a personal team is deleted on accept; a non-personal
// team is being left by its last leader. Either way its linked self-hosted instances
// lose their billing team, so block until they're revoked.
if (linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(team.getId()) > 0) {
throw new IllegalStateException(
"Revoke linked self-hosted instances on this team before joining another"
+ " team.");
}
if (personal) {
// Personal teams are disposable (deleted on accept) and never billed/shared.
continue;
}
if (hasActivePaidSubscription(team)) {
throw new IllegalStateException(
"Your team has an active plan and you are its last leader. Cancel the plan"

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