Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd999144f0 | ||
|
|
f4ab39600d | ||
|
|
f703a67817 | ||
|
|
105af51100 | ||
|
|
57bf17d348 | ||
|
|
11df30b914 | ||
|
|
43162c40ad | ||
|
|
be57f11747 | ||
|
|
8ba8f69252 | ||
|
|
be97268a7c | ||
|
|
7bd3826178 | ||
|
|
17aa71850c | ||
|
|
20204f0ddc | ||
|
|
1df6a1759c | ||
|
|
b4f7b1d8a9 | ||
|
|
f881828cd8 | ||
|
|
16cfbc170e | ||
|
|
355d736487 | ||
|
|
f201aa5915 | ||
|
|
b69b787d63 | ||
|
|
e630d6697b | ||
|
|
a15e8227b4 | ||
|
|
12563050a6 | ||
|
|
1abd23cf94 | ||
|
|
11ba3814e5 | ||
|
|
675afe9b71 | ||
|
|
6c85200eb9 | ||
|
|
467f3a86c4 | ||
|
|
9d3701a585 | ||
|
|
c22ecc6c09 | ||
|
|
b38c849726 | ||
|
|
41f1cb2c22 | ||
|
|
ff3e3bd0fc | ||
|
|
54042c8e5e | ||
|
|
bb92ecc143 | ||
|
|
7ab30d2629 | ||
|
|
276eb8f2a7 | ||
|
|
e44da5c410 | ||
|
|
0beff1a92b | ||
|
|
425b76e9a7 | ||
|
|
c8af6e3b7e | ||
|
|
82ec2acaba | ||
|
|
5e97746721 | ||
|
|
14245d33d1 | ||
|
|
84739e8b0e | ||
|
|
0996277c41 | ||
|
|
d508bc41bf | ||
|
|
6ff910f26c | ||
|
|
013f145462 | ||
|
|
eea9696bd4 | ||
|
|
3f7e898c69 | ||
|
|
def3cf79f6 | ||
|
|
501a7199e0 | ||
|
|
bc6f1a1ff5 |
@@ -27,7 +27,6 @@ node_modules/
|
||||
**/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/editor/dist/
|
||||
frontend/dist-portal/
|
||||
frontend/editor/playwright-report/
|
||||
.npm/
|
||||
.yarn/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.13.2
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.13.2
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -87,6 +87,21 @@ engine: &engine
|
||||
- Taskfile.yml
|
||||
- .taskfiles/engine.yml
|
||||
|
||||
# Files that can make the committed generated API models (frontend tool API
|
||||
# types + engine tool models) go stale: the Java tool surfaces they derive from,
|
||||
# the generators, the generated files themselves (to catch a hand-edit), and the
|
||||
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
|
||||
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
|
||||
generated-models: &generated-models
|
||||
- *openapi
|
||||
- frontend/editor/scripts/generate-tool-api-types.mts
|
||||
- frontend/editor/src/core/types/toolApiTypes.ts
|
||||
- engine/scripts/generate_tool_models.py
|
||||
- engine/src/stirling/models/tool_models.py
|
||||
- .taskfiles/frontend.yml
|
||||
- .taskfiles/engine.yml
|
||||
- .github/workflows/check-generated-models.yml
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: AI Engine CI
|
||||
|
||||
# Validates the Python AI engine: regenerates tool models and runs the
|
||||
# engine quality gate (lint, type-check, format-check, tests). Called from
|
||||
# build.yml on PRs and merge_group; also runs directly on push to main as
|
||||
# a post-merge safety net.
|
||||
# Runs the engine quality gate (lint, type-check, format-check, tests). Called
|
||||
# from build.yml on PRs and merge_group; also runs directly on push to main as
|
||||
# a post-merge safety net. Freshness of the generated tool_models.py is checked
|
||||
# by the shared check-generated-models workflow.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -34,104 +34,9 @@ jobs:
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Regenerate tool models
|
||||
run: task engine:tool-models
|
||||
|
||||
- name: Verify tool models are up to date
|
||||
id: tool-models-check
|
||||
continue-on-error: true
|
||||
run: git diff --exit-code engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on tool models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Tool Models Check Failed',
|
||||
'',
|
||||
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
|
||||
'',
|
||||
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if tool models check failed
|
||||
if: steps.tool-models-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Tool Models Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "The generated engine/src/stirling/models/tool_models.py"
|
||||
echo "is out of date with the Java OpenAPI spec and will"
|
||||
echo "need to be regenerated before it can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task engine:tool-models' to regenerate, then"
|
||||
echo "commit the updated file."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove tool models check comment on success
|
||||
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
run: task engine:check
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
# Enterprise Playwright suite — exercises premium-key gated features (audit,
|
||||
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in three
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in four
|
||||
# situations:
|
||||
#
|
||||
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
|
||||
@@ -12,8 +12,6 @@ name: Enterprise E2E (Playwright)
|
||||
# - on a nightly cron schedule (catches Keycloak image drift, license
|
||||
# expiry, upstream proprietary changes),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -52,6 +50,10 @@ jobs:
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
needs: pick
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE
|
||||
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
|
||||
# header comment. GitHub reports the skipped reusable workflow as success.
|
||||
if: needs.pick.outputs.is_fork != 'true'
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -165,6 +167,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise OAuth Playwright tests
|
||||
id: oauth-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
|
||||
run: task e2e:enterprise -- --grep "OAuth"
|
||||
- name: Stop backend + tear down OAuth Keycloak
|
||||
if: always()
|
||||
@@ -238,6 +242,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise SAML Playwright tests
|
||||
id: saml-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
|
||||
run: task e2e:enterprise -- --grep "SAML"
|
||||
- name: Stop backend + tear down SAML Keycloak
|
||||
if: always()
|
||||
@@ -268,6 +274,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise feature Playwright tests
|
||||
id: feature-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
|
||||
run: task e2e:enterprise -- --grep "Enterprise license"
|
||||
- name: Print backend log on failure
|
||||
if: failure()
|
||||
@@ -280,10 +288,23 @@ jobs:
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
stop_backend
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcomes: a flaky test (passed on retry)
|
||||
# leaves its step green, so this is the only place it surfaces. Merges
|
||||
# all three phase reports (some may be absent if an earlier phase hard-
|
||||
# failed and skipped the rest). Emits ::warning:: annotations + a job
|
||||
# summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: >
|
||||
npx tsx editor/scripts/report-flaky-tests.mts
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
docker-base: ${{ steps.changes.outputs.docker-base }}
|
||||
tauri: ${{ steps.changes.outputs.tauri }}
|
||||
engine: ${{ steps.changes.outputs.engine }}
|
||||
generated-models: ${{ steps.changes.outputs.generated-models }}
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
@@ -171,6 +172,20 @@ jobs:
|
||||
uses: ./.github/workflows/ai-engine.yml
|
||||
secrets: inherit
|
||||
|
||||
# The generated frontend types and engine tool models are both derived from
|
||||
# the Java OpenAPI spec. This job regenerates and diffs them; it boots the
|
||||
# backend, so it is gated on the narrow generated-models filter (spec source,
|
||||
# generators, generated files, generation tasks) rather than the broad
|
||||
# frontend filter, so a CSS-only PR does not pay for a backend build.
|
||||
generated-models:
|
||||
if: needs.files-changed.outputs.generated-models == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/check-generated-models.yml
|
||||
secrets: inherit
|
||||
|
||||
pre-commit:
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
@@ -202,6 +217,9 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/coverage-aggregate.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
frontend-validation-result: ${{ needs.frontend-validation.result }}
|
||||
playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }}
|
||||
|
||||
# Single status check that branch protection should mark as required.
|
||||
# Succeeds when every upstream job is either `success` or `skipped` (path-
|
||||
@@ -225,6 +243,7 @@ jobs:
|
||||
- test-build-docker-images
|
||||
- tauri-build
|
||||
- ai-engine
|
||||
- generated-models
|
||||
- pre-commit
|
||||
- dependency-review
|
||||
runs-on: ubuntu-latest
|
||||
@@ -250,6 +269,7 @@ jobs:
|
||||
test-build-docker-images=${{ needs.test-build-docker-images.result }}
|
||||
tauri-build=${{ needs.tauri-build.result }}
|
||||
ai-engine=${{ needs.ai-engine.result }}
|
||||
generated-models=${{ needs.generated-models.result }}
|
||||
pre-commit=${{ needs.pre-commit.result }}
|
||||
dependency-review=${{ needs.dependency-review.result }}
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
name: Check generated models
|
||||
|
||||
# Verifies the committed generated API models are still in sync with the Java
|
||||
# OpenAPI spec: the frontend tool API types
|
||||
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
|
||||
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
|
||||
# single top-level `task tool-models` and fails if either committed file is
|
||||
# out of date. Called from build.yml when the backend Java, frontend, or engine
|
||||
# changes; also runs on push to main as a post-merge safety net.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generated-models:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
|
||||
# frontend types and the engine tool models from it.
|
||||
- name: Regenerate generated models
|
||||
run: task tool-models
|
||||
|
||||
- name: Verify generated models are up to date
|
||||
id: models-check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git diff --exit-code \
|
||||
frontend/editor/src/core/types/toolApiTypes.ts \
|
||||
engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on generated models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- generated-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Generated Models Check Failed',
|
||||
'',
|
||||
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
|
||||
'',
|
||||
'Run `task tool-models` to regenerate both, then commit the updated files.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if generated models check failed
|
||||
if: steps.models-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Generated Models Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "The generated frontend API types and/or engine tool"
|
||||
echo "models are out of date with the Java OpenAPI spec and"
|
||||
echo "will need to be regenerated before they can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task tool-models' to regenerate both, then"
|
||||
echo "commit the updated files."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove generated models check comment on success
|
||||
if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- generated-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,17 @@ name: Aggregate backend coverage
|
||||
# producers themselves
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
frontend-validation-result:
|
||||
description: Result of the frontend-validation producer job
|
||||
required: false
|
||||
type: string
|
||||
default: skipped
|
||||
playwright-e2e-live-result:
|
||||
description: Result of the playwright-e2e-live producer job
|
||||
required: false
|
||||
type: string
|
||||
default: skipped
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -196,9 +207,9 @@ jobs:
|
||||
# --------------------------------------------------------------
|
||||
- name: Download vitest coverage artifact
|
||||
# frontend-validation uploads as `frontend-coverage`. Tolerate
|
||||
# absence so a backend-only PR still produces the matrix with
|
||||
# just backend rows populated.
|
||||
if: always()
|
||||
# absence on backend-only runs by skipping the download entirely
|
||||
# when the producer job was not part of this workflow run.
|
||||
if: inputs.frontend-validation-result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: frontend-coverage
|
||||
@@ -206,12 +217,12 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Download Playwright frontend coverage artifact
|
||||
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
|
||||
# Same tolerance as vitest - matrix script handles missing inputs.
|
||||
if: always()
|
||||
# e2e-live uploads the artifact with a stable name. Skip the
|
||||
# download entirely when the producer job did not run.
|
||||
if: inputs.playwright-e2e-live-result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
name: playwright-frontend-coverage
|
||||
path: matrix-inputs/playwright/
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,7 +62,17 @@ jobs:
|
||||
# .test-state/playwright/coverage-pw/ for the post-process step
|
||||
# to aggregate. Chromium-only - other engines silently skip.
|
||||
PW_COVERAGE: "1"
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:live
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Generate JaCoCo report from e2e:live .exec
|
||||
if: always()
|
||||
id: live-coverage
|
||||
@@ -169,7 +179,7 @@ jobs:
|
||||
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
name: playwright-frontend-coverage
|
||||
path: |
|
||||
.test-state/playwright/coverage-pw-summary/
|
||||
.test-state/playwright/coverage-pw/
|
||||
|
||||
@@ -44,11 +44,22 @@ jobs:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -98,6 +98,13 @@ jobs:
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Generate frontend license report (Push only)
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: task frontend:licenses:generate
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
env:
|
||||
@@ -353,6 +360,7 @@ jobs:
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,8 +53,8 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
name: playwright-report-nightly-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
@@ -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 }}
|
||||
|
||||
+10
-2
@@ -15,7 +15,15 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
|
||||
testing/compose/validate-mcp-test.sh:curl-auth-header:116
|
||||
|
||||
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
|
||||
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
|
||||
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
|
||||
|
||||
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
|
||||
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
|
||||
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
|
||||
|
||||
# False positive: generic-api-key matches the Java type name "X509Certificate"
|
||||
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
|
||||
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
|
||||
|
||||
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
|
||||
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
|
||||
.github/workflows/tauri-build.yml:generic-api-key:402
|
||||
|
||||
@@ -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:
|
||||
|
||||
+26
-10
@@ -149,16 +149,32 @@ tasks:
|
||||
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
|
||||
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
|
||||
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
|
||||
- |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}} \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
#
|
||||
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
|
||||
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
|
||||
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
|
||||
- cmd: |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
platforms: [windows]
|
||||
- cmd: |
|
||||
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
|
||||
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
|
||||
"$JLINK" \
|
||||
--add-modules {{.JLINK_MODULES}} \
|
||||
--strip-debug \
|
||||
--compress="$JLINK_COMPRESS" \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output runtime/jre
|
||||
platforms: [linux, darwin]
|
||||
# jlink emits its files mode 444 (read-only). Tauri's build-script
|
||||
# resource copier preserves source permissions when staging
|
||||
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
|
||||
|
||||
+49
-75
@@ -128,37 +128,6 @@ tasks:
|
||||
- task: dev:_run
|
||||
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
ignore_error: true
|
||||
deps: [install]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
MOCKS: '{{.MOCKS | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
|
||||
|
||||
dev:portal:proxy:serve:
|
||||
internal: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
|
||||
cmds:
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
# ============================================================
|
||||
@@ -205,29 +174,6 @@ tasks:
|
||||
cmds:
|
||||
- npx vite build editor --mode prototypes
|
||||
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [install]
|
||||
vars:
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
cmds:
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- task: build:proprietary
|
||||
vars: { PREVIEW: '1' }
|
||||
- task: build:portal
|
||||
vars: { SUBPATH: portal }
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
@@ -263,8 +209,8 @@ tasks:
|
||||
deps: [install]
|
||||
cmds:
|
||||
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
|
||||
# shell-agnostic. Covers editor, portal, and the shared design system.
|
||||
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
|
||||
# shell-agnostic. Covers the whole editor tree, including the portal layer.
|
||||
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
|
||||
|
||||
lint:fix:
|
||||
desc: "Auto-fix lint issues"
|
||||
@@ -295,17 +241,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"
|
||||
@@ -313,7 +268,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"
|
||||
@@ -321,38 +277,36 @@ 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: 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
|
||||
|
||||
typecheck:shared:
|
||||
desc: "Typecheck the shared design system"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project shared/tsconfig.json
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/portal/tsconfig.json }
|
||||
|
||||
typecheck:all:
|
||||
desc: "Typecheck all build variants"
|
||||
@@ -365,7 +319,6 @@ tasks:
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
- task: typecheck:shared
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
@@ -394,7 +347,6 @@ tasks:
|
||||
- task: lint
|
||||
- task: format:check
|
||||
- task: build
|
||||
- task: build:portal
|
||||
- task: test
|
||||
- task: storybook:build
|
||||
|
||||
@@ -404,6 +356,11 @@ tasks:
|
||||
|
||||
test:
|
||||
desc: "Run tests"
|
||||
cmds:
|
||||
- task: test:editor
|
||||
|
||||
test:editor:
|
||||
desc: "Run editor tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root editor
|
||||
@@ -439,6 +396,23 @@ tasks:
|
||||
# Code Generation
|
||||
# ============================================================
|
||||
|
||||
tool-models:
|
||||
desc: "Generate tool API types from the Java OpenAPI spec"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
|
||||
sources:
|
||||
- editor/scripts/generate-tool-api-types.mts
|
||||
- ../SwaggerDoc.json
|
||||
generates:
|
||||
- editor/src/core/types/toolApiTypes.ts
|
||||
|
||||
tool-models:check:
|
||||
desc: "Fail if committed tool API types are out of date"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
|
||||
|
||||
licenses:generate:
|
||||
desc: "Generate frontend license report"
|
||||
deps: [install]
|
||||
@@ -452,7 +426,7 @@ tasks:
|
||||
clean:
|
||||
desc: "Clean build artifacts and caches"
|
||||
cmds:
|
||||
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
|
||||
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
|
||||
platforms: [windows]
|
||||
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
|
||||
- cmd: rm -rf node_modules/.vite editor/dist dist
|
||||
platforms: [linux, darwin]
|
||||
|
||||
@@ -139,7 +139,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
|
||||
#### Environment Variables
|
||||
- All `VITE_*` variables must be declared in the appropriate committed env file:
|
||||
- `frontend/editor/.env` — core, proprietary, and shared vars
|
||||
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
|
||||
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
|
||||
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
|
||||
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
|
||||
- These files are committed to Git and must not contain private keys
|
||||
|
||||
+3
-3
@@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
|
||||
|
||||
5. Add environment variable
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
|
||||
5. **Frontend Setup (Required for Stirling 2.0)**
|
||||
6. **Frontend Setup (Required for Stirling 2.0)**
|
||||
Navigate to the frontend directory and install dependencies using npm.
|
||||
|
||||
### Verify Setup
|
||||
@@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
1. Set the security environment variable:
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
|
||||
```
|
||||
|
||||
2. Build the project:
|
||||
@@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
|
||||
```
|
||||
|
||||
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
|
||||
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case
|
||||
|
||||
## 7. Testing
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
|
||||
* All content that resides under the "frontend/portal/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
|
||||
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ For full installation options (including desktop and Kubernetes), see our [Docum
|
||||
|
||||
## Support
|
||||
|
||||
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
- **Community**: [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+14
-76
@@ -79,78 +79,23 @@ tasks:
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:
|
||||
desc: "Start backend + developer portal concurrently on free ports"
|
||||
desc: "Start backend + editor; the portal is an admin route at /portal"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
desc: "Start backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:proxy:
|
||||
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
SUBPATH: portal
|
||||
MOCKS: 'false'
|
||||
- task: frontend:dev:portal:proxy:serve
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
|
||||
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
@@ -198,23 +143,6 @@ tasks:
|
||||
- task: backend:build
|
||||
- task: frontend:build
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:preview:portal:proxy
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
# ============================================================
|
||||
# Test
|
||||
# ============================================================
|
||||
@@ -257,6 +185,16 @@ tasks:
|
||||
- task: frontend:format:check
|
||||
- task: engine:format:check
|
||||
|
||||
# ============================================================
|
||||
# Code generation
|
||||
# ============================================================
|
||||
|
||||
tool-models:
|
||||
desc: "Generate all API models from the Java OpenAPI spec"
|
||||
cmds:
|
||||
- task: frontend:tool-models
|
||||
- task: engine:tool-models
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
# ============================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,10 +80,18 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License, version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The Apache License, Version 2.0"
|
||||
@@ -108,6 +116,10 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "CDDL+GPL License"
|
||||
@@ -172,6 +184,14 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "EPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "LGPL-2.1-only"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Ubuntu Font Licence 1.0"
|
||||
|
||||
@@ -132,7 +132,7 @@ public class AppConfig {
|
||||
return true;
|
||||
}
|
||||
Path mountInfo = Path.of("/proc/1/mountinfo");
|
||||
// this should always exist, if not some unknown usecase
|
||||
// this should always exist, if not some unknown use case
|
||||
if (!Files.exists(mountInfo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+37
-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
|
||||
@@ -514,6 +519,14 @@ public class ApplicationProperties {
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
private LoginAgreement loginAgreement = new LoginAgreement();
|
||||
|
||||
@Data
|
||||
public static class LoginAgreement {
|
||||
private boolean enabled = false;
|
||||
private boolean showInAnonymousMode = true;
|
||||
private String fallbackText = "";
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -582,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";
|
||||
|
||||
@@ -659,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<>();
|
||||
@@ -730,7 +743,6 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
|
||||
/**
|
||||
@@ -834,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;
|
||||
}
|
||||
@@ -878,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();
|
||||
@@ -895,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 {
|
||||
@@ -906,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;
|
||||
}
|
||||
@@ -990,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;
|
||||
}
|
||||
@@ -1164,7 +1187,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Metrics {
|
||||
private boolean enabled;
|
||||
private boolean enabled = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -1216,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;
|
||||
@@ -1243,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;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
|
||||
// enable/visibility come from the legal.loginAgreement settings.
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LoginAgreementService {
|
||||
|
||||
// Locale codes only: rejects path separators and dots so the value can never escape the
|
||||
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
|
||||
private static final Pattern LOCALE_PATTERN =
|
||||
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
|
||||
|
||||
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
|
||||
// from recursing far enough to overflow the stack on a hostile over-length input.
|
||||
private static final int MAX_LOCALE_LENGTH = 35;
|
||||
|
||||
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
|
||||
// wholesale into heap on every public request.
|
||||
private static final long MAX_FILE_BYTES = 256 * 1024;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public LoginAgreementService(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return config().isEnabled();
|
||||
}
|
||||
|
||||
public boolean isShowInAnonymousMode() {
|
||||
return config().isShowInAnonymousMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the markdown to show for the requested language, falling back through the base
|
||||
* language, the configured default locale (and its base), then the configured fallbackText.
|
||||
* Returns an empty string when nothing is configured.
|
||||
*/
|
||||
public String resolveContent(String requestedLang) {
|
||||
List<String> candidates = new ArrayList<>();
|
||||
addLocaleCandidates(candidates, requestedLang);
|
||||
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
for (String candidate : candidates) {
|
||||
String content = readFileIfExists(candidate);
|
||||
if (content != null && !content.isBlank()) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
String fallback = config().getFallbackText();
|
||||
return fallback == null ? "" : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
|
||||
*/
|
||||
public String readRawForLocale(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
String content = readFileIfExists(locale);
|
||||
return content == null ? "" : content;
|
||||
}
|
||||
|
||||
/** Admin write. Blank content deletes the file so it falls back cleanly. */
|
||||
public void writeForLocale(String locale, String content) throws IOException {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
throw new IllegalArgumentException("Invalid locale: " + locale);
|
||||
}
|
||||
if (content == null || content.isBlank()) {
|
||||
Files.deleteIfExists(file);
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(file.getParent());
|
||||
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
|
||||
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
|
||||
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
|
||||
try {
|
||||
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(
|
||||
tmp,
|
||||
file,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locales that currently have a markdown file, for the admin editor. */
|
||||
public Set<String> listLocalesWithContent() {
|
||||
Set<String> result = new TreeSet<>();
|
||||
Path dir = disclaimerDir();
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return result;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
files.filter(Files::isRegularFile)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.filter(name -> name.endsWith(".md"))
|
||||
.map(name -> name.substring(0, name.length() - ".md".length()))
|
||||
.filter(this::isValidLocale)
|
||||
.forEach(result::add);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed listing login agreement files", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ApplicationProperties.Legal.LoginAgreement config() {
|
||||
return applicationProperties.getLegal().getLoginAgreement();
|
||||
}
|
||||
|
||||
private Path disclaimerDir() {
|
||||
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
|
||||
}
|
||||
|
||||
private void addLocaleCandidates(List<String> out, String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return;
|
||||
}
|
||||
if (!out.contains(locale)) {
|
||||
out.add(locale);
|
||||
}
|
||||
String base = locale.split("[_-]", 2)[0];
|
||||
if (!base.equals(locale) && !out.contains(base)) {
|
||||
out.add(base);
|
||||
}
|
||||
}
|
||||
|
||||
private String readFileIfExists(String locale) {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
|
||||
// planted symlink can't expose files outside the disclaimer dir via the public read.
|
||||
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.size(file) > MAX_FILE_BYTES) {
|
||||
log.warn(
|
||||
"Login agreement file for locale {} exceeds {} bytes; ignoring",
|
||||
locale,
|
||||
MAX_FILE_BYTES);
|
||||
return null;
|
||||
}
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed reading login agreement file for locale {}", locale, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path resolveLocaleFile(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
Path dir = disclaimerDir();
|
||||
Path file = dir.resolve(locale + ".md").normalize();
|
||||
// Defence in depth: the regex already blocks separators, but confirm containment.
|
||||
if (!file.startsWith(dir)) {
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private boolean isValidLocale(String locale) {
|
||||
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
|
||||
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
|
||||
return locale != null
|
||||
&& locale.length() <= MAX_LOCALE_LENGTH
|
||||
&& LOCALE_PATTERN.matcher(locale).matches();
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from
|
||||
* {@code <customFiles>/disclaimer/<locale>.md}; here {@link
|
||||
* InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is
|
||||
* isolated.
|
||||
*/
|
||||
class LoginAgreementServiceTest {
|
||||
|
||||
@TempDir Path customFilesDir;
|
||||
|
||||
private ApplicationProperties properties;
|
||||
private ApplicationProperties.Legal.LoginAgreement config;
|
||||
private LoginAgreementService service;
|
||||
private Path disclaimerDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new ApplicationProperties();
|
||||
config = properties.getLegal().getLoginAgreement();
|
||||
service = new LoginAgreementService(properties);
|
||||
disclaimerDir = customFilesDir.resolve("disclaimer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir.
|
||||
*/
|
||||
private void withMockedPath(Runnable action) {
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getCustomFilesPath)
|
||||
.thenReturn(customFilesDir.toString());
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFile(String locale, String content) throws IOException {
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsReflectConfig() {
|
||||
config.setEnabled(true);
|
||||
config.setShowInAnonymousMode(false);
|
||||
assertTrue(service.isEnabled());
|
||||
assertFalse(service.isShowInAnonymousMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsExactLocaleFile() throws IOException {
|
||||
writeFile("fr-FR", "# Avis");
|
||||
withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToBaseLanguage() throws IOException {
|
||||
// Only a language-only file exists; a region-specific request should fall back to it.
|
||||
writeFile("de", "# Hinweis");
|
||||
withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToDefaultLocale() throws IOException {
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
writeFile("en-GB", "# Notice");
|
||||
// No file for the requested locale -> falls through to the configured default locale.
|
||||
withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToFallbackTextWhenNoFile() {
|
||||
config.setFallbackText("# Fallback");
|
||||
withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsEmptyWhenNothingConfigured() {
|
||||
withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException {
|
||||
// Plant a file outside the disclaimer dir; a traversal-style locale must not read it.
|
||||
Files.writeString(
|
||||
customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8);
|
||||
config.setFallbackText("safe");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertEquals("safe", service.resolveContent("../secret"));
|
||||
assertEquals("safe", service.resolveContent("..%2Fsecret"));
|
||||
assertEquals("safe", service.resolveContent("/etc/passwd"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertNull(service.readRawForLocale("../secret"));
|
||||
assertNull(service.readRawForLocale("en/GB"));
|
||||
assertNull(service.readRawForLocale("C:\\x"));
|
||||
assertNull(service.readRawForLocale(null));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawReturnsEmptyForValidButAbsentLocale() {
|
||||
withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlongLocaleIsRejectedWithoutStackOverflow() {
|
||||
// Guards against the regex-recursion stack overflow on unbounded input.
|
||||
String hostile = "en" + "-ab".repeat(4000);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.readRawForLocale(hostile));
|
||||
assertNull(service.readRawForLocale(hostile));
|
||||
assertDoesNotThrow(() -> service.resolveContent(hostile));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeThenReadRoundTrips() throws IOException {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour"));
|
||||
assertEquals("# Bonjour", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBlankDeletesFile() throws IOException {
|
||||
writeFile("fr-FR", "# Bonjour");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " "));
|
||||
assertEquals("", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() ->
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.writeForLocale("../escape", "x")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException {
|
||||
writeFile("en-GB", "a");
|
||||
writeFile("fr-FR", "b");
|
||||
Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
var locales = service.listLocalesWithContent();
|
||||
assertTrue(locales.contains("en-GB"));
|
||||
assertTrue(locales.contains("fr-FR"));
|
||||
assertEquals(2, locales.size());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedFileIsIgnored() throws IOException {
|
||||
// Files beyond the read cap are skipped rather than loaded into heap.
|
||||
byte[] big = new byte[300 * 1024];
|
||||
java.util.Arrays.fill(big, (byte) 'x');
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.write(disclaimerDir.resolve("en-GB.md"), big);
|
||||
config.setFallbackText("small-fallback");
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB")));
|
||||
}
|
||||
}
|
||||
+96
-15
@@ -24,12 +24,14 @@ import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Provider;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
|
||||
import org.bouncycastle.cert.jcajce.JcaCertStore;
|
||||
@@ -50,6 +52,13 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
@Getter private Certificate[] certificateChain;
|
||||
@Setter private String tsaUrl;
|
||||
|
||||
/**
|
||||
* Provider that must service the signing operation. Set for hardware-held keys (SunPKCS11 for
|
||||
* USB tokens, SunMSCAPI for the Windows store) so the {@link java.security.Signature} runs on
|
||||
* the token. Left {@code null} for software keystores, which use the default provider.
|
||||
*/
|
||||
@Setter private Provider signingProvider;
|
||||
|
||||
/**
|
||||
* Specifies whether the external signing scenario should be used. If set to {@code true},
|
||||
* external signing will be performed and {@link SignatureInterface} will be used for signing.
|
||||
@@ -80,25 +89,48 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
NoSuchAlgorithmException,
|
||||
IOException,
|
||||
CertificateException {
|
||||
// grabs the first alias from the keystore and get the private key. An
|
||||
// alternative method or constructor could be used for setting a specific
|
||||
// alias that should be used.
|
||||
this(keystore, pin, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the signature creator, optionally selecting a specific certificate by alias. A
|
||||
* hardware token / the Windows store can hold several certificates, so the caller picks one;
|
||||
* when {@code requestedAlias} is null the first usable entry is used (software keystore
|
||||
* behaviour).
|
||||
*
|
||||
* @param keystore the keystore (software, PKCS#11 or Windows-MY)
|
||||
* @param pin the keystore / token PIN, may be null for the Windows store
|
||||
* @param requestedAlias the alias to sign with, or null to pick the first usable entry
|
||||
*/
|
||||
public CreateSignatureBase(KeyStore keystore, char[] pin, String requestedAlias)
|
||||
throws KeyStoreException,
|
||||
UnrecoverableKeyException,
|
||||
NoSuchAlgorithmException,
|
||||
IOException,
|
||||
CertificateException {
|
||||
if (requestedAlias != null
|
||||
&& !requestedAlias.isBlank()
|
||||
&& keystore.containsAlias(requestedAlias)) {
|
||||
privateKey = (PrivateKey) keystore.getKey(requestedAlias, pin);
|
||||
certificateChain = resolveChain(keystore, requestedAlias);
|
||||
if (certificateChain == null) {
|
||||
throw new IOException("Could not find certificate for alias " + requestedAlias);
|
||||
}
|
||||
checkValidity(certificateChain[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// grabs the first alias from the keystore and gets the private key.
|
||||
Enumeration<String> aliases = keystore.aliases();
|
||||
String alias;
|
||||
Certificate cert = null;
|
||||
while (cert == null && aliases.hasMoreElements()) {
|
||||
alias = aliases.nextElement();
|
||||
String alias = aliases.nextElement();
|
||||
privateKey = (PrivateKey) keystore.getKey(alias, pin);
|
||||
Certificate[] certChain = keystore.getCertificateChain(alias);
|
||||
Certificate[] certChain = resolveChain(keystore, alias);
|
||||
if (certChain != null) {
|
||||
certificateChain = certChain;
|
||||
cert = certChain[0];
|
||||
if (cert instanceof X509Certificate) {
|
||||
// avoid expired certificate
|
||||
((X509Certificate) cert).checkValidity();
|
||||
|
||||
//// SigUtils.checkCertificateUsage((X509Certificate) cert);
|
||||
}
|
||||
checkValidity(cert);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +139,27 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the certificate chain for an alias. PKCS#11 tokens and the Windows store frequently
|
||||
* expose only the leaf certificate (a null chain), so fall back to the single certificate.
|
||||
*/
|
||||
private static Certificate[] resolveChain(KeyStore keystore, String alias)
|
||||
throws KeyStoreException {
|
||||
Certificate[] chain = keystore.getCertificateChain(alias);
|
||||
if (chain != null && chain.length > 0) {
|
||||
return chain;
|
||||
}
|
||||
Certificate single = keystore.getCertificate(alias);
|
||||
return single != null ? new Certificate[] {single} : null;
|
||||
}
|
||||
|
||||
private static void checkValidity(Certificate cert) throws CertificateException {
|
||||
if (cert instanceof X509Certificate x509Cert) {
|
||||
// avoid expired certificate
|
||||
x509Cert.checkValidity();
|
||||
}
|
||||
}
|
||||
|
||||
public final void setPrivateKey(PrivateKey privateKey) {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
@@ -136,12 +189,18 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
try {
|
||||
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
|
||||
X509Certificate cert = (X509Certificate) certificateChain[0];
|
||||
ContentSigner sha1Signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey);
|
||||
JcaContentSignerBuilder signerBuilder =
|
||||
new JcaContentSignerBuilder(resolveSignatureAlgorithm(privateKey, cert));
|
||||
// Hardware keys (PKCS#11 / Windows store) must sign on their own provider so the
|
||||
// operation runs on the token; software keys use the default provider.
|
||||
if (signingProvider != null) {
|
||||
signerBuilder.setProvider(signingProvider);
|
||||
}
|
||||
ContentSigner signer = signerBuilder.build(privateKey);
|
||||
gen.addSignerInfoGenerator(
|
||||
new JcaSignerInfoGeneratorBuilder(
|
||||
new JcaDigestCalculatorProviderBuilder().build())
|
||||
.build(sha1Signer, cert));
|
||||
.build(signer, cert));
|
||||
gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));
|
||||
CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
|
||||
CMSSignedData signedData = gen.generate(msg, false);
|
||||
@@ -157,4 +216,26 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a SHA-256 signature algorithm that matches the key type. RSA keeps the historical
|
||||
* default; EC / EdDSA tokens are common, so they are handled too.
|
||||
*/
|
||||
private static String resolveSignatureAlgorithm(PrivateKey key, X509Certificate cert) {
|
||||
String alg = key.getAlgorithm();
|
||||
if (alg == null || alg.isBlank()) {
|
||||
alg = cert.getPublicKey().getAlgorithm();
|
||||
}
|
||||
alg = alg == null ? "" : alg.toUpperCase(Locale.ROOT);
|
||||
if (alg.contains("ED25519") || alg.contains("EDDSA")) {
|
||||
return "Ed25519";
|
||||
}
|
||||
if (alg.contains("EC")) { // EC, ECDSA
|
||||
return "SHA256withECDSA";
|
||||
}
|
||||
if (alg.contains("DSA")) {
|
||||
return "SHA256withDSA";
|
||||
}
|
||||
return "SHA256withRSA";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"/icons/**",
|
||||
"/modern-logo/**",
|
||||
"/classic-logo/**",
|
||||
"/robots.txt",
|
||||
"/3rdPartyLicenses.json",
|
||||
"/pdfjs/**",
|
||||
"/pdfjs-legacy/**",
|
||||
|
||||
+14
-2
@@ -3,9 +3,12 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
@@ -261,10 +264,19 @@ public class RearrangePagesPDFController {
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
|
||||
// Snapshot the desired pages before mutating the source document's page tree.
|
||||
// Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE)
|
||||
// so each slot is a distinct node, not one PDPage under multiple /Kids.
|
||||
List<PDPage> newPages = new ArrayList<>(newPageOrder.size());
|
||||
Set<Integer> seenIndices = new HashSet<>();
|
||||
for (Integer idx : newPageOrder) {
|
||||
newPages.add(document.getPage(idx));
|
||||
PDPage page = document.getPage(idx);
|
||||
if (!seenIndices.add(idx)) {
|
||||
// Duplicate index: distinct page node sharing content/resources.
|
||||
COSDictionary clonedDict = new COSDictionary();
|
||||
clonedDict.addAll(page.getCOSObject());
|
||||
page = new PDPage(clonedDict);
|
||||
}
|
||||
newPages.add(page);
|
||||
}
|
||||
|
||||
// Rearrange in-place on the source document rather than copying pages into a
|
||||
|
||||
+12
@@ -350,6 +350,18 @@ public class ConfigController {
|
||||
"serverCertificateEnabled",
|
||||
serverCertificateService != null && serverCertificateService.isEnabled());
|
||||
|
||||
// Hardware-backed signing (Windows store / USB PKCS#11 tokens) is only viable on the
|
||||
// desktop bundle, where the backend runs locally in the user's session. The Tauri
|
||||
// bundle signals this via STIRLING_PDF_TAURI_MODE (machineType is Server-jar there);
|
||||
// the bare-jar desktop launcher signals it via a Client-* machineType.
|
||||
boolean hardwareSigningAvailable =
|
||||
Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
|
||||
if (!hardwareSigningAvailable && applicationContext.containsBean("machineType")) {
|
||||
String mt = applicationContext.getBean("machineType", String.class);
|
||||
hardwareSigningAvailable = mt != null && mt.startsWith("Client-");
|
||||
}
|
||||
configData.put("hardwareSigningAvailable", hardwareSigningAvailable);
|
||||
|
||||
// Legal settings
|
||||
configData.put(
|
||||
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
/**
|
||||
* Serves the login agreement / disclaimer for the frontend. Shares the /api/v1/config access rules:
|
||||
* it requires authentication when login is enabled (the modal is shown after login, never on the
|
||||
* login screen) and is permit-all in anonymous/no-login mode and in SaaS. The text is read live
|
||||
* from disk, so admin edits take effect on the next login without a restart.
|
||||
*/
|
||||
@ConfigApi
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
public class LoginDisclaimerController {
|
||||
|
||||
private final LoginAgreementService loginAgreementService;
|
||||
|
||||
@GetMapping("/login-disclaimer")
|
||||
@Operation(
|
||||
summary = "Get the login agreement/disclaimer",
|
||||
description =
|
||||
"Returns whether the login agreement is enabled and, if so, the markdown to"
|
||||
+ " display for the requested language.")
|
||||
public LoginDisclaimerResponse getLoginDisclaimer(
|
||||
@RequestParam(name = "lang", required = false) String lang) {
|
||||
boolean showInAnonymousMode = loginAgreementService.isShowInAnonymousMode();
|
||||
if (!loginAgreementService.isEnabled()) {
|
||||
return new LoginDisclaimerResponse(false, showInAnonymousMode, "", "markdown");
|
||||
}
|
||||
String content = loginAgreementService.resolveContent(lang);
|
||||
// Enabled but no resolvable text (no file for any candidate locale and no fallbackText):
|
||||
// report disabled so clients don't try to render an empty agreement.
|
||||
boolean hasContent = content != null && !content.isBlank();
|
||||
return new LoginDisclaimerResponse(
|
||||
hasContent, showInAnonymousMode, hasContent ? content : "", "markdown");
|
||||
}
|
||||
|
||||
public record LoginDisclaimerResponse(
|
||||
boolean enabled, boolean showInAnonymousMode, String content, String format) {}
|
||||
}
|
||||
+63
-4
@@ -70,10 +70,13 @@ import io.micrometer.common.util.StringUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.SPDF.service.HardwareKeyStoreService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -109,14 +112,17 @@ public class CertSignController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final HardwareKeyStoreService hardwareKeyStoreService;
|
||||
|
||||
public CertSignController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
|
||||
TempFileManager tempFileManager) {
|
||||
TempFileManager tempFileManager,
|
||||
HardwareKeyStoreService hardwareKeyStoreService) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.hardwareKeyStoreService = hardwareKeyStoreService;
|
||||
}
|
||||
|
||||
public static void sign(
|
||||
@@ -170,7 +176,8 @@ public class CertSignController {
|
||||
"This endpoint accepts a PDF file, a digital certificate and related"
|
||||
+ " information to sign the PDF. It then returns the digitally signed PDF"
|
||||
+ " file. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request)
|
||||
public ResponseEntity<Resource> signPDFWithCert(
|
||||
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
|
||||
throws Exception {
|
||||
MultipartFile pdf = request.getFileInput();
|
||||
String certType = request.getCertType();
|
||||
@@ -196,6 +203,8 @@ public class CertSignController {
|
||||
|
||||
KeyStore ks = null;
|
||||
String keystorePassword = password;
|
||||
Provider signingProvider = null;
|
||||
HardwareKeyStoreService.Pkcs11Session pkcs11Session = null;
|
||||
|
||||
switch (certType) {
|
||||
case "PEM":
|
||||
@@ -245,6 +254,31 @@ public class CertSignController {
|
||||
ks = serverCertificateService.getServerKeyStore();
|
||||
keystorePassword = serverCertificateService.getServerCertificatePassword();
|
||||
break;
|
||||
case "WINDOWS_STORE":
|
||||
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
|
||||
ks = hardwareKeyStoreService.loadWindowsKeyStore();
|
||||
signingProvider = hardwareKeyStoreService.windowsProvider();
|
||||
// PIN is prompted by the Windows CSP / token middleware, not passed here.
|
||||
keystorePassword = password;
|
||||
break;
|
||||
case "PKCS11":
|
||||
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
|
||||
char[] pkcs11Pin = password != null ? password.toCharArray() : null;
|
||||
try {
|
||||
pkcs11Session =
|
||||
hardwareKeyStoreService.openPkcs11(
|
||||
request.getPkcs11LibraryPath(),
|
||||
request.getPkcs11Slot(),
|
||||
pkcs11Pin);
|
||||
} finally {
|
||||
if (pkcs11Pin != null) {
|
||||
java.util.Arrays.fill(pkcs11Pin, '\0');
|
||||
}
|
||||
}
|
||||
ks = pkcs11Session.keyStore();
|
||||
signingProvider = pkcs11Session.provider();
|
||||
keystorePassword = password;
|
||||
break;
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
@@ -252,7 +286,9 @@ public class CertSignController {
|
||||
"certificate type: " + certType);
|
||||
}
|
||||
|
||||
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
|
||||
char[] pin = keystorePassword != null ? keystorePassword.toCharArray() : null;
|
||||
CreateSignature createSignature =
|
||||
new CreateSignature(ks, pin, request.getAlias(), signingProvider);
|
||||
TempFile signedOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (OutputStream os = new FileOutputStream(signedOut.getFile())) {
|
||||
sign(
|
||||
@@ -269,6 +305,14 @@ public class CertSignController {
|
||||
} catch (IOException e) {
|
||||
signedOut.close();
|
||||
throw e;
|
||||
} finally {
|
||||
// Clear the PIN copy and log out the token session once signing is done.
|
||||
if (pin != null) {
|
||||
java.util.Arrays.fill(pin, '\0');
|
||||
}
|
||||
if (pkcs11Session != null) {
|
||||
pkcs11Session.close();
|
||||
}
|
||||
}
|
||||
// Return the signed PDF
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
@@ -324,7 +368,22 @@ public class CertSignController {
|
||||
NoSuchAlgorithmException,
|
||||
IOException,
|
||||
CertificateException {
|
||||
super(keystore, pin);
|
||||
this(keystore, pin, null, null);
|
||||
}
|
||||
|
||||
public CreateSignature(
|
||||
KeyStore keystore, char[] pin, String alias, Provider signingProvider)
|
||||
throws KeyStoreException,
|
||||
UnrecoverableKeyException,
|
||||
NoSuchAlgorithmException,
|
||||
IOException,
|
||||
CertificateException {
|
||||
super(keystore, pin, alias);
|
||||
setSigningProvider(signingProvider);
|
||||
loadLogo();
|
||||
}
|
||||
|
||||
private void loadLogo() throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource("static/images/signature.png");
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
logoFile = Files.createTempFile("signature", ".png").toFile();
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
|
||||
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
|
||||
import stirling.software.SPDF.model.api.security.Pkcs11CertificatesRequest;
|
||||
import stirling.software.SPDF.service.HardwareKeyStoreService;
|
||||
|
||||
/**
|
||||
* Lets the desktop frontend discover which hardware-backed signing options the local backend can
|
||||
* reach (Windows certificate store, plugged-in USB / PKCS#11 tokens) and enumerate the certificates
|
||||
* available to sign with. Enumeration endpoints are restricted to the desktop bundle, reached over
|
||||
* loopback - see {@link HardwareKeyStoreService#assertLocalDesktop}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security/cert-sign/hardware")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
public class HardwareSigningController {
|
||||
|
||||
private final HardwareKeyStoreService hardwareKeyStoreService;
|
||||
|
||||
@GetMapping("/capabilities")
|
||||
@Operation(
|
||||
summary = "Hardware signing capabilities",
|
||||
description =
|
||||
"Reports whether hardware-backed signing is available on this device and which"
|
||||
+ " PKCS#11 driver libraries were detected. Returns desktop=false when"
|
||||
+ " not running as the desktop app.")
|
||||
public ResponseEntity<HardwareSigningCapabilities> getCapabilities() {
|
||||
return ResponseEntity.ok(hardwareKeyStoreService.capabilities());
|
||||
}
|
||||
|
||||
@GetMapping("/windows-certificates")
|
||||
@Operation(
|
||||
summary = "List Windows certificate store signing certificates",
|
||||
description =
|
||||
"Enumerates certificates with a usable private key from the current user's"
|
||||
+ " Windows certificate store. Desktop-only, loopback-only.")
|
||||
public ResponseEntity<List<HardwareCertificateInfo>> getWindowsCertificates(
|
||||
HttpServletRequest request) throws Exception {
|
||||
hardwareKeyStoreService.assertLocalDesktop(request);
|
||||
return ResponseEntity.ok(hardwareKeyStoreService.listWindowsCertificates());
|
||||
}
|
||||
|
||||
@PostMapping("/pkcs11-certificates")
|
||||
@Operation(
|
||||
summary = "List PKCS#11 token signing certificates",
|
||||
description =
|
||||
"Logs into a PKCS#11 token with the supplied PIN and enumerates its signing"
|
||||
+ " certificates. The PIN is used only for this call. Desktop-only,"
|
||||
+ " loopback-only.")
|
||||
public ResponseEntity<List<HardwareCertificateInfo>> getPkcs11Certificates(
|
||||
HttpServletRequest request, @RequestBody Pkcs11CertificatesRequest body)
|
||||
throws Exception {
|
||||
hardwareKeyStoreService.assertLocalDesktop(request);
|
||||
char[] pin = body.pin() != null ? body.pin().toCharArray() : null;
|
||||
try {
|
||||
return ResponseEntity.ok(
|
||||
hardwareKeyStoreService.listPkcs11Certificates(
|
||||
body.libraryPath(), body.slot(), pin));
|
||||
} finally {
|
||||
if (pin != null) {
|
||||
java.util.Arrays.fill(pin, '\0');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -102,8 +102,27 @@ public class ValidateSignatureController {
|
||||
try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
|
||||
List<PDSignature> signatures = document.getSignatureDictionaries();
|
||||
|
||||
// Detect content appended outside every signature's ByteRange (added after signing). A
|
||||
// properly signed document has its last signature cover all the way to EOF; if the
|
||||
// furthest any signature reaches stops short of the file length, the tail is unsigned.
|
||||
// Taking the max across all signatures avoids false positives on legitimately
|
||||
// multi-signed PDFs, where an earlier signature intentionally omits later revisions.
|
||||
long fileLength = file.getSize();
|
||||
long maxCovered = 0;
|
||||
for (PDSignature sig : signatures) {
|
||||
int[] byteRange = sig.getByteRange();
|
||||
if (byteRange != null && byteRange.length == 4) {
|
||||
long end = (long) byteRange[2] + byteRange[3];
|
||||
if (end > maxCovered) {
|
||||
maxCovered = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean documentCovered = maxCovered <= 0 || maxCovered >= fileLength;
|
||||
|
||||
for (PDSignature sig : signatures) {
|
||||
SignatureValidationResult result = new SignatureValidationResult();
|
||||
result.setCoversEntireDocument(documentCovered);
|
||||
|
||||
try {
|
||||
byte[] signedContent = sig.getSignedContent(file.getInputStream());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+34
-12
@@ -1,5 +1,7 @@
|
||||
package stirling.software.SPDF.model.api.general;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
@@ -17,20 +19,8 @@ public class PosterPdfRequest extends PDFFile {
|
||||
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
|
||||
private String pageSize = "A4";
|
||||
|
||||
@Schema(
|
||||
description = "Horizontal decimation factor (how many columns to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
private int xFactor = 2;
|
||||
|
||||
@Schema(
|
||||
description = "Vertical decimation factor (how many rows to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
private int yFactor = 2;
|
||||
|
||||
@Schema(
|
||||
@@ -38,4 +28,36 @@ public class PosterPdfRequest extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private boolean rightToLeft = false;
|
||||
|
||||
@JsonProperty("xFactor")
|
||||
@Schema(
|
||||
description = "Horizontal decimation factor (how many columns to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
public int getXFactor() {
|
||||
return xFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("xFactor")
|
||||
public void setXFactor(int xFactor) {
|
||||
this.xFactor = xFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("yFactor")
|
||||
@Schema(
|
||||
description = "Vertical decimation factor (how many rows to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
public int getYFactor() {
|
||||
return yFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("yFactor")
|
||||
public void setYFactor(int yFactor) {
|
||||
this.yFactor = yFactor;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ public class AddPasswordRequest extends PDFFile {
|
||||
description = "The length of the encryption key",
|
||||
type = "integer",
|
||||
allowableValues = {"40", "128", "256"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "256")
|
||||
private int keyLength = 256;
|
||||
|
||||
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
/**
|
||||
* Metadata for a single signing certificate discovered on a hardware source (Windows certificate
|
||||
* store or a PKCS#11 token). Returned to the desktop frontend so the user can pick which
|
||||
* certificate to sign with. Never carries private key material - signing always happens on the
|
||||
* token / OS.
|
||||
*/
|
||||
public record HardwareCertificateInfo(
|
||||
String alias,
|
||||
String source,
|
||||
String subject,
|
||||
String issuer,
|
||||
String subjectCommonName,
|
||||
String issuerCommonName,
|
||||
String serialNumber,
|
||||
String keyAlgorithm,
|
||||
String notBefore,
|
||||
String notAfter,
|
||||
boolean expired,
|
||||
boolean notYetValid) {}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Describes what hardware-backed signing the local backend can offer. Only meaningful on the
|
||||
* desktop bundle, where the backend runs as a local sidecar in the signed-in user's session and can
|
||||
* reach the Windows certificate store / a plugged-in USB PKCS#11 token.
|
||||
*/
|
||||
public record HardwareSigningCapabilities(
|
||||
boolean desktop,
|
||||
String osName,
|
||||
boolean windowsStoreSupported,
|
||||
boolean pkcs11Supported,
|
||||
List<Pkcs11LibraryInfo> detectedLibraries) {
|
||||
|
||||
/** A PKCS#11 driver library detected on disk (or supplied via configuration). */
|
||||
public record Pkcs11LibraryInfo(String name, String path) {}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
/**
|
||||
* Request body for enumerating the certificates on a PKCS#11 token. The PIN is required to log into
|
||||
* the token; it is used only for the duration of the call and never stored.
|
||||
*/
|
||||
public record Pkcs11CertificatesRequest(String libraryPath, Integer slot, String pin) {}
|
||||
+27
-3
@@ -14,8 +14,10 @@ import stirling.software.common.model.api.PDFFile;
|
||||
public class SignPDFWithCertRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The type of the digital certificate",
|
||||
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
|
||||
description =
|
||||
"The type of the digital certificate. WINDOWS_STORE and PKCS11 are"
|
||||
+ " hardware-backed and only available in the desktop app.",
|
||||
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER", "WINDOWS_STORE", "PKCS11"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String certType;
|
||||
|
||||
@@ -39,9 +41,31 @@ public class SignPDFWithCertRequest extends PDFFile {
|
||||
@Schema(description = "The JKS keystore file (Java Key Store)")
|
||||
private MultipartFile jksFile;
|
||||
|
||||
@Schema(description = "The password for the keystore or the private key", format = "password")
|
||||
@Schema(
|
||||
description =
|
||||
"The password for the keystore / private key, or the token PIN for PKCS11",
|
||||
format = "password")
|
||||
private String password;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The alias of the certificate to sign with. Required for WINDOWS_STORE and"
|
||||
+ " recommended for PKCS11 tokens holding multiple certificates.")
|
||||
private String alias;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must"
|
||||
+ " be an allowed driver - a detected one or configured via"
|
||||
+ " STIRLING_PKCS11_LIBRARIES.")
|
||||
private String pkcs11LibraryPath;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Optional PKCS#11 slot index. When omitted the first slot with a token is"
|
||||
+ " used.")
|
||||
private Integer pkcs11Slot;
|
||||
|
||||
@Schema(
|
||||
description = "Whether to visually show the signature in the PDF file",
|
||||
defaultValue = "false",
|
||||
|
||||
+5
@@ -18,6 +18,11 @@ public class SignatureValidationResult {
|
||||
// Time validation
|
||||
private boolean notExpired;
|
||||
|
||||
// Whether the document's signatures cover all of its bytes. False when content was appended
|
||||
// outside every signature's ByteRange (i.e. added after signing), which the signature can't
|
||||
// attest to even though the signed bytes themselves remain cryptographically intact.
|
||||
private boolean coversEntireDocument = true;
|
||||
|
||||
// Revocation validation
|
||||
private boolean revocationChecked; // true if PKIX revocation was enabled
|
||||
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
|
||||
+2
-1
@@ -115,7 +115,8 @@ public class CertificateValidationService {
|
||||
log.info("Enabled AIA certificate fetching and revocation checking");
|
||||
}
|
||||
|
||||
// Trust only what we explicitly opt into:
|
||||
// Trust only what we explicitly opt into. Desktop follows the same flags as the server -
|
||||
// our own signing cert is trusted via serverAsAnchor, not by force-loading every system CA.
|
||||
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
|
||||
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
|
||||
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.security.Provider;
|
||||
import java.security.Security;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.security.auth.x500.X500Principal;
|
||||
|
||||
import org.bouncycastle.asn1.x500.RDN;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x500.style.BCStyle;
|
||||
import org.bouncycastle.asn1.x500.style.IETFUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
|
||||
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
|
||||
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities.Pkcs11LibraryInfo;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* Bridges PDF signing to hardware-held keys: the Windows certificate store (via the JDK SunMSCAPI
|
||||
* provider) and USB / smart-card PKCS#11 tokens (via SunPKCS11). The private key never leaves the
|
||||
* token - the JCA routes the actual signing operation onto the hardware.
|
||||
*
|
||||
* <p>These code paths are gated to the desktop bundle. On a hosted server the backend cannot reach
|
||||
* a remote user's USB token anyway, and loading an arbitrary PKCS#11 driver library is effectively
|
||||
* native code execution, so PKCS#11 libraries are additionally restricted to an allowlist of
|
||||
* detected / configured driver paths.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class HardwareKeyStoreService {
|
||||
|
||||
public static final String SOURCE_WINDOWS_STORE = "WINDOWS_STORE";
|
||||
public static final String SOURCE_PKCS11 = "PKCS11";
|
||||
|
||||
private static final String WINDOWS_KEYSTORE_TYPE = "Windows-MY";
|
||||
private static final String MSCAPI_PROVIDER = "SunMSCAPI";
|
||||
private static final String PKCS11_BASE_PROVIDER = "SunPKCS11";
|
||||
|
||||
/** Extra PKCS#11 driver libraries, absolute paths, comma/`File.pathSeparator` separated. */
|
||||
private static final String PKCS11_LIBRARIES_ENV = "STIRLING_PKCS11_LIBRARIES";
|
||||
|
||||
/** Same as {@link #PKCS11_LIBRARIES_ENV} but as a JVM system property. */
|
||||
private static final String PKCS11_LIBRARIES_PROP = "stirling.pkcs11.libraries";
|
||||
|
||||
private final String machineType;
|
||||
|
||||
public HardwareKeyStoreService(
|
||||
@Autowired(required = false) @Qualifier("machineType") String machineType) {
|
||||
this.machineType = machineType;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Gating
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True when running as the desktop bundle (local sidecar in the user's session). The Tauri
|
||||
* bundle sets {@code STIRLING_PDF_TAURI_MODE=true} (with {@code BROWSER_OPEN=false}, so
|
||||
* machineType is {@code Server-jar} there); the bare-jar desktop launcher instead yields a
|
||||
* {@code Client-*} machineType. Accept either.
|
||||
*/
|
||||
public boolean isDesktop() {
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||
return true;
|
||||
}
|
||||
return machineType != null && machineType.startsWith("Client-");
|
||||
}
|
||||
|
||||
public boolean isWindows() {
|
||||
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
|
||||
}
|
||||
|
||||
private boolean windowsStoreSupported() {
|
||||
return isWindows() && Security.getProvider(MSCAPI_PROVIDER) != null;
|
||||
}
|
||||
|
||||
private boolean pkcs11Supported() {
|
||||
return Security.getProvider(PKCS11_BASE_PROVIDER) != null;
|
||||
}
|
||||
|
||||
/** Reject anything that is not the desktop bundle reached over loopback. */
|
||||
public void assertLocalDesktop(HttpServletRequest request) {
|
||||
if (!isDesktop()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.hardwareSigningDesktopOnly",
|
||||
"Hardware-backed signing is only available in the Stirling PDF desktop app");
|
||||
}
|
||||
if (request != null && !isLocalRequest(request.getRemoteAddr())) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.hardwareSigningLocalOnly",
|
||||
"Hardware-backed signing can only be used from this device");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request originates from this machine. Loopback (incl. IPv4-mapped IPv6 like
|
||||
* {@code ::ffff:127.0.0.1}) counts, as does any address bound to a local interface - so it
|
||||
* works whether the desktop app reaches the sidecar over {@code localhost} or a LAN IP, while
|
||||
* still rejecting other machines on the network.
|
||||
*/
|
||||
static boolean isLocalRequest(String remoteAddr) {
|
||||
if (remoteAddr == null || remoteAddr.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
InetAddress addr = InetAddress.getByName(remoteAddr);
|
||||
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) {
|
||||
return true;
|
||||
}
|
||||
return NetworkInterface.networkInterfaces()
|
||||
.anyMatch(nif -> nif.inetAddresses().anyMatch(local -> local.equals(addr)));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Capabilities
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public HardwareSigningCapabilities capabilities() {
|
||||
boolean desktop = isDesktop();
|
||||
if (!desktop) {
|
||||
return new HardwareSigningCapabilities(false, "", false, false, List.of());
|
||||
}
|
||||
return new HardwareSigningCapabilities(
|
||||
true,
|
||||
System.getProperty("os.name", ""),
|
||||
windowsStoreSupported(),
|
||||
pkcs11Supported(),
|
||||
detectPkcs11Libraries());
|
||||
}
|
||||
|
||||
/**
|
||||
* Known driver install locations plus any paths configured via {@code
|
||||
* STIRLING_PKCS11_LIBRARIES}.
|
||||
*/
|
||||
public List<Pkcs11LibraryInfo> detectPkcs11Libraries() {
|
||||
Map<String, List<String>> candidates = new LinkedHashMap<>();
|
||||
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
||||
|
||||
if (os.contains("win")) {
|
||||
candidates.put(
|
||||
"OpenSC",
|
||||
List.of(
|
||||
"C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
|
||||
candidates.put(
|
||||
"YubiKey (ykcs11)",
|
||||
List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll"));
|
||||
candidates.put("SafeNet eToken", List.of("C:\\Windows\\System32\\eTPKCS11.dll"));
|
||||
candidates.put(
|
||||
"Thales/Gemalto IDPrime", List.of("C:\\Windows\\System32\\IDPrimePKCS11.dll"));
|
||||
candidates.put(
|
||||
"SoftHSM2",
|
||||
List.of(
|
||||
"C:\\Program Files\\SoftHSM2\\lib\\softhsm2-x64.dll",
|
||||
"C:\\SoftHSM2\\lib\\softhsm2-x64.dll"));
|
||||
} else if (os.contains("mac")) {
|
||||
candidates.put(
|
||||
"OpenSC",
|
||||
List.of(
|
||||
"/Library/OpenSC/lib/opensc-pkcs11.so",
|
||||
"/usr/local/lib/opensc-pkcs11.so"));
|
||||
candidates.put(
|
||||
"YubiKey (ykcs11)",
|
||||
List.of("/usr/local/lib/libykcs11.dylib", "/opt/homebrew/lib/libykcs11.dylib"));
|
||||
candidates.put(
|
||||
"SoftHSM2",
|
||||
List.of(
|
||||
"/usr/local/lib/softhsm/libsofthsm2.so",
|
||||
"/opt/homebrew/lib/softhsm/libsofthsm2.so"));
|
||||
} else {
|
||||
candidates.put(
|
||||
"OpenSC",
|
||||
List.of(
|
||||
"/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
|
||||
"/usr/lib/opensc-pkcs11.so",
|
||||
"/usr/lib64/opensc-pkcs11.so"));
|
||||
candidates.put(
|
||||
"YubiKey (ykcs11)",
|
||||
List.of(
|
||||
"/usr/lib/x86_64-linux-gnu/libykcs11.so",
|
||||
"/usr/local/lib/libykcs11.so"));
|
||||
candidates.put(
|
||||
"SoftHSM2",
|
||||
List.of(
|
||||
"/usr/lib/softhsm/libsofthsm2.so",
|
||||
"/usr/lib64/softhsm/libsofthsm2.so",
|
||||
"/usr/local/lib/softhsm/libsofthsm2.so"));
|
||||
}
|
||||
|
||||
List<Pkcs11LibraryInfo> result = new ArrayList<>();
|
||||
candidates.forEach(
|
||||
(name, paths) ->
|
||||
paths.stream()
|
||||
.filter(p -> Files.exists(Path.of(p)))
|
||||
.findFirst()
|
||||
.ifPresent(p -> result.add(new Pkcs11LibraryInfo(name, p))));
|
||||
|
||||
for (String configured : configuredLibraries()) {
|
||||
if (Files.exists(Path.of(configured))
|
||||
&& result.stream().noneMatch(l -> sameFile(l.path(), configured))) {
|
||||
result.add(new Pkcs11LibraryInfo(fileName(configured), configured));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<String> configuredLibraries() {
|
||||
String env = System.getenv(PKCS11_LIBRARIES_ENV);
|
||||
String prop = System.getProperty(PKCS11_LIBRARIES_PROP);
|
||||
StringBuilder combined = new StringBuilder();
|
||||
if (env != null && !env.isBlank()) {
|
||||
combined.append(env);
|
||||
}
|
||||
if (prop != null && !prop.isBlank()) {
|
||||
if (combined.length() > 0) {
|
||||
combined.append(java.io.File.pathSeparator);
|
||||
}
|
||||
combined.append(prop);
|
||||
}
|
||||
if (combined.length() == 0) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Windows certificate store
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
public KeyStore loadWindowsKeyStore() throws Exception {
|
||||
if (!windowsStoreSupported()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.windowsStoreUnavailable",
|
||||
"The Windows certificate store is not available on this platform");
|
||||
}
|
||||
KeyStore ks = KeyStore.getInstance(WINDOWS_KEYSTORE_TYPE, MSCAPI_PROVIDER);
|
||||
ks.load(null, null);
|
||||
return ks;
|
||||
}
|
||||
|
||||
public Provider windowsProvider() {
|
||||
return Security.getProvider(MSCAPI_PROVIDER);
|
||||
}
|
||||
|
||||
public List<HardwareCertificateInfo> listWindowsCertificates() throws Exception {
|
||||
return listSigningCertificates(loadWindowsKeyStore(), SOURCE_WINDOWS_STORE);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PKCS#11 tokens
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A configured, logged-in PKCS#11 keystore plus the provider that must service signing. Closing
|
||||
* logs the session out so the PIN-authenticated session does not outlive the request. The
|
||||
* provider stays cached (logout is C_Logout, not C_Finalize) so the next call reuses the same
|
||||
* C_Initialize. Single-user desktop model - logout is best-effort.
|
||||
*/
|
||||
public record Pkcs11Session(KeyStore keyStore, Provider provider) implements AutoCloseable {
|
||||
@Override
|
||||
public void close() {
|
||||
if (provider instanceof java.security.AuthProvider authProvider) {
|
||||
try {
|
||||
authProvider.logout();
|
||||
} catch (Exception e) {
|
||||
// Not logged in / already logged out - nothing to clear.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One SunPKCS11 provider per driver+slot, reused across enumerate + sign. A PKCS#11 module
|
||||
// typically allows C_Initialize only once per process, so configuring a fresh provider on every
|
||||
// call races with the previous (not-yet-GC'd) one - the cause of "first sign fails, second
|
||||
// works". Reusing the provider keeps a single C_Initialize alive for the session.
|
||||
private final java.util.concurrent.ConcurrentHashMap<String, Provider> pkcs11Providers =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
public Pkcs11Session openPkcs11(String libraryPath, Integer slot, char[] pin) throws Exception {
|
||||
validateLibraryAllowed(libraryPath);
|
||||
if (!pkcs11Supported()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pkcs11Unavailable", "PKCS#11 support is not available in this runtime");
|
||||
}
|
||||
|
||||
String cacheKey = libraryPath + "|" + slot;
|
||||
Provider provider =
|
||||
pkcs11Providers.computeIfAbsent(
|
||||
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
|
||||
try {
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", provider);
|
||||
ks.load(null, pin);
|
||||
return new Pkcs11Session(ks, provider);
|
||||
} catch (Exception e) {
|
||||
// A wrong PIN must not be retried: a second C_Login would burn the token's retry
|
||||
// counter twice per attempt and can lock the token. Only rebuild on provider/init
|
||||
// failures (e.g. token removed/re-inserted leaving a stale provider).
|
||||
if (isAuthFailure(e)) {
|
||||
throw e;
|
||||
}
|
||||
pkcs11Providers.remove(cacheKey, provider);
|
||||
Provider fresh =
|
||||
pkcs11Providers.computeIfAbsent(
|
||||
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", fresh);
|
||||
ks.load(null, pin);
|
||||
return new Pkcs11Session(ks, fresh);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the failure is a bad/locked PIN rather than a provider/init/device problem. */
|
||||
private static boolean isAuthFailure(Throwable t) {
|
||||
while (t != null) {
|
||||
if (t instanceof javax.security.auth.login.FailedLoginException) {
|
||||
return true;
|
||||
}
|
||||
String msg = t.getMessage();
|
||||
if (msg != null && msg.toUpperCase(Locale.ROOT).contains("CKR_PIN")) {
|
||||
return true; // CKR_PIN_INCORRECT / CKR_PIN_LOCKED / CKR_PIN_INVALID / ...
|
||||
}
|
||||
t = t.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Provider buildPkcs11Provider(String libraryPath, Integer slot) {
|
||||
StringBuilder config = new StringBuilder();
|
||||
config.append("--name=").append(providerName(libraryPath)).append('\n');
|
||||
config.append("library=").append(libraryPath).append('\n');
|
||||
if (slot != null) {
|
||||
config.append("slot=").append(slot).append('\n');
|
||||
}
|
||||
try {
|
||||
return Security.getProvider(PKCS11_BASE_PROVIDER).configure(config.toString());
|
||||
} catch (Exception e) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pkcs11ConfigFailed",
|
||||
"Failed to initialise the PKCS#11 driver: {0}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<HardwareCertificateInfo> listPkcs11Certificates(
|
||||
String libraryPath, Integer slot, char[] pin) throws Exception {
|
||||
try (Pkcs11Session session = openPkcs11(libraryPath, slot, pin)) {
|
||||
return listSigningCertificates(session.keyStore(), SOURCE_PKCS11);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject driver paths that are not detected on disk / configured - blocks arbitrary DLL loads.
|
||||
*/
|
||||
public void validateLibraryAllowed(String libraryPath) {
|
||||
if (libraryPath == null || libraryPath.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pkcs11LibraryRequired", "A PKCS#11 driver library path is required");
|
||||
}
|
||||
Set<String> allowed =
|
||||
detectPkcs11Libraries().stream()
|
||||
.map(Pkcs11LibraryInfo::path)
|
||||
.collect(Collectors.toSet());
|
||||
boolean ok = allowed.stream().anyMatch(p -> sameFile(p, libraryPath));
|
||||
if (!ok) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pkcs11LibraryNotAllowed",
|
||||
"PKCS#11 driver is not in the allowed list. Add it via the"
|
||||
+ " STIRLING_PKCS11_LIBRARIES setting: {0}",
|
||||
libraryPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private List<HardwareCertificateInfo> listSigningCertificates(KeyStore ks, String source)
|
||||
throws Exception {
|
||||
List<HardwareCertificateInfo> certs = new ArrayList<>();
|
||||
Enumeration<String> aliases = ks.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
String alias = aliases.nextElement();
|
||||
if (!ks.isKeyEntry(alias)) {
|
||||
continue; // only entries we can sign with
|
||||
}
|
||||
Certificate cert = ks.getCertificate(alias);
|
||||
if (cert instanceof X509Certificate x509) {
|
||||
certs.add(toInfo(alias, x509, source));
|
||||
}
|
||||
}
|
||||
return certs;
|
||||
}
|
||||
|
||||
private static HardwareCertificateInfo toInfo(
|
||||
String alias, X509Certificate cert, String source) {
|
||||
java.util.Date now = new java.util.Date();
|
||||
return new HardwareCertificateInfo(
|
||||
alias,
|
||||
source,
|
||||
cert.getSubjectX500Principal().getName(),
|
||||
cert.getIssuerX500Principal().getName(),
|
||||
commonName(cert.getSubjectX500Principal()),
|
||||
commonName(cert.getIssuerX500Principal()),
|
||||
cert.getSerialNumber().toString(16),
|
||||
cert.getPublicKey().getAlgorithm(),
|
||||
cert.getNotBefore().toInstant().toString(),
|
||||
cert.getNotAfter().toInstant().toString(),
|
||||
now.after(cert.getNotAfter()),
|
||||
now.before(cert.getNotBefore()));
|
||||
}
|
||||
|
||||
private static String commonName(X500Principal principal) {
|
||||
try {
|
||||
X500Name x500Name = new X500Name(principal.getName());
|
||||
RDN[] rdns = x500Name.getRDNs(BCStyle.CN);
|
||||
if (rdns.length > 0) {
|
||||
return IETFUtils.valueToString(rdns[0].getFirst().getValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not parse common name from {}", principal.getName());
|
||||
}
|
||||
return principal.getName();
|
||||
}
|
||||
|
||||
private static String providerName(String libraryPath) {
|
||||
String base = fileName(libraryPath).replaceAll("[^a-zA-Z0-9]", "");
|
||||
if (base.isEmpty()) {
|
||||
base = "token";
|
||||
}
|
||||
return "StirlingHW" + base;
|
||||
}
|
||||
|
||||
private static String fileName(String path) {
|
||||
try {
|
||||
return Path.of(path).getFileName().toString();
|
||||
} catch (Exception e) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sameFile(String a, String b) {
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Path pa = Path.of(a);
|
||||
Path pb = Path.of(b);
|
||||
if (Files.exists(pa) && Files.exists(pb)) {
|
||||
return Files.isSameFile(pa, pb);
|
||||
}
|
||||
return pa.toAbsolutePath().normalize().equals(pb.toAbsolutePath().normalize());
|
||||
} catch (Exception e) {
|
||||
return a.equalsIgnoreCase(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -165,10 +163,14 @@ legal:
|
||||
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
|
||||
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
|
||||
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
|
||||
loginAgreement:
|
||||
enabled: false # set to 'true' to show a login agreement/disclaimer popup after login (and on app launch when login is disabled). Per-language text is read from customFiles/disclaimer/<locale>.md (e.g. en-GB.md, fr-FR.md)
|
||||
showInAnonymousMode: true # when login is disabled, set to 'false' to suppress the agreement in anonymous (no-login) mode
|
||||
fallbackText: "" # optional markdown used for any language that has no customFiles/disclaimer/<locale>.md file (also settable via the LEGAL_LOGINAGREEMENT_FALLBACKTEXT env var for single-language headless installs)
|
||||
|
||||
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'
|
||||
@@ -182,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.
|
||||
@@ -193,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:
|
||||
@@ -300,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
|
||||
|
||||
@@ -179,6 +179,13 @@
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.code.gson:gson",
|
||||
"moduleUrl": "https://github.com/google/gson",
|
||||
"moduleVersion": "2.14.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.errorprone:error_prone_annotations",
|
||||
"moduleUrl": "https://errorprone.info/error_prone_annotations",
|
||||
@@ -186,6 +193,13 @@
|
||||
"moduleLicense": "Apache 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.errorprone:error_prone_annotations",
|
||||
"moduleUrl": "https://errorprone.info/error_prone_annotations",
|
||||
"moduleVersion": "2.48.0",
|
||||
"moduleLicense": "Apache 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.guava:failureaccess",
|
||||
"moduleUrl": "https://github.com/google/guava/",
|
||||
@@ -629,13 +643,6 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-io:commons-io",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
|
||||
"moduleVersion": "2.21.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-io:commons-io",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
|
||||
|
||||
+31
@@ -9,6 +9,7 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
@@ -302,6 +303,11 @@ class RearrangePagesPDFControllerTest {
|
||||
assertNotNull(response);
|
||||
// 2 pages * 3 duplicates = 6 final pages
|
||||
assertEquals(6, realDoc.getNumberOfPages());
|
||||
// Each duplicate must be a distinct page node in the saved output; a shared
|
||||
// node under multiple /Kids is an invalid tree readers reject as cyclic.
|
||||
List<Object> savedPages = reloadAndSnapshot(response);
|
||||
assertEquals(6, savedPages.size());
|
||||
assertEquals(6, new HashSet<>(savedPages).size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,4 +329,29 @@ class RearrangePagesPDFControllerTest {
|
||||
assertEquals(4, realDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRearrangePages_SideStitchBooklet_RepeatedPaddingPagesAreDistinctNodes()
|
||||
throws IOException {
|
||||
MockMultipartFile file = createMockPdf();
|
||||
RearrangePagesRequest request = new RearrangePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("SIDE_STITCH_BOOKLET_SORT");
|
||||
|
||||
// 6 pages is not a multiple of 4, so booklet padding repeats the last page index
|
||||
// several times; each repeat must be a distinct page node, not one shared node.
|
||||
try (PDDocument realDoc = buildRealPdf(6)) {
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(8, realDoc.getNumberOfPages());
|
||||
List<Object> savedPages = reloadAndSnapshot(response);
|
||||
assertEquals(8, savedPages.size());
|
||||
assertEquals(8, new HashSet<>(savedPages).size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
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.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.SPDF.controller.api.misc.LoginDisclaimerController.LoginDisclaimerResponse;
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoginDisclaimerControllerTest {
|
||||
|
||||
@Mock LoginAgreementService loginAgreementService;
|
||||
|
||||
@InjectMocks LoginDisclaimerController controller;
|
||||
|
||||
@Test
|
||||
void disabledReturnsEmptyContent() {
|
||||
when(loginAgreementService.isEnabled()).thenReturn(false);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("en-GB");
|
||||
|
||||
assertFalse(resp.enabled());
|
||||
assertEquals("", resp.content());
|
||||
assertTrue(resp.showInAnonymousMode());
|
||||
assertEquals("markdown", resp.format());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledWithContentReturnsIt() {
|
||||
when(loginAgreementService.isEnabled()).thenReturn(true);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(false);
|
||||
when(loginAgreementService.resolveContent("fr-FR")).thenReturn("# Avis");
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("fr-FR");
|
||||
|
||||
assertTrue(resp.enabled());
|
||||
assertEquals("# Avis", resp.content());
|
||||
assertFalse(resp.showInAnonymousMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledButBlankContentReportsDisabled() {
|
||||
// No file for any candidate locale and no fallbackText -> report disabled so clients
|
||||
// don't render an empty agreement.
|
||||
when(loginAgreementService.isEnabled()).thenReturn(true);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
|
||||
when(loginAgreementService.resolveContent("ja-JP")).thenReturn(" ");
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("ja-JP");
|
||||
|
||||
assertFalse(resp.enabled());
|
||||
assertEquals("", resp.content());
|
||||
}
|
||||
}
|
||||
+20
-8
@@ -30,7 +30,10 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.SPDF.service.HardwareKeyStoreService;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
@@ -51,6 +54,8 @@ class CertSignControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private HardwareKeyStoreService hardwareKeyStoreService;
|
||||
@Mock private HttpServletRequest httpRequest;
|
||||
|
||||
@InjectMocks private CertSignController certSignController;
|
||||
|
||||
@@ -169,7 +174,8 @@ class CertSignControllerTest {
|
||||
request.setPageNumber(1);
|
||||
request.setShowLogo(false);
|
||||
|
||||
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
|
||||
ResponseEntity<Resource> response =
|
||||
certSignController.signPDFWithCert(request, httpRequest);
|
||||
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
@@ -195,7 +201,8 @@ class CertSignControllerTest {
|
||||
request.setPageNumber(1);
|
||||
request.setShowLogo(false);
|
||||
|
||||
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
|
||||
ResponseEntity<Resource> response =
|
||||
certSignController.signPDFWithCert(request, httpRequest);
|
||||
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
@@ -221,7 +228,7 @@ class CertSignControllerTest {
|
||||
IllegalArgumentException exception =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> certSignController.signPDFWithCert(request));
|
||||
() -> certSignController.signPDFWithCert(request, httpRequest));
|
||||
|
||||
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
|
||||
}
|
||||
@@ -247,7 +254,8 @@ class CertSignControllerTest {
|
||||
request.setPageNumber(1);
|
||||
request.setShowLogo(false);
|
||||
|
||||
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
|
||||
ResponseEntity<Resource> response =
|
||||
certSignController.signPDFWithCert(request, httpRequest);
|
||||
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
@@ -278,7 +286,8 @@ class CertSignControllerTest {
|
||||
request.setPageNumber(1);
|
||||
request.setShowLogo(false);
|
||||
|
||||
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
|
||||
ResponseEntity<Resource> response =
|
||||
certSignController.signPDFWithCert(request, httpRequest);
|
||||
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
@@ -309,7 +318,8 @@ class CertSignControllerTest {
|
||||
request.setPageNumber(1);
|
||||
request.setShowLogo(false);
|
||||
|
||||
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
|
||||
ResponseEntity<Resource> response =
|
||||
certSignController.signPDFWithCert(request, httpRequest);
|
||||
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
@@ -340,7 +350,8 @@ class CertSignControllerTest {
|
||||
request.setPageNumber(1);
|
||||
request.setShowLogo(false);
|
||||
|
||||
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
|
||||
ResponseEntity<Resource> response =
|
||||
certSignController.signPDFWithCert(request, httpRequest);
|
||||
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
@@ -371,7 +382,8 @@ class CertSignControllerTest {
|
||||
request.setPageNumber(1);
|
||||
request.setShowLogo(false);
|
||||
|
||||
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
|
||||
ResponseEntity<Resource> response =
|
||||
certSignController.signPDFWithCert(request, httpRequest);
|
||||
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
|
||||
|
||||
/** Unit tests for the gating / allowlist logic that protects the hardware signing paths. */
|
||||
class HardwareKeyStoreServiceTest {
|
||||
|
||||
private static final String PKCS11_PROP = "stirling.pkcs11.libraries";
|
||||
|
||||
private HardwareKeyStoreService service(String machineType) {
|
||||
return new HardwareKeyStoreService(machineType);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDesktop_trueOnlyForClientMachineTypes() {
|
||||
assertTrue(service("Client-windows").isDesktop());
|
||||
assertTrue(service("Client-mac").isDesktop());
|
||||
assertTrue(service("Client-unix").isDesktop());
|
||||
assertFalse(service("Server-jar").isDesktop());
|
||||
assertFalse(service("Docker").isDesktop());
|
||||
assertFalse(service(null).isDesktop());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isDesktop_trueInTauriModeEvenWithoutClientMachineType() {
|
||||
// The Tauri bundle sets STIRLING_PDF_TAURI_MODE=true while machineType stays Server-jar.
|
||||
String previous = System.getProperty("STIRLING_PDF_TAURI_MODE");
|
||||
try {
|
||||
System.setProperty("STIRLING_PDF_TAURI_MODE", "true");
|
||||
assertTrue(service("Server-jar").isDesktop());
|
||||
assertTrue(service(null).isDesktop());
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
System.clearProperty("STIRLING_PDF_TAURI_MODE");
|
||||
} else {
|
||||
System.setProperty("STIRLING_PDF_TAURI_MODE", previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void capabilities_notDesktop_reportsUnavailable() {
|
||||
HardwareSigningCapabilities caps = service("Server-jar").capabilities();
|
||||
assertFalse(caps.desktop());
|
||||
assertFalse(caps.windowsStoreSupported());
|
||||
assertFalse(caps.pkcs11Supported());
|
||||
assertTrue(caps.detectedLibraries().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void capabilities_desktop_reportsOsName() {
|
||||
HardwareSigningCapabilities caps = service("Client-windows").capabilities();
|
||||
assertTrue(caps.desktop());
|
||||
assertFalse(caps.osName().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void assertLocalDesktop_rejectsNonDesktop() {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service("Server-jar").assertLocalDesktop(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assertLocalDesktop_rejectsRemoteCallerEvenOnDesktop() {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
// 203.0.113.0/24 is TEST-NET-3 (RFC 5737) - never a real local interface address.
|
||||
when(request.getRemoteAddr()).thenReturn("203.0.113.5");
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service("Client-windows").assertLocalDesktop(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assertLocalDesktop_allowsLoopbackOnDesktop() {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
|
||||
assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(request));
|
||||
// No servlet context (e.g. internal call) is also allowed.
|
||||
assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLocalRequest_acceptsLoopbackForms_rejectsRemote() {
|
||||
assertTrue(HardwareKeyStoreService.isLocalRequest("127.0.0.1"));
|
||||
assertTrue(HardwareKeyStoreService.isLocalRequest("::1"));
|
||||
assertTrue(HardwareKeyStoreService.isLocalRequest("0:0:0:0:0:0:0:1"));
|
||||
// IPv4-mapped IPv6 loopback - what Tomcat reports for the desktop webview.
|
||||
assertTrue(HardwareKeyStoreService.isLocalRequest("::ffff:127.0.0.1"));
|
||||
assertFalse(HardwareKeyStoreService.isLocalRequest("203.0.113.5"));
|
||||
assertFalse(HardwareKeyStoreService.isLocalRequest(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateLibraryAllowed_blankPath_throws() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service("Client-windows").validateLibraryAllowed(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateLibraryAllowed_unknownPath_throws() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
service("Client-windows")
|
||||
.validateLibraryAllowed("/definitely/not/a/real/driver.so"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateLibraryAllowed_configuredPath_isAllowed(@TempDir Path tempDir) throws Exception {
|
||||
Path fakeDriver = Files.createFile(tempDir.resolve("fake-pkcs11.so"));
|
||||
String previous = System.getProperty(PKCS11_PROP);
|
||||
try {
|
||||
System.setProperty(PKCS11_PROP, fakeDriver.toString());
|
||||
HardwareKeyStoreService service = service("Client-windows");
|
||||
assertDoesNotThrow(() -> service.validateLibraryAllowed(fakeDriver.toString()));
|
||||
assertTrue(
|
||||
service.detectPkcs11Libraries().stream()
|
||||
.anyMatch(l -> l.path().equals(fakeDriver.toString())));
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
System.clearProperty(PKCS11_PROP);
|
||||
} else {
|
||||
System.setProperty(PKCS11_PROP, previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,10 @@ dependencies {
|
||||
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
|
||||
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
|
||||
|
||||
// @DataJpaTest slice (Boot 4 ships test slices as separate starters, like webmvc-test at the
|
||||
// root) so policy.source repositories can be exercised against embedded H2.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
|
||||
|
||||
// Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without
|
||||
// manually-started instances. Tests skip cleanly when Docker is unavailable.
|
||||
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package stirling.software.proprietary.access.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import stirling.software.proprietary.access.service.DefaultTeamLeadLookup;
|
||||
import stirling.software.proprietary.access.service.TeamLeadLookup;
|
||||
|
||||
/** Access-layer bean wiring. */
|
||||
@Configuration
|
||||
public class AccessConfig {
|
||||
|
||||
/** No-op {@link TeamLeadLookup} unless another bean is defined. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(TeamLeadLookup.class)
|
||||
TeamLeadLookup defaultTeamLeadLookup() {
|
||||
return new DefaultTeamLeadLookup();
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package stirling.software.proprietary.access.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.access.model.AccessPermission;
|
||||
import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.service.ResourceAccessService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/access")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)")
|
||||
public class ResourceGrantController {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
|
||||
@GetMapping("/grants")
|
||||
public ResponseEntity<?> list(
|
||||
@RequestParam ResourceType resourceType,
|
||||
@RequestParam(required = false, defaultValue = "") String resourceId) {
|
||||
List<ResourceGrant> grants = accessService.listGrants(resourceType, resourceId);
|
||||
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/grants")
|
||||
public ResponseEntity<?> create(
|
||||
@RequestBody GrantRequest request, @AuthenticationPrincipal User admin) {
|
||||
if (request.resourceType() == null
|
||||
|| request.principalType() == null
|
||||
|| request.principalId() == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"resourceType, principalType and principalId are required"));
|
||||
}
|
||||
AccessPermission permission =
|
||||
request.permission() == null ? AccessPermission.USE : request.permission();
|
||||
// PORTAL is a singleton resource; its grants always target the whole type.
|
||||
String resourceId =
|
||||
request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId();
|
||||
ResourceGrant grant =
|
||||
accessService.grant(
|
||||
request.resourceType(),
|
||||
resourceId,
|
||||
request.principalType(),
|
||||
request.principalId(),
|
||||
permission,
|
||||
admin);
|
||||
return ResponseEntity.ok(toDto(grant));
|
||||
}
|
||||
|
||||
@DeleteMapping("/grants/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
accessService.revoke(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Grant revoked"));
|
||||
}
|
||||
|
||||
private Map<String, Object> toDto(ResourceGrant g) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", g.getId());
|
||||
m.put("resourceType", g.getResourceType());
|
||||
m.put("resourceId", g.getResourceId());
|
||||
m.put("principalType", g.getPrincipalType());
|
||||
m.put("principalId", g.getPrincipalId());
|
||||
m.put("permission", g.getPermission());
|
||||
m.put("createdAt", g.getCreatedAt());
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Request body for creating a grant. */
|
||||
public record GrantRequest(
|
||||
ResourceType resourceType,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId,
|
||||
AccessPermission permission) {}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Permission level a grant confers. MANAGE implies USE. */
|
||||
public enum AccessPermission {
|
||||
USE,
|
||||
MANAGE
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/**
|
||||
* Fallback policy applied when no explicit {@link ResourceGrant} matches. Admins (org owners)
|
||||
* always pass regardless of this policy.
|
||||
*/
|
||||
public enum DefaultAccessPolicy {
|
||||
// Every authenticated user in the deployment (org) may use the resource.
|
||||
ORG_ALL,
|
||||
// Only org admins and team leaders. This is the default for the portal.
|
||||
ADMINS_AND_TEAM_LEADS,
|
||||
// Nobody but the owner, admins, and explicit grantees.
|
||||
EXPLICIT_ONLY
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Base for a resource owned by a user, a team, or the server, with grant-based access. */
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
public abstract class OwnedResource {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "scope", nullable = false, length = 32)
|
||||
private OwnerScope scope;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_user_id")
|
||||
private User ownerUser;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_team_id")
|
||||
private Team ownerTeam;
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private boolean enabled = true;
|
||||
|
||||
// Server resource that users cannot override with their own of the same kind.
|
||||
@Column(name = "locked", nullable = false)
|
||||
private boolean locked = false;
|
||||
|
||||
// Who, besides owner/admin/grantees, may use this resource.
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "default_access", nullable = false, length = 32)
|
||||
private DefaultAccessPolicy defaultAccess = DefaultAccessPolicy.EXPLICIT_ONLY;
|
||||
|
||||
/** Subclass primary key. */
|
||||
public abstract Long getId();
|
||||
|
||||
public Long getOwnerUserId() {
|
||||
return ownerUser != null ? ownerUser.getId() : null;
|
||||
}
|
||||
|
||||
public Long getOwnerTeamId() {
|
||||
return ownerTeam != null ? ownerTeam.getId() : null;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Ownership scope of an {@link OwnedResource}: a single user, a team, or the whole server. */
|
||||
public enum OwnerScope {
|
||||
USER,
|
||||
TEAM,
|
||||
SERVER
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */
|
||||
public enum PrincipalType {
|
||||
USER,
|
||||
TEAM
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Grants a user or team access to a resource. Owner and admin access are implicit. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "resource_grants",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_resource_grant",
|
||||
columnNames = {
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission"
|
||||
}),
|
||||
indexes = {
|
||||
@Index(name = "idx_resource_grants_lookup", columnList = "resource_type,resource_id"),
|
||||
@Index(
|
||||
name = "idx_resource_grants_principal",
|
||||
columnList = "principal_type,principal_id")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ResourceGrant implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "resource_grant_id")
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "resource_type", nullable = false, length = 64)
|
||||
private ResourceType resourceType;
|
||||
|
||||
// Empty string (never null) for a whole-type grant such as the portal.
|
||||
@Column(name = "resource_id", nullable = false, length = 255)
|
||||
private String resourceId = "";
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "principal_type", nullable = false, length = 32)
|
||||
private PrincipalType principalType;
|
||||
|
||||
@Column(name = "principal_id", nullable = false)
|
||||
private Long principalId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "permission", nullable = false, length = 32)
|
||||
private AccessPermission permission;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "granted_by_user_id")
|
||||
private User grantedBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
|
||||
public enum ResourceType {
|
||||
// The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
|
||||
// resourceId).
|
||||
PORTAL,
|
||||
// A stored S3/MCP/API integration configuration.
|
||||
INTEGRATION_CONFIG
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.access.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
|
||||
@Repository
|
||||
public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Long> {
|
||||
|
||||
List<ResourceGrant> findByResourceTypeAndResourceId(
|
||||
ResourceType resourceType, String resourceId);
|
||||
|
||||
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType, PrincipalType principalType, Long principalId);
|
||||
|
||||
void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId);
|
||||
|
||||
boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package stirling.software.proprietary.access.security;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.access.service.ResourceAccessService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */
|
||||
@Component("resourceAccess")
|
||||
@RequiredArgsConstructor
|
||||
public class ResourceAccessSecurity {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
private final UserService userService;
|
||||
|
||||
public boolean canUsePortal() {
|
||||
User user = currentUser();
|
||||
return user != null && accessService.canAccessPortal(user);
|
||||
}
|
||||
|
||||
private User currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
if (principal instanceof UserDetails userDetails) {
|
||||
return userService.findByUsername(userDetails.getUsername()).orElse(null);
|
||||
}
|
||||
if (principal instanceof String username && !"anonymousUser".equals(username)) {
|
||||
return userService.findByUsername(username).orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** No-op {@link TeamLeadLookup}: always false. */
|
||||
public class DefaultTeamLeadLookup implements TeamLeadLookup {
|
||||
|
||||
@Override
|
||||
public boolean isAnyTeamLeader(User user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLeaderOfTeam(User user, Long teamId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.access.model.OwnedResource;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
/** Ownership and access checks for {@link OwnedResource}, backed by the resource-grant ACL. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class OwnershipService {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
private final TeamLeadLookup teamLeadLookup;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
/** Whether the user may use the resource. */
|
||||
public boolean canUse(ResourceType type, OwnedResource resource, User user) {
|
||||
if (!resource.isEnabled()) {
|
||||
return isAdmin(user) || isOwner(resource, user);
|
||||
}
|
||||
return accessService.canUseResource(
|
||||
type,
|
||||
String.valueOf(resource.getId()),
|
||||
resource.getOwnerUserId(),
|
||||
resource.getDefaultAccess(),
|
||||
user);
|
||||
}
|
||||
|
||||
/** Whether the user may manage the resource. */
|
||||
public boolean canManage(ResourceType type, OwnedResource resource, User user) {
|
||||
return accessService.canManageResource(
|
||||
type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizes the scope and assigns ownership; {@code lockedOverrideBlocks} guards USER scope.
|
||||
*/
|
||||
public void assignOwnership(
|
||||
OwnedResource resource,
|
||||
OwnerScope scope,
|
||||
Long teamId,
|
||||
User user,
|
||||
BooleanSupplier lockedOverrideBlocks) {
|
||||
resource.setScope(scope);
|
||||
switch (scope) {
|
||||
case USER -> {
|
||||
if (lockedOverrideBlocks.getAsBoolean() && !isAdmin(user)) {
|
||||
throw forbidden(
|
||||
"This is locked to the server configuration by an administrator");
|
||||
}
|
||||
resource.setOwnerUser(user);
|
||||
}
|
||||
case SERVER -> {
|
||||
if (!isAdmin(user)) {
|
||||
throw forbidden("Only administrators can create server-owned resources");
|
||||
}
|
||||
}
|
||||
case TEAM -> {
|
||||
if (teamId == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "ownerTeamId is required");
|
||||
}
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> notFound("Team not found"));
|
||||
if (!isAdmin(user) && !teamLeadLookup.isLeaderOfTeam(user, team.getId())) {
|
||||
throw forbidden("Only admins or team leaders can create team-owned resources");
|
||||
}
|
||||
resource.setOwnerTeam(team);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resource ids of the given type the user or their team holds a grant on. */
|
||||
public Set<String> grantedResourceIds(ResourceType type, User user) {
|
||||
return accessService.grantedResourceIds(type, user);
|
||||
}
|
||||
|
||||
public boolean isAdmin(User user) {
|
||||
return user.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
public boolean isOwner(OwnedResource resource, User user) {
|
||||
if (resource.getOwnerUserId() != null && resource.getOwnerUserId().equals(user.getId())) {
|
||||
return true;
|
||||
}
|
||||
// Team-owned: the lead of the owning team owns it.
|
||||
return resource.getOwnerTeamId() != null
|
||||
&& teamLeadLookup.isLeaderOfTeam(user, resource.getOwnerTeamId());
|
||||
}
|
||||
|
||||
private ResponseStatusException forbidden(String message) {
|
||||
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
|
||||
}
|
||||
|
||||
private ResponseStatusException notFound(String message) {
|
||||
return new ResponseStatusException(HttpStatus.NOT_FOUND, message);
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.access.model.AccessPermission;
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Resolves access to gated resources: owner, then admin, then grant, then default policy. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional(readOnly = true)
|
||||
public class ResourceAccessService {
|
||||
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
private final TeamLeadLookup teamLeadLookup;
|
||||
|
||||
@Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}")
|
||||
private DefaultAccessPolicy portalDefaultPolicy;
|
||||
|
||||
// ---- public checks ----
|
||||
|
||||
/** Whether the user may use the portal / processor. */
|
||||
public boolean canAccessPortal(User user) {
|
||||
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
|
||||
}
|
||||
|
||||
/** Whether the user may use a resource, falling back to its default policy. */
|
||||
public boolean canUseResource(
|
||||
ResourceType type,
|
||||
String resourceId,
|
||||
Long ownerUserId,
|
||||
DefaultAccessPolicy defaultPolicy,
|
||||
User user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner(ownerUserId, user) || isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) {
|
||||
return true;
|
||||
}
|
||||
return matchesDefault(defaultPolicy, user);
|
||||
}
|
||||
|
||||
/** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */
|
||||
public boolean canManageResource(
|
||||
ResourceType type, String resourceId, Long ownerUserId, User user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner(ownerUserId, user) || isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE);
|
||||
}
|
||||
|
||||
// ---- grant management ----
|
||||
|
||||
@Transactional
|
||||
public ResourceGrant grant(
|
||||
ResourceType type,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId,
|
||||
AccessPermission permission,
|
||||
User grantedBy) {
|
||||
String rid = normalize(resourceId);
|
||||
ResourceGrant grant =
|
||||
grantRepository.findByResourceTypeAndResourceId(type, rid).stream()
|
||||
.filter(
|
||||
g ->
|
||||
g.getPrincipalType() == principalType
|
||||
&& g.getPrincipalId().equals(principalId))
|
||||
.findFirst()
|
||||
.orElseGet(ResourceGrant::new);
|
||||
grant.setResourceType(type);
|
||||
grant.setResourceId(rid);
|
||||
grant.setPrincipalType(principalType);
|
||||
grant.setPrincipalId(principalId);
|
||||
grant.setPermission(permission);
|
||||
if (grantedBy != null) {
|
||||
grant.setGrantedBy(grantedBy);
|
||||
}
|
||||
return grantRepository.save(grant);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void revoke(Long grantId) {
|
||||
grantRepository.deleteById(grantId);
|
||||
}
|
||||
|
||||
public List<ResourceGrant> listGrants(ResourceType type, String resourceId) {
|
||||
return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId));
|
||||
}
|
||||
|
||||
/** Resource ids of the given type that this user (or their team) holds any grant on. */
|
||||
public Set<String> grantedResourceIds(ResourceType type, User user) {
|
||||
if (user == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
type, PrincipalType.USER, user.getId())) {
|
||||
ids.add(g.getResourceId());
|
||||
}
|
||||
if (user.getTeam() != null) {
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
type, PrincipalType.TEAM, user.getTeam().getId())) {
|
||||
ids.add(g.getResourceId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
private boolean hasGrant(
|
||||
ResourceType type, String resourceId, User user, AccessPermission required) {
|
||||
Long teamId = user.getTeam() != null ? user.getTeam().getId() : null;
|
||||
for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) {
|
||||
if (!permissionSatisfies(g.getPermission(), required)) {
|
||||
continue;
|
||||
}
|
||||
if (g.getPrincipalType() == PrincipalType.USER
|
||||
&& g.getPrincipalId().equals(user.getId())) {
|
||||
return true;
|
||||
}
|
||||
if (g.getPrincipalType() == PrincipalType.TEAM
|
||||
&& teamId != null
|
||||
&& g.getPrincipalId().equals(teamId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// MANAGE implies USE.
|
||||
private boolean permissionSatisfies(AccessPermission held, AccessPermission required) {
|
||||
if (required == AccessPermission.USE) {
|
||||
return held == AccessPermission.USE || held == AccessPermission.MANAGE;
|
||||
}
|
||||
return held == AccessPermission.MANAGE;
|
||||
}
|
||||
|
||||
private boolean matchesDefault(DefaultAccessPolicy policy, User user) {
|
||||
if (policy == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (policy) {
|
||||
case ORG_ALL -> true;
|
||||
// Admins already pass above; only team leads here.
|
||||
case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user);
|
||||
case EXPLICIT_ONLY -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isOwner(Long ownerUserId, User user) {
|
||||
return ownerUserId != null && ownerUserId.equals(user.getId());
|
||||
}
|
||||
|
||||
private boolean isAdmin(User user) {
|
||||
return user.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
private String normalize(String resourceId) {
|
||||
return resourceId == null ? "" : resourceId;
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Masks, merges and sanitizes secret values in a config map, recursing into nested maps/lists. */
|
||||
@Component
|
||||
public class SecretMasker {
|
||||
|
||||
public static final String MASK = "********";
|
||||
|
||||
// Cap recursion so a pathologically nested payload cannot overflow the stack.
|
||||
private static final int MAX_DEPTH = 32;
|
||||
|
||||
private static final Set<String> SENSITIVE_HINTS =
|
||||
Set.of(
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"credential",
|
||||
"privatekey");
|
||||
|
||||
/** Replace sensitive values with the mask (recursively) for safe display. */
|
||||
public Map<String, Object> mask(Map<String, Object> config) {
|
||||
return mask(config, 0);
|
||||
}
|
||||
|
||||
/** Drop sensitive blank/masked values from an incoming create payload. */
|
||||
public Map<String, Object> sanitize(Map<String, Object> config) {
|
||||
return sanitize(config, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an update over the stored map, keeping stored secrets where the incoming is redacted.
|
||||
*/
|
||||
public Map<String, Object> merge(Map<String, Object> stored, Map<String, Object> incoming) {
|
||||
return merge(stored, incoming, 0);
|
||||
}
|
||||
|
||||
private Map<String, Object> mask(Map<String, Object> config, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : config.entrySet()) {
|
||||
out.put(e.getKey(), maskValue(e.getKey(), e.getValue(), depth));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> sanitize(Map<String, Object> config, int depth) {
|
||||
if (config == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : config.entrySet()) {
|
||||
if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) {
|
||||
continue;
|
||||
}
|
||||
out.put(
|
||||
e.getKey(),
|
||||
e.getValue() instanceof Map<?, ?> m && depth < MAX_DEPTH
|
||||
? sanitize(castMap(m), depth + 1)
|
||||
: e.getValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> merge(
|
||||
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>(stored);
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
String key = e.getKey();
|
||||
Object value = e.getValue();
|
||||
if (isSensitive(key)) {
|
||||
if (!isRedacted(value, depth)) {
|
||||
out.put(key, value); // a real new secret replaces the stored one
|
||||
}
|
||||
continue; // redacted (blank / mask) -> keep stored
|
||||
}
|
||||
if (depth < MAX_DEPTH
|
||||
&& out.get(key) instanceof Map<?, ?> s
|
||||
&& value instanceof Map<?, ?> i) {
|
||||
out.put(key, merge(castMap(s), castMap(i), depth + 1));
|
||||
} else {
|
||||
out.put(key, value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A sensitive key masks its whole value; recurse into non-sensitive containers.
|
||||
private Object maskValue(String key, Object value, int depth) {
|
||||
if (isSensitive(key)) {
|
||||
if (value == null || (value instanceof String s && s.isBlank())) {
|
||||
return value;
|
||||
}
|
||||
return MASK;
|
||||
}
|
||||
if (depth >= MAX_DEPTH) {
|
||||
// Too deep to descend; mask containers rather than risk leaking an unmasked secret.
|
||||
return value instanceof Map<?, ?> || value instanceof List<?> ? MASK : value;
|
||||
}
|
||||
if (value instanceof Map<?, ?> m) {
|
||||
return mask(castMap(m), depth + 1);
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> out = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
out.add(item instanceof Map<?, ?> m ? mask(castMap(m), depth + 1) : item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private boolean isSensitive(String key) {
|
||||
String lower = key.toLowerCase(Locale.ROOT);
|
||||
return SENSITIVE_HINTS.stream().anyMatch(lower::contains);
|
||||
}
|
||||
|
||||
/** Blank, the mask placeholder, or any structure that still contains the mask. */
|
||||
private boolean isRedacted(Object value, int depth) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
if (value instanceof String s) {
|
||||
return s.isBlank() || MASK.equals(s);
|
||||
}
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
if (value instanceof Map<?, ?> m) {
|
||||
return m.values().stream().anyMatch(v -> isRedacted(v, depth + 1));
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
return list.stream().anyMatch(v -> isRedacted(v, depth + 1));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> castMap(Map<?, ?> map) {
|
||||
return (Map<String, Object>) map;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Resolves whether a user leads a team. */
|
||||
public interface TeamLeadLookup {
|
||||
|
||||
/** Whether the user leads at least one team. */
|
||||
boolean isAnyTeamLeader(User user);
|
||||
|
||||
/** Whether the user leads the given team. */
|
||||
boolean isLeaderOfTeam(User user, Long teamId);
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
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 java.time.LocalDateTime;
|
||||
|
||||
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 stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
|
||||
* A").
|
||||
*
|
||||
* <p>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.
|
||||
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
|
||||
* cumulative units and returns the refreshed entitlement.
|
||||
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
|
||||
* /api/v1/instance/revoke-self}).
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
|
||||
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
|
||||
*/
|
||||
@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) — the device credential is revoked or invalid. Unlike a
|
||||
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
|
||||
* this. Unchecked so it propagates 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, authenticated by that credential.
|
||||
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
|
||||
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
|
||||
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
|
||||
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
|
||||
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
|
||||
* not advance its last-synced markers so the usage retries next sync.
|
||||
*/
|
||||
public InstanceEntitlement reportUsage(
|
||||
String deviceId,
|
||||
String deviceSecret,
|
||||
long syncSeq,
|
||||
LocalDateTime periodStart,
|
||||
long apiUnits,
|
||||
long aiUnits,
|
||||
long automationUnits) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
root.put("syncSeq", syncSeq);
|
||||
// Explicit ISO-8601 string so it round-trips regardless of the mapper's time config.
|
||||
root.put("periodStart", periodStart.toString());
|
||||
ObjectNode units = root.putObject("cumulativeUnits");
|
||||
units.put("api", apiUnits);
|
||||
units.put("ai", aiUnits);
|
||||
units.put("automation", automationUnits);
|
||||
String body = mapper.writeValueAsString(root);
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/instance/sync"))
|
||||
.header(HEADER_DEVICE_ID, deviceId)
|
||||
.header(HEADER_DEVICE_SECRET, deviceSecret)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
response = send(request);
|
||||
} catch (Exception e) {
|
||||
log.debug("Usage sync failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
int status = response.statusCode();
|
||||
if (status == 401 || status == 403) {
|
||||
throw new RevokedException(status);
|
||||
}
|
||||
if (status / 100 != 2) {
|
||||
log.debug("Usage sync returned HTTP {}", status);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseEntitlement(response.body());
|
||||
} catch (IOException e) {
|
||||
log.debug("Usage sync 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,
|
||||
parseUnitCalcPolicy(root),
|
||||
parseDateTime(root, "periodStart"),
|
||||
parseDateTime(root, "periodEnd"));
|
||||
}
|
||||
|
||||
/** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */
|
||||
private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) {
|
||||
if (!root.hasNonNull("unitCalcPolicy")) {
|
||||
return null;
|
||||
}
|
||||
JsonNode node = root.get("unitCalcPolicy");
|
||||
try {
|
||||
return new UnitCalcPolicy(
|
||||
node.path("docPagesPerUnit").asInt(),
|
||||
node.path("docBytesPerUnit").asLong(),
|
||||
node.path("minChargeUnits").asInt(),
|
||||
node.path("fileUnitCap").asInt());
|
||||
} catch (RuntimeException e) {
|
||||
// Malformed policy → degrade to "none" rather than fail the whole entitlement parse.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO date-time field → LocalDateTime; null if absent or unparseable. */
|
||||
private static LocalDateTime parseDateTime(JsonNode root, String field) {
|
||||
if (!root.hasNonNull(field)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(root.get(field).asText(null));
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
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; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
|
||||
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
|
||||
* / test aid).
|
||||
*
|
||||
* <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;
|
||||
private final LocalUsageService localUsageService;
|
||||
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
|
||||
private final ObjectProvider<UsageSyncService> syncServiceProvider;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> syncServiceProvider) {
|
||||
this.service = service;
|
||||
this.localUsageService = localUsageService;
|
||||
this.syncServiceProvider = syncServiceProvider;
|
||||
}
|
||||
|
||||
/** {@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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend
|
||||
* so "current usage" includes work done since the last daily sync.
|
||||
*/
|
||||
@GetMapping("/usage")
|
||||
public ResponseEntity<LocalUsageService.LocalUsage> usage() {
|
||||
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
|
||||
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
|
||||
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
|
||||
* {@code 409} when metering is off (the sync bean is absent).
|
||||
*/
|
||||
@PostMapping("/sync-now")
|
||||
public ResponseEntity<Void> syncNow() {
|
||||
UsageSyncService sync = syncServiceProvider.getIfAvailable();
|
||||
if (sync == null) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
sync.syncNow();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
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;
|
||||
|
||||
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
|
||||
private final Metering metering = new Metering();
|
||||
|
||||
/**
|
||||
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
|
||||
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
|
||||
* enforcement. Both default off; metering requires the master flag too. This is the production
|
||||
* safety key — flipping it on is what actually bills linked instances.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Metering {
|
||||
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* How often the instance syncs usage + refreshes entitlement (matches the licence sync).
|
||||
*/
|
||||
private int syncIntervalHours = 24;
|
||||
|
||||
/**
|
||||
* Block billable work after this many days with no successful sync (fail-open → closed).
|
||||
*/
|
||||
private int graceDays = 3;
|
||||
|
||||
/**
|
||||
* Dedup window for identical input sets. A re-run of the same inputs within this window is
|
||||
* treated as workflow chaining and not re-charged; the same inputs run again after it are
|
||||
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
|
||||
* costs the same on the instance and in the cloud.
|
||||
*/
|
||||
private Duration workflowWindow = Duration.ofMinutes(5);
|
||||
}
|
||||
}
|
||||
+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));
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
|
||||
*
|
||||
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
|
||||
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
|
||||
* so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last
|
||||
* sync SaaS accepted and drives the fail-open→closed grace window.
|
||||
*
|
||||
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "account_link_sync_state")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class AccountLinkSyncState {
|
||||
|
||||
/** One instance links to one team → one bookkeeping row. */
|
||||
public static final long SINGLETON_ID = 1L;
|
||||
|
||||
@Id private Long id;
|
||||
|
||||
// columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres.
|
||||
@Column(
|
||||
name = "last_sync_seq",
|
||||
nullable = false,
|
||||
columnDefinition = "bigint not null default 0")
|
||||
private long lastSyncSeq;
|
||||
|
||||
/** Null until the first sync SaaS accepts. */
|
||||
@Column(name = "last_success_at")
|
||||
private LocalDateTime lastSuccessAt;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
|
||||
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
|
||||
+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/**");
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.BillingCategoryClassifier;
|
||||
|
||||
/**
|
||||
* Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only
|
||||
* HTTP-level signals (no dependency on the saas module):
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>AUTOMATION</b> — the automation marker header ({@link
|
||||
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
|
||||
* <li><b>AI</b> — the AI surface ({@code /api/v1/ai/**});
|
||||
* <li><b>API</b> — an API-key authenticated tool call;
|
||||
* <li><b>BYPASSED</b> — a manual interactive tool call, never billed.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared
|
||||
* {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the
|
||||
* saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the
|
||||
* caller (resolved from the security context), so this class stays free of any security-type
|
||||
* dependency.
|
||||
*/
|
||||
public final class BillableOperationClassifier {
|
||||
|
||||
private static final String AI_PATH_PREFIX = "/api/v1/ai/";
|
||||
|
||||
private BillableOperationClassifier() {}
|
||||
|
||||
/**
|
||||
* @param apiKey whether the request authenticated via an API key (an {@code
|
||||
* ApiKeyAuthenticationToken} principal), resolved by the caller from the security context.
|
||||
*/
|
||||
public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) {
|
||||
boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null;
|
||||
return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey);
|
||||
}
|
||||
|
||||
private static boolean isAiSurface(HttpServletRequest request) {
|
||||
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 AI.
|
||||
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);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
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 needn't call SaaS on every billable
|
||||
* request. Single-slot (one instance = one linked team), TTL-based.
|
||||
*
|
||||
* <p>A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has
|
||||
* and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An
|
||||
* authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is
|
||||
* replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately.
|
||||
*/
|
||||
@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 while concurrent callers serve
|
||||
// the last snapshot — no thundering herd of round-trips on the billable hot path.
|
||||
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 a full TTL instead of every request re-triggering a round-trip to a dead SaaS.
|
||||
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the
|
||||
* attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a
|
||||
* blocked snapshot.
|
||||
*/
|
||||
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 but stamp the attempt
|
||||
// so we don't hammer SaaS; the gate fails open meanwhile.
|
||||
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 — block immediately rather than serving the stale entitled
|
||||
// snapshot.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh
|
||||
* one), saving a redundant fetch. No-op on null.
|
||||
*/
|
||||
public void accept(InstanceEntitlement fresh) {
|
||||
if (fresh != null) {
|
||||
snapshot = new Snapshot(fresh, Instant.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
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,
|
||||
/**
|
||||
* Linked + metering, but SaaS has been unreachable past the grace window — block (the
|
||||
* fail-open backstop expired) so unbounded free/unbilled billable work can't continue.
|
||||
*/
|
||||
GRACE_EXPIRED,
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
|
||||
/**
|
||||
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response.
|
||||
* Mirrors the saas {@code EntitlementResponse} shape but carries no saas types.
|
||||
*
|
||||
* <p>The first five fields are what the <b>gate</b> enforces against; the trailing three are the
|
||||
* metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its
|
||||
* per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the
|
||||
* revoked sentinel and unit tests that don't exercise metering.
|
||||
*
|
||||
* @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})
|
||||
* @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not
|
||||
* supplied (older SaaS / gate-only sentinel)
|
||||
* @param periodStart inclusive start of the current billing period; {@code null} if not supplied
|
||||
* @param periodEnd exclusive end of the current billing period; {@code null} if not supplied
|
||||
*/
|
||||
public record InstanceEntitlement(
|
||||
boolean subscribed,
|
||||
long freeRemainingUnits,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
EntitlementState state,
|
||||
UnitCalcPolicy unitCalcPolicy,
|
||||
LocalDateTime periodStart,
|
||||
LocalDateTime periodEnd) {
|
||||
|
||||
/** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */
|
||||
public InstanceEntitlement(
|
||||
boolean subscribed,
|
||||
long freeRemainingUnits,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
EntitlementState state) {
|
||||
this(
|
||||
subscribed,
|
||||
freeRemainingUnits,
|
||||
periodSpendUnits,
|
||||
periodCapUnits,
|
||||
state,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 — unless
|
||||
* metering is on and SaaS has been unreachable past the grace window, then block with {@code
|
||||
* GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever.
|
||||
* <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 supplies the live
|
||||
* flag / linked-state / entitlement and computes whether the grace window has expired. 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;
|
||||
private final AccountLinkSyncStateRepository syncStateRepository;
|
||||
private final LocalUsageService localUsageService;
|
||||
|
||||
public InstanceEntitlementGate(
|
||||
AccountLinkProperties properties,
|
||||
DeviceCredentialStore credentialStore,
|
||||
EntitlementCache entitlementCache,
|
||||
AccountLinkSyncStateRepository syncStateRepository,
|
||||
LocalUsageService localUsageService) {
|
||||
this.properties = properties;
|
||||
this.credentialStore = credentialStore;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.syncStateRepository = syncStateRepository;
|
||||
this.localUsageService = localUsageService;
|
||||
}
|
||||
|
||||
/** 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();
|
||||
boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired();
|
||||
// Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped
|
||||
// subscription) — by local usage not yet synced, so the gate stops in real time instead of
|
||||
// overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0.
|
||||
long pendingUnsynced =
|
||||
entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false)
|
||||
? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits()
|
||||
: 0L;
|
||||
return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced);
|
||||
}
|
||||
|
||||
/** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */
|
||||
private static boolean depletesCeiling(InstanceEntitlement e) {
|
||||
return !e.subscribed() || e.periodCapUnits() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
|
||||
* (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace
|
||||
* window elapsed with no authoritative contact), in which case it blocks.
|
||||
*
|
||||
* @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the
|
||||
* free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate
|
||||
* stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement
|
||||
* cases, where it has no effect).
|
||||
*/
|
||||
public static GateDecision decide(
|
||||
boolean flagEnabled,
|
||||
boolean billable,
|
||||
boolean linked,
|
||||
Optional<InstanceEntitlement> entitlement,
|
||||
boolean graceExpired,
|
||||
long pendingUnsyncedUnits) {
|
||||
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 unreachable: fail open, unless the grace window has expired
|
||||
// (so
|
||||
// the fail-open can't grant unbounded unbilled work forever).
|
||||
return graceExpired
|
||||
? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED)
|
||||
: 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, pendingUnsyncedUnits)
|
||||
? GateDecision.allow(GateDecision.Reason.ENTITLED)
|
||||
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when metering is on and it's been {@code graceDays} since the last authoritative contact
|
||||
* (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off
|
||||
* disables the backstop.
|
||||
*/
|
||||
private boolean isGraceExpired() {
|
||||
AccountLinkProperties.Metering metering = properties.getMetering();
|
||||
if (!metering.isEnabled() || metering.getGraceDays() <= 0) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime reference = lastAuthoritativeContact();
|
||||
if (reference == null) {
|
||||
return false; // can't determine elapsed time → fail open
|
||||
}
|
||||
return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private LocalDateTime lastAuthoritativeContact() {
|
||||
LocalDateTime lastSuccess =
|
||||
syncStateRepository
|
||||
.findById(AccountLinkSyncState.SINGLETON_ID)
|
||||
.map(AccountLinkSyncState::getLastSuccessAt)
|
||||
.orElse(null);
|
||||
if (lastSuccess != null) {
|
||||
return lastSuccess;
|
||||
}
|
||||
return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null);
|
||||
}
|
||||
|
||||
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
|
||||
private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) {
|
||||
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
|
||||
return false;
|
||||
}
|
||||
if (e.subscribed()) {
|
||||
if (e.periodCapUnits() == null) {
|
||||
return true; // uncapped subscription
|
||||
}
|
||||
// Project the cap the way the grant is projected: synced paid spend plus the paid part
|
||||
// of local usage not yet synced (free grant is consumed first, so only the excess
|
||||
// bills) — stops at the cap in real time instead of overshooting until the next sync.
|
||||
long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits());
|
||||
return e.periodSpendUnits() + pendingPaid < e.periodCapUnits();
|
||||
}
|
||||
// Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local
|
||||
// usage not yet synced — deplete by the pending delta so we stop at the grant in real time.
|
||||
return e.freeRemainingUnits() - pendingUnsyncedUnits > 0;
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.util.WebUtils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.ContentHasher;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
|
||||
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
|
||||
*
|
||||
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
|
||||
* prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind
|
||||
* {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link
|
||||
* UsageMeterService} bean is absent and nothing accrues, while the gate still works.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final String ATTR_CATEGORY =
|
||||
InstanceEntitlementInterceptor.class.getName() + ".category";
|
||||
|
||||
private final InstanceEntitlementGate gate;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final ObjectProvider<UsageMeterService> meterProvider;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
public InstanceEntitlementInterceptor(
|
||||
InstanceEntitlementGate gate,
|
||||
EntitlementCache entitlementCache,
|
||||
ObjectProvider<UsageMeterService> meterProvider,
|
||||
TempFileManager tempFileManager) {
|
||||
this.gate = gate;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.meterProvider = meterProvider;
|
||||
this.tempFileManager = tempFileManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
GateDecision decision;
|
||||
try {
|
||||
// API-key tool calls are billable (category API); stash the category for the meter.
|
||||
boolean apiKey =
|
||||
SecurityContextHolder.getContext().getAuthentication()
|
||||
instanceof ApiKeyAuthenticationToken;
|
||||
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
|
||||
request.setAttribute(ATTR_CATEGORY, category);
|
||||
decision = gate.evaluate(category != BillingCategory.BYPASSED);
|
||||
} 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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex) {
|
||||
// Meter successful billable ops only.
|
||||
if (ex != null || response.getStatus() >= 400) {
|
||||
return;
|
||||
}
|
||||
UsageMeterService meter = meterProvider.getIfAvailable();
|
||||
if (meter == null) {
|
||||
return; // metering switch off
|
||||
}
|
||||
if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category)
|
||||
|| category == BillingCategory.BYPASSED) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
InstanceEntitlement ent = entitlementCache.current().orElse(null);
|
||||
if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) {
|
||||
// Not yet synced (no policy/period) — can't compute units; skip until next sync.
|
||||
return;
|
||||
}
|
||||
meterRequest(request, category, ent, meter);
|
||||
} catch (RuntimeException e) {
|
||||
// Metering must never affect the response that already completed.
|
||||
log.debug("Usage metering failed for {}", request.getRequestURI(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance
|
||||
* is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy
|
||||
* but small PDF must be page-counted or it under-bills. A fileless op has no input identity —
|
||||
* null signature (no dedup), billed the 1-unit floor each time.
|
||||
*/
|
||||
private void meterRequest(
|
||||
HttpServletRequest request,
|
||||
BillingCategory category,
|
||||
InstanceEntitlement ent,
|
||||
UsageMeterService meter) {
|
||||
UnitCalcPolicy policy = ent.unitCalcPolicy();
|
||||
MultipartHttpServletRequest mreq =
|
||||
WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
|
||||
if (mreq == null) {
|
||||
long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy);
|
||||
meter.accrue(ent.periodStart(), category, fileless, null);
|
||||
return;
|
||||
}
|
||||
List<TempFile> temps = new ArrayList<>();
|
||||
try {
|
||||
List<FileSize> sizes = new ArrayList<>();
|
||||
List<String> hashes = new ArrayList<>();
|
||||
int fileCount = 0;
|
||||
for (List<MultipartFile> files : mreq.getMultiFileMap().values()) {
|
||||
for (MultipartFile f : files) {
|
||||
fileCount++;
|
||||
try {
|
||||
TempFile temp = tempFileManager.createManagedTempFile(".bin");
|
||||
temps.add(temp);
|
||||
// Hash in the same pass that writes the temp file — one read of the upload,
|
||||
// not a second full read just to fingerprint it.
|
||||
MessageDigest digest = ContentHasher.newSha256();
|
||||
try (InputStream in = f.getInputStream();
|
||||
DigestOutputStream out =
|
||||
new DigestOutputStream(
|
||||
Files.newOutputStream(temp.getPath()), digest)) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize()));
|
||||
hashes.add(ContentHasher.toHex(digest.digest()));
|
||||
} catch (IOException | RuntimeException perFile) {
|
||||
// Couldn't materialise/hash this input — bill on bytes only and, by leaving
|
||||
// it out of `hashes`, drop dedup for the whole op rather than risk a
|
||||
// mismatch.
|
||||
log.debug(
|
||||
"Metering materialise/hash failed for {}; bytes-only",
|
||||
f.getOriginalFilename());
|
||||
sizes.add(new FileSize(0, f.getSize()));
|
||||
}
|
||||
}
|
||||
}
|
||||
long units =
|
||||
sizes.isEmpty()
|
||||
? DocumentUnitCalculator.unitsForFile(0, 0, policy)
|
||||
: DocumentUnitCalculator.unitsForGroup(sizes, policy);
|
||||
// Only dedup when every input hashed; a partial signature could collide with a
|
||||
// different input set, so fall back to no-dedup (bill it) if any file failed.
|
||||
String opSignature =
|
||||
fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null;
|
||||
meter.accrue(ent.periodStart(), category, units, opSignature);
|
||||
} finally {
|
||||
for (TempFile temp : temps) {
|
||||
try {
|
||||
temp.close();
|
||||
} catch (RuntimeException cleanup) {
|
||||
log.debug("Temp file cleanup failed: {}", cleanup.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */
|
||||
private static int pageCount(Path path, MultipartFile file) {
|
||||
if (!isPdf(file)) {
|
||||
return 0;
|
||||
}
|
||||
try (PdfDocument doc = PdfDocument.open(path)) {
|
||||
return doc.pageCount();
|
||||
} catch (RuntimeException e) {
|
||||
// Malformed / encrypted → byte axis only, matching the SaaS classifier.
|
||||
log.debug(
|
||||
"Page count unavailable for {}; metering on bytes only",
|
||||
file.getOriginalFilename());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Order-independent signature of the input set: sorted per-file hashes, hashed together. */
|
||||
private static String opSignature(List<String> hashes) {
|
||||
List<String> sorted = new ArrayList<>(hashes);
|
||||
Collections.sort(sorted);
|
||||
return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static boolean isPdf(MultipartFile file) {
|
||||
String contentType = file.getContentType();
|
||||
if (contentType != null && contentType.toLowerCase().contains("pdf")) {
|
||||
return true;
|
||||
}
|
||||
String name = file.getOriginalFilename();
|
||||
return name != null && name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.EnumMap;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal
|
||||
* adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync.
|
||||
*
|
||||
* <p>Unsynced per category = {@code cumulativeUnits − lastSyncedUnits} (floored at 0), scoped to
|
||||
* the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown
|
||||
* or metering is off.
|
||||
*/
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class LocalUsageService {
|
||||
|
||||
private final UsageCounterRepository counters;
|
||||
private final EntitlementCache entitlementCache;
|
||||
|
||||
public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) {
|
||||
this.counters = counters;
|
||||
this.entitlementCache = entitlementCache;
|
||||
}
|
||||
|
||||
/** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */
|
||||
public record LocalUsage(
|
||||
LocalDateTime periodStart,
|
||||
long apiUnsyncedUnits,
|
||||
long aiUnsyncedUnits,
|
||||
long automationUnsyncedUnits,
|
||||
long totalUnsyncedUnits) {}
|
||||
|
||||
public LocalUsage currentPeriodUnsynced() {
|
||||
LocalDateTime period =
|
||||
entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null);
|
||||
if (period == null) {
|
||||
return new LocalUsage(null, 0, 0, 0, 0);
|
||||
}
|
||||
EnumMap<BillingCategory, Long> unsynced = new EnumMap<>(BillingCategory.class);
|
||||
for (UsageCounter c : counters.findByPeriodStart(period)) {
|
||||
BillingCategory cat = c.billingCategory();
|
||||
if (cat != null && cat != BillingCategory.BYPASSED) {
|
||||
unsynced.merge(cat, c.unsyncedUnits(), Long::sum);
|
||||
}
|
||||
}
|
||||
long api = unsynced.getOrDefault(BillingCategory.API, 0L);
|
||||
long ai = unsynced.getOrDefault(BillingCategory.AI, 0L);
|
||||
long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L);
|
||||
return new LocalUsage(period, api, ai, automation, api + ai + automation);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
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 jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* The last time the instance metered a given input set this period — the local equivalent of the
|
||||
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
|
||||
* window</b>: an identical input set re-submitted within the window (see {@link
|
||||
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
|
||||
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
|
||||
* window so the same operation costs the same on the instance and in the cloud.
|
||||
*
|
||||
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
|
||||
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
|
||||
* makes the first-sighting insert an atomic claim under concurrency.
|
||||
*
|
||||
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_metered_signature",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_account_link_metered_signature",
|
||||
columnNames = {"period_start", "signature"}))
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class MeteredInputSignature {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
/** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */
|
||||
@Column(name = "signature", nullable = false, length = 64)
|
||||
private String signature;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* When this input set was last metered — the anchor the workflow-window dedup compares against.
|
||||
*/
|
||||
@Column(name = "last_metered_at")
|
||||
private LocalDateTime lastMeteredAt;
|
||||
|
||||
public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) {
|
||||
this.periodStart = periodStart;
|
||||
this.signature = signature;
|
||||
this.createdAt = at;
|
||||
this.lastMeteredAt = at;
|
||||
}
|
||||
|
||||
/** Slides the window forward — the input set was seen again. */
|
||||
public void touch(LocalDateTime at) {
|
||||
this.lastMeteredAt = at;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
|
||||
public interface MeteredInputSignatureRepository
|
||||
extends JpaRepository<MeteredInputSignature, Long> {
|
||||
|
||||
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
|
||||
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
|
||||
LocalDateTime periodStart, String signature);
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
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 jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
|
||||
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
|
||||
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
|
||||
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_usage_counter",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_usage_counter_period_category",
|
||||
columnNames = {"period_start", "category"}))
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class UsageCounter {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* Inclusive start of the billing period this counter belongs to (from the entitlement sync).
|
||||
*/
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
/** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */
|
||||
@Column(name = "category", nullable = false, length = 32)
|
||||
private String category;
|
||||
|
||||
/** Running total of metered units in this period+category. */
|
||||
@Column(name = "cumulative_units", nullable = false)
|
||||
private long cumulativeUnits;
|
||||
|
||||
/**
|
||||
* {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported
|
||||
* usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default
|
||||
* keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already
|
||||
* populated (NOT NULL with no default would fail the ALTER).
|
||||
*/
|
||||
@Column(
|
||||
name = "last_synced_units",
|
||||
nullable = false,
|
||||
columnDefinition = "bigint not null default 0")
|
||||
private long lastSyncedUnits;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** Fresh-accrual row: nothing synced yet. */
|
||||
public UsageCounter(
|
||||
LocalDateTime periodStart,
|
||||
String category,
|
||||
long cumulativeUnits,
|
||||
LocalDateTime updatedAt) {
|
||||
this(periodStart, category, cumulativeUnits, 0L, updatedAt);
|
||||
}
|
||||
|
||||
public UsageCounter(
|
||||
LocalDateTime periodStart,
|
||||
String category,
|
||||
long cumulativeUnits,
|
||||
long lastSyncedUnits,
|
||||
LocalDateTime updatedAt) {
|
||||
this.periodStart = periodStart;
|
||||
this.category = category;
|
||||
this.cumulativeUnits = cumulativeUnits;
|
||||
this.lastSyncedUnits = lastSyncedUnits;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
/** This row's category as the enum, or {@code null} for an unrecognised stored value. */
|
||||
public BillingCategory billingCategory() {
|
||||
try {
|
||||
return BillingCategory.valueOf(category);
|
||||
} catch (IllegalArgumentException unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Units accrued but not yet accepted by SaaS (floored at 0). */
|
||||
public long unsyncedUnits() {
|
||||
return Math.max(0, cumulativeUnits - lastSyncedUnits);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
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;
|
||||
|
||||
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
|
||||
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
* Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated
|
||||
* (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a
|
||||
* read-modify-write race between concurrent billable requests.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta,"
|
||||
+ " c.updatedAt = :now"
|
||||
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
|
||||
int increment(
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("category") String category,
|
||||
@Param("delta") long delta,
|
||||
@Param("now") LocalDateTime now);
|
||||
|
||||
/** All counters for a period — the daily sync reads these to report cumulative totals. */
|
||||
List<UsageCounter> findByPeriodStart(LocalDateTime periodStart);
|
||||
|
||||
/**
|
||||
* Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each
|
||||
* so end-of-period usage isn't stranded when the billing period rolls over between syncs.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT DISTINCT c.periodStart FROM UsageCounter c"
|
||||
+ " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart")
|
||||
List<LocalDateTime> findPeriodsWithUnsyncedUsage();
|
||||
|
||||
/**
|
||||
* Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by
|
||||
* SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits"
|
||||
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
|
||||
int markSynced(
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("category") String category,
|
||||
@Param("syncedUnits") long syncedUnits);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily
|
||||
* sync later reports the cumulative totals to SaaS.
|
||||
*
|
||||
* <p>Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow-
|
||||
* window} is treated as chaining and not re-charged; the same inputs run again after the window are
|
||||
* billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the
|
||||
* instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue}
|
||||
* is best-effort: callers need not handle persistence errors.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(
|
||||
name = "stirling.billing.account-link.metering.enabled",
|
||||
havingValue = "true")
|
||||
public class UsageMeterService {
|
||||
|
||||
private final UsageCounterRepository repo;
|
||||
private final MeteredInputSignatureRepository signatureRepo;
|
||||
private final Duration workflowWindow;
|
||||
|
||||
public UsageMeterService(
|
||||
UsageCounterRepository repo,
|
||||
MeteredInputSignatureRepository signatureRepo,
|
||||
AccountLinkProperties properties) {
|
||||
this.repo = repo;
|
||||
this.signatureRepo = signatureRepo;
|
||||
this.workflowWindow = properties.getMetering().getWorkflowWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first
|
||||
* use), unless {@code opSignature} was already metered this period. No-ops for non-billable
|
||||
* categories, non-positive units, or a missing period.
|
||||
*/
|
||||
public void accrue(
|
||||
LocalDateTime periodStart, BillingCategory category, long units, String opSignature) {
|
||||
if (periodStart == null
|
||||
|| category == null
|
||||
|| category == BillingCategory.BYPASSED
|
||||
|| units <= 0) {
|
||||
return;
|
||||
}
|
||||
if (opSignature != null && !shouldCharge(periodStart, opSignature)) {
|
||||
return; // identical inputs seen within the workflow window — chaining, already billed
|
||||
}
|
||||
incrementOrInsert(periodStart, category.name(), units);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this input set should be charged: unseen this period, or last seen outside the
|
||||
* workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and
|
||||
* slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge.
|
||||
*/
|
||||
private boolean shouldCharge(LocalDateTime periodStart, String opSignature) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
MeteredInputSignature seen =
|
||||
signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null);
|
||||
if (seen == null) {
|
||||
try {
|
||||
signatureRepo.saveAndFlush(
|
||||
new MeteredInputSignature(periodStart, opSignature, now));
|
||||
return true; // first sighting this period
|
||||
} catch (DataIntegrityViolationException raced) {
|
||||
return false; // a concurrent op just claimed it — within window → chaining
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now;
|
||||
boolean withinWindow = last.isAfter(now.minus(workflowWindow));
|
||||
try {
|
||||
seen.touch(now);
|
||||
signatureRepo.save(seen);
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage());
|
||||
}
|
||||
return !withinWindow;
|
||||
}
|
||||
|
||||
private void incrementOrInsert(LocalDateTime periodStart, String category, long units) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
try {
|
||||
if (repo.increment(periodStart, category, units, now) > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
repo.saveAndFlush(new UsageCounter(periodStart, category, units, now));
|
||||
} catch (DataIntegrityViolationException raceLostInsert) {
|
||||
// A concurrent request inserted the row first — increment the now-existing row.
|
||||
repo.increment(periodStart, category, units, now);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Metering must never break the request it rode in on; a lost accrual self-heals on the
|
||||
// next increment and the daily sync reports the cumulative total either way.
|
||||
log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.SchedulingConfigurer;
|
||||
import org.springframework.scheduling.config.FixedDelayTask;
|
||||
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
|
||||
* usage to SaaS, which bills the delta against its own last-seen totals.
|
||||
*
|
||||
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
|
||||
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
|
||||
* usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All
|
||||
* periods with unsynced usage are reported so nothing is stranded when the period rolls over
|
||||
* between syncs.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(
|
||||
name = "stirling.billing.account-link.metering.enabled",
|
||||
havingValue = "true")
|
||||
public class UsageSyncService implements SchedulingConfigurer {
|
||||
|
||||
// First run waits out startup churn; then every interval.
|
||||
private static final Duration INITIAL_DELAY = Duration.ofMinutes(5);
|
||||
|
||||
private final UsageCounterRepository counters;
|
||||
private final AccountLinkSyncStateRepository syncState;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final AccountLinkClient client;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final AccountLinkProperties properties;
|
||||
|
||||
public UsageSyncService(
|
||||
UsageCounterRepository counters,
|
||||
AccountLinkSyncStateRepository syncState,
|
||||
DeviceCredentialStore credentialStore,
|
||||
AccountLinkClient client,
|
||||
EntitlementCache entitlementCache,
|
||||
AccountLinkProperties properties) {
|
||||
this.counters = counters;
|
||||
this.syncState = syncState;
|
||||
this.credentialStore = credentialStore;
|
||||
this.client = client;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in
|
||||
* code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather
|
||||
* than only on a flags-on run.
|
||||
*/
|
||||
@Override
|
||||
public void configureTasks(ScheduledTaskRegistrar registrar) {
|
||||
Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours());
|
||||
registrar.addFixedDelayTask(
|
||||
new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY));
|
||||
}
|
||||
|
||||
public void scheduledSync() {
|
||||
try {
|
||||
syncNow();
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Scheduled usage sync failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports every period with unsynced usage and refreshes the cached entitlement from the reply.
|
||||
* Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when
|
||||
* unlinked or when nothing is pending.
|
||||
*/
|
||||
public void syncNow() {
|
||||
Optional<DeviceCredential> cred = credentialStore.get();
|
||||
if (cred.isEmpty()) {
|
||||
return; // not linked
|
||||
}
|
||||
List<LocalDateTime> periods = counters.findPeriodsWithUnsyncedUsage();
|
||||
if (periods.isEmpty()) {
|
||||
// Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement
|
||||
// change (e.g. the admin just subscribed) that otherwise wouldn't surface until the
|
||||
// cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now.
|
||||
entitlementCache.invalidate();
|
||||
entitlementCache.current();
|
||||
return;
|
||||
}
|
||||
InstanceEntitlement latest = null;
|
||||
try {
|
||||
for (LocalDateTime period : periods) {
|
||||
InstanceEntitlement fresh = syncPeriod(cred.get(), period);
|
||||
if (fresh != null) {
|
||||
latest = fresh;
|
||||
}
|
||||
}
|
||||
} catch (AccountLinkClient.RevokedException e) {
|
||||
// Authoritative deny — stop reporting; the entitlement cache blocks billable work on
|
||||
// its
|
||||
// own next refresh, so we don't synthesise the blocked state here.
|
||||
log.info(
|
||||
"Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next"
|
||||
+ " refresh",
|
||||
e.status());
|
||||
return;
|
||||
}
|
||||
// Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch.
|
||||
entitlementCache.accept(latest);
|
||||
}
|
||||
|
||||
/** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */
|
||||
private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) {
|
||||
EnumMap<BillingCategory, Long> cumulative = new EnumMap<>(BillingCategory.class);
|
||||
for (UsageCounter c : counters.findByPeriodStart(period)) {
|
||||
BillingCategory cat = c.billingCategory();
|
||||
if (cat != null && cat != BillingCategory.BYPASSED) {
|
||||
cumulative.merge(cat, c.getCumulativeUnits(), Long::sum);
|
||||
}
|
||||
}
|
||||
AccountLinkSyncState state = loadState();
|
||||
long seq = reserveNextSeq(state);
|
||||
InstanceEntitlement fresh =
|
||||
client.reportUsage(
|
||||
cred.getDeviceId(),
|
||||
cred.getDeviceSecret(),
|
||||
seq,
|
||||
period,
|
||||
cumulative.getOrDefault(BillingCategory.API, 0L),
|
||||
cumulative.getOrDefault(BillingCategory.AI, 0L),
|
||||
cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L));
|
||||
if (fresh == null) {
|
||||
// Transport/server failure: leave the synced markers untouched. The burned seq is
|
||||
// harmless (seqs need only be monotonic) and the delta bills on the next successful
|
||||
// sync.
|
||||
return null;
|
||||
}
|
||||
recordSuccess(period, cumulative, state);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Reserves and persists the next strictly-increasing sequence before the report goes out. */
|
||||
private long reserveNextSeq(AccountLinkSyncState state) {
|
||||
long next = state.getLastSyncSeq() + 1;
|
||||
state.setLastSyncSeq(next);
|
||||
syncState.save(state);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the per-category synced markers to the reported totals + stamps the success time.
|
||||
*/
|
||||
private void recordSuccess(
|
||||
LocalDateTime period,
|
||||
EnumMap<BillingCategory, Long> cumulative,
|
||||
AccountLinkSyncState state) {
|
||||
cumulative.forEach(
|
||||
(category, units) -> {
|
||||
if (units > 0) {
|
||||
counters.markSynced(period, category.name(), units);
|
||||
}
|
||||
});
|
||||
state.setLastSuccessAt(LocalDateTime.now());
|
||||
syncState.save(state);
|
||||
}
|
||||
|
||||
private AccountLinkSyncState loadState() {
|
||||
return syncState
|
||||
.findById(AccountLinkSyncState.SINGLETON_ID)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
AccountLinkSyncState s = new AccountLinkSyncState();
|
||||
s.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
return s;
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
/**
|
||||
* The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so
|
||||
* category is metadata only and never affects price.
|
||||
*
|
||||
* <p>Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link
|
||||
* BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
|
||||
* billed.
|
||||
*
|
||||
* <p>Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted
|
||||
* instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} /
|
||||
* {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the
|
||||
* same names. (We deliberately do not share one enum across the modules: that would drag the SaaS
|
||||
* billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.)
|
||||
*/
|
||||
public enum BillingCategory {
|
||||
BYPASSED,
|
||||
API,
|
||||
AI,
|
||||
AUTOMATION
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
/**
|
||||
* Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a
|
||||
* linked self-hosted instance classify identically. Each backend resolves the three signals from
|
||||
* its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature}
|
||||
* annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this
|
||||
* applies the order {@code AUTOMATION → AI → API → BYPASSED}.
|
||||
*
|
||||
* <p>An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the
|
||||
* automation header dominates), while a direct call to it bills as {@code AI}.
|
||||
*/
|
||||
public final class BillingCategoryClassifier {
|
||||
|
||||
private BillingCategoryClassifier() {}
|
||||
|
||||
public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) {
|
||||
if (automation) {
|
||||
return BillingCategory.AUTOMATION;
|
||||
}
|
||||
if (ai) {
|
||||
return BillingCategory.AI;
|
||||
}
|
||||
if (apiKey) {
|
||||
return BillingCategory.API;
|
||||
}
|
||||
return BillingCategory.BYPASSED;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user