Merge branch 'main' into feature/documentClassifierAgent
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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}}'
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
||||
# stays ignored via the root .gitignore.
|
||||
!.env.saas
|
||||
!.env.proprietary
|
||||
|
||||
+29
-14
@@ -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
|
||||
|
||||
+6
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+265
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
@@ -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();
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
+92
@@ -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));
|
||||
}
|
||||
}
|
||||
+36
@@ -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/**");
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+124
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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) {}
|
||||
+104
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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;
|
||||
|
||||
+11
-7
@@ -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() {
|
||||
|
||||
+35
-5
@@ -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();
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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<>();
|
||||
|
||||
+28
-10
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-6
@@ -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());
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+7
-6
@@ -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). */
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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();
|
||||
}
|
||||
}
|
||||
+191
@@ -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();
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
+4
@@ -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) {}
|
||||
+118
@@ -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"));
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+25
@@ -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) {}
|
||||
}
|
||||
+6
@@ -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) {}
|
||||
+9
-1
@@ -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()
|
||||
|
||||
+9
-3
@@ -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()
|
||||
|
||||
+6
-2
@@ -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;
|
||||
}
|
||||
|
||||
+12
@@ -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);
|
||||
}
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
|
||||
+21
-5
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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() {}
|
||||
}
|
||||
|
||||
+24
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
|
||||
+9
-9
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+192
@@ -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"));
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+111
@@ -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();
|
||||
}
|
||||
}
|
||||
+49
@@ -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));
|
||||
}
|
||||
}
|
||||
+114
@@ -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());
|
||||
}
|
||||
}
|
||||
+117
@@ -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());
|
||||
}
|
||||
}
|
||||
+71
@@ -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();
|
||||
}
|
||||
}
|
||||
+61
@@ -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());
|
||||
}
|
||||
}
|
||||
+24
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
+14
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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);
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+114
@@ -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;
|
||||
}
|
||||
}
|
||||
+123
@@ -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());
|
||||
}
|
||||
}
|
||||
+187
@@ -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();
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -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;
|
||||
}
|
||||
|
||||
+48
-2
@@ -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());
|
||||
}
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
+108
@@ -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 < 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
Reference in New Issue
Block a user