mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce98d29f2 | ||
|
|
65c01e8078 | ||
|
|
4700542c75 | ||
|
|
9d01866c83 | ||
|
|
b35329c8f5 | ||
|
|
4dc0927104 | ||
|
|
b4a264239c | ||
|
|
bbd4d2c3ac | ||
|
|
4d207f0c3f | ||
|
|
8a5470dd01 | ||
|
|
999b5e5995 | ||
|
|
66b80a80c0 | ||
|
|
e05b7f12da | ||
|
|
dff3101ca7 | ||
|
|
af1acb68d5 | ||
|
|
22ec0947c9 | ||
|
|
ba404d3f90 | ||
|
|
a380a82234 | ||
|
|
3813ca360e | ||
|
|
1681b5d298 | ||
|
|
831bd4fe94 | ||
|
|
4fbb2fe885 | ||
|
|
54bf32485f | ||
|
|
a1b1f974a0 | ||
|
|
29002d0b82 | ||
|
|
1e2895a79f | ||
|
|
67e10138b5 | ||
|
|
8de94ff152 | ||
|
|
b3875d3149 | ||
|
|
357eb77f94 | ||
|
|
718277a934 | ||
|
|
3bf0019d7c | ||
|
|
7e76097ac1 | ||
|
|
ce7f74a3c1 | ||
|
|
50c0f2bcb5 | ||
|
|
fab00f3fe2 | ||
|
|
b5b9cc443f | ||
|
|
a4ac034a18 |
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: pr-quiz
|
||||
description: >-
|
||||
Quiz the PR author on their own branch before they request review, to prove they
|
||||
actually understand the change - especially code an AI wrote for them. Scopes the
|
||||
branch diff vs its base, reads the changed code, then asks graded questions about
|
||||
what changed, why, how it works, what it could break, and which edge cases it must
|
||||
handle. Presents all questions first, waits for the author's answers, then grades
|
||||
each honestly against the real code (Correct / Partial / Incorrect with the true
|
||||
answer and file:line), scores it, and gives a readiness verdict that names the
|
||||
areas to re-study before asking humans to review. Use when asked to quiz me on my
|
||||
PR/branch, "test my understanding before review", a self-check gate before opening
|
||||
a PR, or before requesting reviewers. Administered as an interactive
|
||||
multiple-choice quiz (clickable options) by default; pass --free-text for
|
||||
written answers, --questions N to set count, --save to write a scorecard.
|
||||
argument-hint: "[branch-or-base-ref] [--questions N] [--free-text] [--save]"
|
||||
allowed-tools: Bash, Read, Grep, Glob, Write, AskUserQuestion
|
||||
---
|
||||
|
||||
# PR Quiz
|
||||
|
||||
Test whether the **author** genuinely understands their own branch before they ask
|
||||
other people to spend time reviewing it. This is a self-check gate: the point is to
|
||||
catch changes - often AI-written - that the author would not be able to explain or
|
||||
defend in review. Be a fair but honest examiner, not a pushover.
|
||||
|
||||
`$ARGUMENTS` may name a base ref or branch to diff against; default is this branch
|
||||
vs where it forked from the main line. Flags:
|
||||
- `--questions N` - target N questions (else scale to diff size, see below).
|
||||
- `--free-text` - administer as a written numbered list instead of the default
|
||||
interactive multiple-choice.
|
||||
- `--save` - also write a scorecard file after grading.
|
||||
|
||||
## Integrity rules (read first - the whole skill depends on these)
|
||||
|
||||
1. **Present every question before revealing any answer.** Ask, then wait. Never
|
||||
show the answer key alongside the questions.
|
||||
2. **Do not give hints or the answer while the quiz is open.** If the author asks
|
||||
"what's the answer?" or "is it X?" before committing, decline warmly and tell
|
||||
them to give their best answer first - guessing is part of the signal.
|
||||
3. **Grade truthfully.** Vague, hand-wavy, or "the AI did it" non-answers are
|
||||
Partial or Incorrect, not Correct. Do not inflate the score to be nice; a false
|
||||
pass defeats the entire purpose.
|
||||
4. **Ground everything in code you actually read.** Every question and every model
|
||||
answer must trace to a real line in the diff. Cite `path:line`. No trivia
|
||||
("how many lines?"), no invented behavior.
|
||||
5. **Credit real understanding.** If the author explains it correctly in their own
|
||||
words, mark it Correct even if worded differently than your key.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the change (silently)
|
||||
- Find the base. Prefer the fork point off the main line so the quiz covers only
|
||||
this branch's work:
|
||||
```bash
|
||||
git fetch -q origin 2>/dev/null; \
|
||||
BASE=$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main); \
|
||||
git diff --stat "$BASE"...HEAD
|
||||
```
|
||||
If `$ARGUMENTS` names a ref, diff against that instead.
|
||||
- If the diff is empty, stop and say there's nothing to quiz on.
|
||||
- Read commit messages / PR description for the *stated* intent, but verify it
|
||||
against the actual diff - a mismatch is itself a good question.
|
||||
|
||||
### 2. Understand the code well enough to examine on it
|
||||
Read the full diff plus enough surrounding context and related files to answer
|
||||
every question you plan to ask. You cannot grade understanding you don't have.
|
||||
Note the non-obvious parts: the design decisions, the risky lines, the edge cases,
|
||||
the cross-file ripples, and anything that violates or upholds repo conventions
|
||||
(for this repo e.g. `@app/*` import layering, all file ops via FileContext,
|
||||
Jackson 3 / Spring Boot 4 APIs, engine typed-contract boundaries).
|
||||
|
||||
### 3. Build the question set
|
||||
Scale count to the change unless `--questions N` is given:
|
||||
small (< ~50 changed lines) 3-4, medium 5-8, large 9-12. Cap at 12.
|
||||
Draw from these categories - weight toward the ones the diff actually exercises:
|
||||
- **Intent** - what problem this solves; why it was needed now.
|
||||
- **Mechanism** - how a specific non-trivial piece actually works ("walk me
|
||||
through what `foo()` does when called with X").
|
||||
- **Decisions & alternatives** - why this approach over an obvious alternative;
|
||||
what a reviewer would reasonably push back on.
|
||||
- **Blast radius** - what else this touches or could break; what you'd retest.
|
||||
- **Edge cases** - inputs/states the change must handle (null, empty, large,
|
||||
concurrent, error paths).
|
||||
- **Conventions & correctness** - does it follow the repo's rules; is there a
|
||||
latent bug the author should be able to spot.
|
||||
Prefer questions the author can only answer if they read and understood the code.
|
||||
Keep a private answer key with `path:line` for each - do **not** show it yet.
|
||||
|
||||
### 4. Administer the quiz
|
||||
- **Default (multiple choice):** use the `AskUserQuestion` tool. Per question write
|
||||
3-4 options where **every** option is independently plausible - each distractor a
|
||||
real-but-wrong reading of the code, not filler. Two hard rules so the answer
|
||||
can't be spotted by shape rather than knowledge:
|
||||
- **Randomise the correct option's position** across questions - never default
|
||||
it to first. Spread it roughly evenly over the slots.
|
||||
- **Keep all options the same depth and length.** Do not describe the correct
|
||||
one more fully than the distractors - a longer or more-detailed option is a
|
||||
dead giveaway. Trim the right answer or flesh out the wrong ones until a
|
||||
reader can't tell them apart by size.
|
||||
The tool caps a call at 4 questions, so ask in batches of 4 - but run them as
|
||||
one continuous flow: fire the next batch immediately after the previous
|
||||
returns, with no narration ("Round 2 of 3") and no grading between batches.
|
||||
The author always has an "Other" free-text escape, which is fine.
|
||||
- **`--free-text`:** present all questions in one numbered list, then say
|
||||
"Answer in one reply; number your answers. I won't grade until you're done."
|
||||
Wait for the author's answers.
|
||||
- Do not proceed to grading until every answer is in.
|
||||
|
||||
### 5. Grade
|
||||
For each question, in order:
|
||||
- Verdict: **Correct** / **Partial** / **Incorrect**.
|
||||
- The model answer in one or two sentences, citing the real `path:line`.
|
||||
- One line on the gap when Partial/Incorrect - what they missed and where to look.
|
||||
Then a **Score** (e.g. 6/8, counting Partial as half) and a one-line summary of
|
||||
the pattern (e.g. "solid on intent, shaky on the error paths").
|
||||
|
||||
### 6. Readiness verdict
|
||||
End with a clear call:
|
||||
- **Ready for review** - understanding is sound; note anything to mention to
|
||||
reviewers proactively.
|
||||
- **Study first** - list the specific files/concepts to re-read before requesting
|
||||
review, each as a clickable `path:line`. Be concrete: "re-read the null handling
|
||||
in X before you send this out."
|
||||
Keep it honest - if they'd get grilled in review on something, say so now.
|
||||
|
||||
### 7. If `--save`
|
||||
Write `pr-quiz/<branch>-scorecard.md`: the questions, their answers, your grades
|
||||
and model answers, the score, and the verdict. Don't commit it unless asked.
|
||||
|
||||
## Principles
|
||||
- **The author is the examinee, not the collaborator.** During the quiz you withhold
|
||||
answers; you're measuring them, not helping them pass.
|
||||
- **A failed quiz is a successful outcome** - it caught a gap before a human's time
|
||||
was spent. Frame it that way, not as a scolding.
|
||||
- **True to the code.** Every question, answer, and grade traces to a line you read.
|
||||
- **Terse and direct** in chat - the questions and the verdict, minimal preamble.
|
||||
@@ -84,6 +84,7 @@ frontend: &frontend
|
||||
- .taskfiles/frontend.yml
|
||||
- .taskfiles/e2e.yml
|
||||
- .github/workflows/frontend-validation.yml
|
||||
- .github/workflows/frontend-a11y.yml
|
||||
- .github/workflows/e2e-stubbed.yml
|
||||
- .github/workflows/e2e-live.yml
|
||||
|
||||
|
||||
@@ -23,13 +23,9 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-pr:
|
||||
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_deploy: ${{ steps.decide.outputs.should_deploy }}
|
||||
is_fork: ${{ steps.resolve.outputs.is_fork }}
|
||||
@@ -101,8 +97,8 @@ jobs:
|
||||
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy-v2-pr:
|
||||
needs: [pick, check-pr]
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
needs: check-pr
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
|
||||
# Concurrency control - only one deployment per PR at a time
|
||||
concurrency:
|
||||
@@ -112,10 +108,7 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
# Single source of truth for whether this preview embeds the admin portal:
|
||||
# drives the image build-arg and the deployment comment.
|
||||
BUILD_PORTAL: "true"
|
||||
@@ -190,12 +183,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get version number
|
||||
@@ -240,22 +228,8 @@ jobs:
|
||||
echo "Image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Build and push V2 image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-image.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
|
||||
- name: Build and push V2 image
|
||||
if: steps.check-image.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
@@ -474,8 +448,7 @@ jobs:
|
||||
|
||||
cleanup-v2-deployment:
|
||||
if: github.event.action == 'closed'
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
@@ -34,12 +34,8 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-comment:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
if: |
|
||||
@@ -179,15 +175,11 @@ jobs:
|
||||
}
|
||||
|
||||
deploy-pr:
|
||||
needs: [pick, check-comment]
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
needs: check-comment
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -240,12 +232,7 @@ jobs:
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
@@ -254,22 +241,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push PR-specific image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: |
|
||||
VERSION_TAG=alpha
|
||||
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push PR-specific image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
- name: Build and push PR-specific image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
@@ -283,19 +255,8 @@ jobs:
|
||||
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push engine image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ./engine
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push engine image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
- name: Build and push engine image
|
||||
if: needs.check-comment.outputs.enable_prototypes == 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./engine
|
||||
@@ -510,8 +471,7 @@ jobs:
|
||||
|
||||
handle-label-commands:
|
||||
if: ${{ github.event.issue.pull_request != null }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -2,13 +2,8 @@ name: _runner-pick
|
||||
|
||||
# Tiny reusable workflow that classifies the trigger as either a "fork PR
|
||||
# from an untrusted contributor" or a "trusted commit" so downstream jobs
|
||||
# can pick a runner class without each one duplicating the 200-char gate
|
||||
# expression in their own `runs-on:`.
|
||||
#
|
||||
# It also owns the single Depot kill-switch (use_depot). Depot is currently
|
||||
# disabled repo-wide; downstream jobs gate their Depot runner/build usage on
|
||||
# use_depot so nothing has to be deleted to turn Depot off. Flip DEPOT_ENABLED
|
||||
# in the decide step to switch Depot back on.
|
||||
# can trust-gate (skip secret-dependent jobs on forks) without each one
|
||||
# duplicating the gate expression.
|
||||
#
|
||||
# Caller pattern:
|
||||
#
|
||||
@@ -18,15 +13,12 @@ name: _runner-pick
|
||||
#
|
||||
# real-work:
|
||||
# needs: pick
|
||||
# runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
|
||||
# if: needs.pick.outputs.is_fork != 'true'
|
||||
# steps: [...]
|
||||
#
|
||||
# Outputs:
|
||||
# is_fork: "true" when the trigger is a pull_request from a fork or an
|
||||
# untrusted author_association, "false" otherwise. Use this for
|
||||
# trust gating (skipping secret-dependent jobs on forks).
|
||||
# use_depot: "true" when downstream jobs should use Depot runners/builders.
|
||||
# Currently forced "false" (Depot disabled repo-wide).
|
||||
# untrusted author_association, "false" otherwise.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -34,9 +26,6 @@ on:
|
||||
is_fork:
|
||||
description: '"true" if the trigger is an untrusted fork PR.'
|
||||
value: ${{ jobs.pick.outputs.is_fork }}
|
||||
use_depot:
|
||||
description: '"true" when downstream jobs should use Depot. Currently forced off.'
|
||||
value: ${{ jobs.pick.outputs.use_depot }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -47,7 +36,6 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
outputs:
|
||||
is_fork: ${{ steps.decide.outputs.is_fork }}
|
||||
use_depot: ${{ steps.decide.outputs.use_depot }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -63,12 +51,6 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
# Depot kill-switch. Depot is disabled repo-wide: no job uses Depot
|
||||
# runners or the Depot build actions while this is false. All the
|
||||
# Depot wiring is left in place - set DEPOT_ENABLED=true to switch it
|
||||
# back on (it then activates on trusted, non-fork triggers as before).
|
||||
DEPOT_ENABLED=false
|
||||
|
||||
if [ -z "${PR_NUMBER:-}" ]; then
|
||||
# Not a pull_request event at all (push, schedule, workflow_dispatch,
|
||||
# workflow_call from a non-PR trigger) -> trusted by default.
|
||||
@@ -82,13 +64,4 @@ jobs:
|
||||
esac
|
||||
fi
|
||||
|
||||
# Depot only ever ran on trusted triggers, so gate it on both the
|
||||
# kill-switch and is_fork.
|
||||
if [ "${DEPOT_ENABLED}" = "true" ] && [ "${is_fork}" = "false" ]; then
|
||||
use_depot=true
|
||||
else
|
||||
use_depot=false
|
||||
fi
|
||||
|
||||
echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT"
|
||||
echo "use_depot=${use_depot}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -18,8 +18,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -19,14 +19,8 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
build:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -247,7 +241,7 @@ jobs:
|
||||
# so skip it for merge_group runs and workflow_dispatch.
|
||||
if: github.event_name == 'pull_request'
|
||||
id: jacoco
|
||||
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
|
||||
uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0
|
||||
with:
|
||||
paths: |
|
||||
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
|
||||
@@ -15,23 +15,11 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
# No `concurrency:` block here on purpose. When this workflow is called via
|
||||
# workflow_call from build.yml, ${{ github.workflow }}/event_name/pr_number
|
||||
@@ -50,17 +38,16 @@ 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.
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
|
||||
# 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.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
|
||||
PREMIUM_ENABLED: "true"
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -99,6 +99,17 @@ jobs:
|
||||
uses: ./.github/workflows/frontend-validation.yml
|
||||
secrets: inherit
|
||||
|
||||
# Advisory: deliberately NOT in all-checks-passed. It reports on the stories a
|
||||
# branch touches so a regression is visible in review, but a browser scan is
|
||||
# too new here to block merges on. Promote it once its pass/fail proves stable.
|
||||
frontend-a11y:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/frontend-a11y.yml
|
||||
secrets: inherit
|
||||
|
||||
playwright-e2e:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [files-changed]
|
||||
@@ -149,7 +160,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/test-build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
@@ -164,13 +174,12 @@ jobs:
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/tauri-build.yml
|
||||
secrets: inherit
|
||||
# PR smoke build: Linux only (fastest + cheapest to compile), unsigned,
|
||||
# deb-only, no AppImage. The full signed multi-OS matrix runs on release;
|
||||
# PR smoke build: macOS + Windows (the platforms our developers use).
|
||||
# The full signed multi-OS matrix runs on release;
|
||||
# nightly still warms the Rust cache with all-OS defaults.
|
||||
with:
|
||||
platform: linux
|
||||
platform: windows-macos
|
||||
sign: false
|
||||
minimal: true
|
||||
|
||||
ai-engine:
|
||||
if: needs.files-changed.outputs.engine == 'true'
|
||||
|
||||
@@ -21,8 +21,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -11,8 +11,6 @@ permissions:
|
||||
jobs:
|
||||
check-licence:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -10,14 +10,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
check-generate-openapi-docs:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -29,12 +29,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
aggregate:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -66,7 +62,7 @@ jobs:
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -12,15 +12,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
migration-test:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -10,21 +10,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
deploy-v2-on-push:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy-v2-push-V2
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -35,12 +25,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
@@ -105,22 +90,8 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push frontend image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-frontend.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push frontend image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false'
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
@@ -134,22 +105,8 @@ jobs:
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push backend image (Depot)
|
||||
if: env.USE_DEPOT == 'true' && steps.check-backend.outputs.exists == 'false'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push backend image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false'
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -11,28 +11,17 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 4 because bench showed 16 was within noise of 4."
|
||||
required: false
|
||||
type: string
|
||||
default: "4"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
docker-compose-tests:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -85,7 +74,7 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
@@ -5,23 +5,13 @@ name: Playwright E2E (live backend)
|
||||
# server.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e-live:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -94,7 +84,7 @@ jobs:
|
||||
fi
|
||||
- name: Set up Python for coverage summary
|
||||
if: always() && steps.live-coverage.outputs.report == 'true'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install defusedxml for coverage summary
|
||||
@@ -134,7 +124,7 @@ jobs:
|
||||
# a summary even on backend failure, as long as some Playwright
|
||||
# tests ran far enough to dump V8 coverage.
|
||||
if: always()
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -5,23 +5,13 @@ name: Playwright E2E (stubbed)
|
||||
# mocks API responses in the browser.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 8 to match the other playwright workflows; bench showed flat scaling above 8."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-e2e:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Frontend a11y regression gate
|
||||
|
||||
# Reusable workflow called from build.yml when frontend sources change.
|
||||
#
|
||||
# Scans the stories this branch touches in real Chromium and runs axe against
|
||||
# each. Existing violations are grandfathered in .storybook/a11y-baseline.json;
|
||||
# the check fails on a NEW violation — a story breaking a rule it wasn't already
|
||||
# breaking — or on a story that fails to render at all.
|
||||
#
|
||||
# Only changed stories, because a full sweep is ~30 minutes: far too slow to sit
|
||||
# in front of every merge. The whole suite is scanned nightly instead
|
||||
# (nightly.yml), which catches anything a branch didn't touch.
|
||||
#
|
||||
# Advisory for now: this is not in build.yml's all-checks-passed list, so a
|
||||
# failure reports without blocking. Promote it once a few weeks of runs show the
|
||||
# pass/fail is stable.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
frontend-a11y:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Need the base branch too, to diff against it.
|
||||
fetch-depth: 0
|
||||
- name: Set up Node.js
|
||||
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
|
||||
- name: a11y gate (changed stories)
|
||||
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
|
||||
- name: Upload scan reports
|
||||
# The reports carry the offending selector and help text for each
|
||||
# violation; without them a red run can only be understood by
|
||||
# reproducing the whole scan locally.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: a11y-scan-${{ github.run_id }}
|
||||
path: frontend/.a11y-scan/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
# The reports live in a dot-directory, which upload-artifact treats as
|
||||
# hidden and silently skips by default.
|
||||
include-hidden-files: true
|
||||
@@ -19,13 +19,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
files-changed:
|
||||
name: detect what files changed
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
|
||||
@@ -48,8 +44,8 @@ jobs:
|
||||
generate-frontend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: [pick, files-changed]
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -321,15 +317,13 @@ jobs:
|
||||
|
||||
generate-backend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: [pick, files-changed]
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -11,12 +11,8 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
frontend-validation:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -121,7 +117,7 @@ jobs:
|
||||
run: task frontend:test:coverage
|
||||
- name: Set up Python for coverage summary
|
||||
if: always()
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install defusedxml for coverage summary
|
||||
|
||||
@@ -36,13 +36,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
determine-matrix:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
@@ -112,10 +108,8 @@ jobs:
|
||||
fi
|
||||
|
||||
build-jars:
|
||||
needs: [pick, determine-matrix]
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant:
|
||||
@@ -195,7 +189,6 @@ jobs:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -638,8 +631,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
collect-and-release:
|
||||
needs: [pick, determine-matrix, build, build-jars]
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
needs: [determine-matrix, build, build-jars]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -13,13 +13,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
playwright-all-browsers:
|
||||
name: Playwright (chromium + firefox + webkit)
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -57,6 +53,49 @@ jobs:
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Whole-suite accessibility sweep. Pull requests only scan the stories they
|
||||
# touch (frontend-a11y.yml) because a full pass takes ~30 minutes; this covers
|
||||
# everything else, so a violation introduced by a change somewhere other than
|
||||
# the story itself — a shared component, a theme token — still surfaces within
|
||||
# a day.
|
||||
a11y-all-stories:
|
||||
name: a11y (every story)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
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
|
||||
|
||||
- name: a11y gate (every story)
|
||||
run: task frontend:storybook:a11y
|
||||
|
||||
- name: Upload scan reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: a11y-scan-nightly-${{ github.run_id }}
|
||||
path: frontend/.a11y-scan/
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
# The reports live in a dot-directory, which upload-artifact treats as
|
||||
# hidden and silently skips by default.
|
||||
include-hidden-files: true
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
# written on main, where PR and merge-queue tauri builds can restore it.
|
||||
warm-tauri-cache:
|
||||
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
build_engine:
|
||||
description: "Build & push the stirling-pdf-engine image (plus the -docparse addon variant)."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
force_unoserver_rebuild:
|
||||
description: "Rebuild stirling-unoserver even if its source hash is unchanged."
|
||||
required: false
|
||||
@@ -51,6 +56,8 @@ jobs:
|
||||
env:
|
||||
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
|
||||
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
|
||||
# Engine images are dispatch-only for now; flip the default once the addon stabilises.
|
||||
RUN_ENGINE: ${{ github.event_name == 'workflow_dispatch' && inputs.build_engine }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -139,7 +146,6 @@ jobs:
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
@@ -220,6 +226,62 @@ jobs:
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for engine
|
||||
id: meta-engine
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
if: env.RUN_ENGINE == 'true'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-engine
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-engine
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push engine image
|
||||
id: build-push-engine
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
if: env.RUN_ENGINE == 'true' && steps.meta-engine.outputs.tags != ''
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: ./engine
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-engine
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-engine
|
||||
tags: ${{ steps.meta-engine.outputs.tags }}
|
||||
labels: ${{ steps.meta-engine.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Generate tags for engine docparse addon
|
||||
id: meta-engine-docparse
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
if: env.RUN_ENGINE == 'true'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-engine
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-engine
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-docparse
|
||||
type=raw,value=latest-docparse
|
||||
|
||||
- name: Build and push engine docparse addon image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
if: env.RUN_ENGINE == 'true' && steps.meta-engine-docparse.outputs.tags != ''
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: ./engine
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-engine-docparse
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-engine-docparse
|
||||
tags: ${{ steps.meta-engine-docparse.outputs.tags }}
|
||||
labels: ${{ steps.meta-engine-docparse.outputs.labels }}
|
||||
build-args: DOCPARSE=true
|
||||
platforms: linux/amd64
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
|
||||
@@ -22,15 +22,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)."
|
||||
description: "Platform to build (windows, macos, linux, windows-macos, or all)."
|
||||
required: false
|
||||
type: string
|
||||
default: "all"
|
||||
@@ -29,7 +29,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
description: "Platform to build (windows, macos, linux, windows-macos, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
@@ -38,6 +38,7 @@ on:
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
- windows-macos
|
||||
sign:
|
||||
description: "Sign and notarize the bundles."
|
||||
required: false
|
||||
@@ -76,10 +77,11 @@ jobs:
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
|
||||
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
@@ -106,7 +108,6 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
|
||||
@@ -17,19 +17,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: "false"
|
||||
depot_cores:
|
||||
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
|
||||
required: false
|
||||
type: string
|
||||
default: "8"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
# TODO: extract a pre-matrix `prepare` job that runs once and produces
|
||||
# shared artifacts for the three matrix entries below to consume:
|
||||
# 1. `task backend:build` — currently runs 3× in parallel with
|
||||
@@ -45,14 +37,7 @@ jobs:
|
||||
# spring-security=true matrix entry if `task backend:build` and
|
||||
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
|
||||
test-build-docker-images:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' && inputs.docker-base-changed != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -125,16 +110,10 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
@@ -182,24 +161,10 @@ jobs:
|
||||
--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
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
platforms: ${{ steps.build-params.outputs.platforms }}
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
# Fork PRs that did NOT change the base use the buildx container builder
|
||||
# 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' && inputs.docker-base-changed != 'true'
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
@@ -227,14 +192,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
test-build-unoserver-image:
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' && inputs.docker-base-changed != 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -244,35 +202,14 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/unoserver/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
tags: stirling-unoserver:pr-test
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
|
||||
@@ -20,19 +20,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pick:
|
||||
uses: ./.github/workflows/_runner-pick.yml
|
||||
|
||||
deploy:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -61,12 +51,7 @@ jobs:
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Depot CLI
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: env.USE_DEPOT != 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Get version number
|
||||
@@ -81,20 +66,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push test image (Depot)
|
||||
if: env.USE_DEPOT == 'true'
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push test image (Docker fork fallback)
|
||||
if: env.USE_DEPOT != 'true'
|
||||
- name: Build and push test image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
@@ -153,8 +125,7 @@ jobs:
|
||||
files-changed:
|
||||
if: always()
|
||||
name: detect what files changed
|
||||
needs: pick
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
@@ -174,8 +145,8 @@ jobs:
|
||||
|
||||
test:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: [pick, deploy, files-changed]
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
needs: [deploy, files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
@@ -208,8 +179,8 @@ jobs:
|
||||
FORCE_COLOR: "3"
|
||||
|
||||
cleanup:
|
||||
needs: [pick, deploy, test]
|
||||
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
|
||||
needs: [deploy, test]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
|
||||
+65
-3
@@ -184,16 +184,74 @@ tasks:
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
deps: [install]
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx storybook dev -p 6006 {{.CLI_ARGS}}
|
||||
|
||||
storybook:build:
|
||||
desc: "Build static Storybook"
|
||||
deps: [install]
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx storybook build {{.CLI_ARGS}}
|
||||
|
||||
storybook:browser:
|
||||
internal: true
|
||||
desc: "Install the Chromium build the story scan runs in"
|
||||
run: once
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx playwright install chromium
|
||||
|
||||
storybook:test:
|
||||
desc: "Scan every story in real Chromium: it must render and pass axe"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
# Runs each story as a browser test. Pass a filter through, e.g.
|
||||
# task frontend:storybook:test -- Button
|
||||
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
|
||||
|
||||
storybook:a11y:
|
||||
desc: "a11y regression gate over every story: fail only on NEW axe violations"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
|
||||
storybook:a11y:changed:
|
||||
desc: "a11y gate over the stories this branch affects (default base origin/main)"
|
||||
summary: |
|
||||
Scans the stories a branch affects, which is what pull requests run — a
|
||||
full scan takes ~30 minutes, far too long to sit in front of every merge.
|
||||
A story is affected if its file changed, or if a same-named sibling
|
||||
source file changed (editing Button.tsx or Button.css re-scans
|
||||
Button.stories.tsx — the story renders the live component, so a component
|
||||
edit changes what the story shows without touching the story file).
|
||||
Changes that ripple further than a component's own stories are covered by
|
||||
the nightly full sweep.
|
||||
|
||||
Pass a base ref through CLI_ARGS, e.g.
|
||||
task frontend:storybook:a11y:changed -- origin/release
|
||||
deps: [prepare, storybook:browser]
|
||||
vars:
|
||||
BASE: '{{.CLI_ARGS | default "origin/main"}}'
|
||||
CHANGED:
|
||||
sh: node .storybook/a11y-changed.mjs {{.CLI_ARGS | default "origin/main"}}
|
||||
cmds:
|
||||
- cmd: |
|
||||
if [ -z '{{.CHANGED}}' ]; then
|
||||
echo "a11y: no story files affected vs {{.BASE}} — nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
node .storybook/a11y-scan.mjs {{.CHANGED}}
|
||||
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
|
||||
storybook:a11y:record:
|
||||
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
|
||||
|
||||
# ============================================================
|
||||
# Code quality
|
||||
# ============================================================
|
||||
@@ -207,10 +265,14 @@ tasks:
|
||||
- task: lint:colors
|
||||
|
||||
lint:colors:
|
||||
desc: "Enforce theme tokens — colours in core/theme route through the palette"
|
||||
desc: "Enforce theme tokens — no hardcoded colours or raw primitives in components"
|
||||
aliases: [lint:colours]
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/lint/theme-lint.mjs
|
||||
- node editor/scripts/lint/theme-lint.mjs css-colors
|
||||
- node editor/scripts/lint/theme-lint.mjs code-colors
|
||||
- node editor/scripts/lint/theme-lint.mjs no-primitives
|
||||
|
||||
contrast:
|
||||
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
|
||||
|
||||
@@ -73,7 +73,7 @@ tasks:
|
||||
- task: gitleaks
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)"
|
||||
desc: "Install the pinned pre-commit Python tools"
|
||||
run: once
|
||||
cmds:
|
||||
- uv sync --project scripts/pre-commit --locked
|
||||
@@ -112,7 +112,7 @@ tasks:
|
||||
toml-sort:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}}
|
||||
- uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
|
||||
|
||||
whitespace:
|
||||
cmds:
|
||||
|
||||
@@ -16,7 +16,7 @@ dependencies {
|
||||
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.10' // RAR archive support for CBR files
|
||||
api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
|
||||
@@ -433,6 +433,10 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
|
||||
addEndpointToGroup("Automation", "pipeline");
|
||||
|
||||
// Adding endpoints to "DocParse" group (ingestion: chunk + index + export)
|
||||
addEndpointToGroup("DocParse", "rag-ingest");
|
||||
addEndpointToGroup("DocParse", "extract-tables");
|
||||
|
||||
// Adding endpoints to "DeveloperTools" group
|
||||
addEndpointToGroup("DeveloperTools", "show-javascript");
|
||||
|
||||
|
||||
+142
-3
@@ -77,6 +77,7 @@ public class ApplicationProperties {
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
private AiEngine aiEngine = new AiEngine();
|
||||
private Docparse docparse = new Docparse();
|
||||
private Mcp mcp = new Mcp();
|
||||
private InternalApi internalApi = new InternalApi();
|
||||
private Cluster cluster = new Cluster();
|
||||
@@ -208,9 +209,10 @@ public class ApplicationProperties {
|
||||
public static class Policies {
|
||||
/**
|
||||
* 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
|
||||
* pointed at an arbitrary server path. Stirling's own config directory is always
|
||||
* off-limits, and folder access is always disabled in SaaS mode regardless of this list.
|
||||
* write to. Empty (the default) disables folder access except to implicitly defined
|
||||
* folders, such as server storage folders (if enabled) and the pipeline watched folders.
|
||||
* Stirling's own config directory is always off-limits, and folder access is always
|
||||
* disabled in SaaS mode regardless of this list.
|
||||
*/
|
||||
private List<String> allowedFolderRoots = new java.util.ArrayList<>();
|
||||
|
||||
@@ -249,6 +251,29 @@ public class ApplicationProperties {
|
||||
* in-network object store.
|
||||
*/
|
||||
private boolean allowPrivateS3Endpoints = false;
|
||||
|
||||
/**
|
||||
* Whether an API/Purview/ConsignO integration's base URL may resolve to a loopback,
|
||||
* link-local, or private address. Off by default: unlike S3 connections, any user may
|
||||
* create one of these, so without this gate a user could point a connection at the cloud
|
||||
* metadata address and have the server fetch it for them. Enable only when integrations
|
||||
* genuinely live inside the network (e.g. an on-prem ConsignO or an internal API gateway).
|
||||
*/
|
||||
private boolean allowPrivateApiEndpoints = false;
|
||||
|
||||
/**
|
||||
* Whether administrators may define their own API integrations - a free-form base URL,
|
||||
* path, body and headers - as opposed to only using the built-in vendor presets (Purview,
|
||||
* ConsignO, S3). On by default, and admin-only regardless: a custom integration can point
|
||||
* the server at any host, so it is authoring power, not self-serve.
|
||||
*
|
||||
* <p>Turning this off stops new custom integrations being created or edited. Ones that
|
||||
* already exist keep running, because a policy that silently stopped calling out would be a
|
||||
* worse surprise than one that keeps working; disable the connection itself to stop it.
|
||||
*/
|
||||
private boolean allowCustomApiIntegrations = true;
|
||||
|
||||
private long webhookMaxBytes = 104857600L;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -303,6 +328,120 @@ public class ApplicationProperties {
|
||||
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
|
||||
*/
|
||||
private int longRunningTimeoutSeconds = 600;
|
||||
|
||||
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
|
||||
private int streamTimeoutSeconds = 1800;
|
||||
|
||||
/**
|
||||
* Whether the processor pushes settings-derived AI config to the engine on startup/save.
|
||||
* Pin false for env-driven deployments (SaaS) to keep the engine env-controlled.
|
||||
*/
|
||||
private boolean pushConfigToEngine = true;
|
||||
|
||||
/** Model + provider selection, forwarded to the engine per-request. */
|
||||
private Models models = new Models();
|
||||
|
||||
/** Retrieval-augmented-generation (RAG) knobs, forwarded to the engine per-request. */
|
||||
private Rag rag = new Rag();
|
||||
|
||||
/** Request size / cost guardrails. */
|
||||
private Limits limits = new Limits();
|
||||
|
||||
/** Per-capability on/off switches so an admin can disable individual AI tools. */
|
||||
private Features features = new Features();
|
||||
|
||||
@Data
|
||||
public static class Models {
|
||||
/** Provider driving the model strings: 'anthropic', 'openai', 'ollama', or 'custom'. */
|
||||
private String provider = "anthropic";
|
||||
|
||||
/** High-quality tier model name (without provider prefix), e.g. 'claude-haiku-4-5'. */
|
||||
private String smartModel = "claude-haiku-4-5";
|
||||
|
||||
/** Cheap/fast tier model name (without provider prefix). */
|
||||
private String fastModel = "claude-haiku-4-5";
|
||||
|
||||
private int smartMaxTokens = 8192;
|
||||
private int fastMaxTokens = 2048;
|
||||
|
||||
/**
|
||||
* API key for the selected provider (secret; masked). Empty means the engine uses its
|
||||
* own env credential (e.g. ANTHROPIC_API_KEY).
|
||||
*/
|
||||
private String apiKey = "";
|
||||
|
||||
/**
|
||||
* OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g.
|
||||
* http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only.
|
||||
*/
|
||||
private String baseUrl = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Rag {
|
||||
/**
|
||||
* Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible).
|
||||
*/
|
||||
private String embeddingProvider = "voyageai";
|
||||
|
||||
/** Embedding model name (without provider prefix), e.g. 'voyage-4'. */
|
||||
private String embeddingModel = "voyage-4";
|
||||
|
||||
/**
|
||||
* Secret API key for the embedding provider; masked + env-overridable like
|
||||
* models.apiKey.
|
||||
*/
|
||||
private String embeddingApiKey = "";
|
||||
|
||||
/**
|
||||
* OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g.
|
||||
* http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only.
|
||||
*/
|
||||
private String embeddingBaseUrl = "";
|
||||
|
||||
/** How many chunks retrieval returns per search. */
|
||||
private int topK = 20;
|
||||
|
||||
/** Per-run cap on knowledge-search tool calls before the agent must answer. */
|
||||
private int maxSearches = 5;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Limits {
|
||||
private int maxPages = 200;
|
||||
private int maxCharacters = 200000;
|
||||
|
||||
/** Process-wide cap on concurrent model API calls (engine restart to apply). */
|
||||
private int modelMaxConcurrency = 32;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Features {
|
||||
private boolean chat = true;
|
||||
private boolean documentQuestions = true;
|
||||
private boolean createPdf = true;
|
||||
private boolean mathAuditor = true;
|
||||
private boolean pdfComment = true;
|
||||
private boolean classify = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DocParse settings (top-level {@code docparse.*}): document understanding for ingestion
|
||||
* pipelines. The basic tier (text layer) always works; the advanced tier lives in the engine's
|
||||
* docparse addon.
|
||||
*/
|
||||
@Data
|
||||
public static class Docparse {
|
||||
|
||||
/** Master switch; hides the DocParse endpoints when false. */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** Requested tier: 'auto', 'basic', or 'advanced'. 'auto' resolves per document. */
|
||||
private String mode = "auto";
|
||||
|
||||
/** Mirrors DOCPARSE_AUTO_INSTALL for the engine's boot-time addon install script. */
|
||||
private boolean autoInstall = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
/**
|
||||
* View of the engine's DocParse capability for modules that cannot see the proprietary
|
||||
* implementation (e.g. ConfigController in core). Implemented by the proprietary
|
||||
* DocparseCapabilityService; absent when the proprietary module is not loaded.
|
||||
*/
|
||||
public interface DocparseCapabilityServiceInterface {
|
||||
|
||||
/**
|
||||
* Whether the engine reports the docparse addon (advanced tier) as installed. Must be cheap and
|
||||
* non-blocking: returns the cached probe result, {@code false} when the engine is disabled,
|
||||
* unreachable, or not yet probed.
|
||||
*/
|
||||
boolean isAdvancedInstalled();
|
||||
}
|
||||
@@ -46,9 +46,14 @@ public class InternalApiClient {
|
||||
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
|
||||
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
|
||||
// intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
|
||||
//
|
||||
// `/api/v1/integration/*` holds third-party steps (external API call, Purview labelling,
|
||||
// ConsignO). They reach outside the JVM, so the namespace is deliberately kept to tools that
|
||||
// dereference an admin-owned connection rather than a caller-supplied host — see
|
||||
// ApiConnectionResolver.
|
||||
private static final Pattern ALLOWED_ENDPOINT_PATH =
|
||||
Pattern.compile(
|
||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||
"^/api/v1/(general|misc|security|convert|filter|integration|docparse)(/[A-Za-z0-9_-]+)+$"
|
||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
/**
|
||||
|
||||
@@ -202,6 +202,7 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/readiness")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/api/v1/webhooks/")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
// Workflow participant endpoints - access controlled by share tokens, not login
|
||||
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
|
||||
|
||||
@@ -176,6 +176,13 @@ class RequestUriUtilsTest {
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_webhookReceiver() {
|
||||
// The webhook source receiver authenticates each delivery by HMAC signature, not a session.
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/whk_abc123", ""));
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/api/v1/webhooks/whk_abc123", "/app"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_withContextPath() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app"));
|
||||
|
||||
+13
-1
@@ -336,7 +336,19 @@ public class ConfigController {
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// AI Engine settings
|
||||
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
|
||||
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
|
||||
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
|
||||
// Per-capability flags let the UI hide individual AI tools an admin has turned off.
|
||||
ApplicationProperties.AiEngine.Features aiFeatures = aiEngineConfig.getFeatures();
|
||||
configData.put(
|
||||
"aiFeatures",
|
||||
Map.ofEntries(
|
||||
Map.entry("chat", aiFeatures.isChat()),
|
||||
Map.entry("documentQuestions", aiFeatures.isDocumentQuestions()),
|
||||
Map.entry("createPdf", aiFeatures.isCreatePdf()),
|
||||
Map.entry("mathAuditor", aiFeatures.isMathAuditor()),
|
||||
Map.entry("pdfComment", aiFeatures.isPdfComment()),
|
||||
Map.entry("classify", aiFeatures.isClassify())));
|
||||
|
||||
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||
ApplicationProperties.Security.Timestamp tsConfig =
|
||||
|
||||
@@ -366,12 +366,51 @@ aiEngine:
|
||||
enabled: false # Set to 'true' to enable the AI engine integration
|
||||
url: http://localhost:5001 # URL of the Python AI engine
|
||||
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
|
||||
longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents
|
||||
streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs
|
||||
pushConfigToEngine: true # Push admin AI config to the engine on startup + save; false = engine stays fully env-controlled
|
||||
models:
|
||||
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
|
||||
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
|
||||
fastModel: claude-haiku-4-5 # Cheap/fast tier model name (no provider prefix)
|
||||
smartMaxTokens: 8192 # Max output tokens for the smart tier
|
||||
fastMaxTokens: 2048 # Max output tokens for the fast tier
|
||||
apiKey: "" # API key for the selected provider (secret). Empty = engine uses its native env credentials (e.g. ANTHROPIC_API_KEY)
|
||||
baseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' providers (e.g. http://ollama:11434/v1). Ignored for anthropic/openai
|
||||
rag:
|
||||
embeddingProvider: voyageai # Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
|
||||
embeddingModel: voyage-4 # Embedding model name (no provider prefix)
|
||||
embeddingApiKey: "" # Secret API key for the embedding provider. Empty = engine uses its native env credentials (e.g. VOYAGE_API_KEY)
|
||||
embeddingBaseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' embedding providers (e.g. http://ollama:11434/v1). Ignored for voyageai/openai
|
||||
topK: 20 # Number of chunks retrieval returns per search
|
||||
maxSearches: 5 # Per-run cap on knowledge-search tool calls before the agent must answer
|
||||
limits:
|
||||
maxPages: 200 # Upper bound on PDF pages the engine will process per request
|
||||
maxCharacters: 200000 # Upper bound on characters of extracted text per request
|
||||
modelMaxConcurrency: 32 # Process-wide cap on concurrent model API calls (engine restart to apply)
|
||||
features: # Per-capability switches; turn an individual AI tool off without disabling the whole engine
|
||||
chat: true # Assistant chat
|
||||
documentQuestions: true # Ask-questions-about-a-PDF
|
||||
createPdf: true # Generate a PDF from a natural-language spec
|
||||
mathAuditor: true # Numerical/formula contradiction auditing
|
||||
pdfComment: true # AI-authored PDF comments/annotations
|
||||
classify: true # Automatic document classification/labelling
|
||||
|
||||
# DocParse: document understanding for ingestion pipelines (chunking + knowledge-base
|
||||
# indexing). The basic tier (text layer) always works; the advanced tier (layout parsing)
|
||||
# requires the engine's docparse addon. Env overrides: DOCPARSE_ENABLED, DOCPARSE_MODE.
|
||||
docparse:
|
||||
enabled: true # Master switch; hides the DocParse endpoints when false
|
||||
mode: auto # Tier selection: 'auto' (best available), 'basic', or 'advanced'
|
||||
autoInstall: false # Mirrors DOCPARSE_AUTO_INSTALL for the engine's boot-time addon install script
|
||||
|
||||
policies:
|
||||
# Folder automations can read from and write to the directories you allow here, so treat this as a
|
||||
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs
|
||||
# entirely; list absolute directories to permit folder access only within them. Stirling's own
|
||||
# config directory is always off-limits, and folder access is always disabled in SaaS mode.
|
||||
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs,
|
||||
# other than from directories that are always permitted like server file-storage and watched folders.
|
||||
# List absolute directories to permit folder access within them.
|
||||
# Stirling's own config directory is always off-limits, and folder access is always
|
||||
# disabled in SaaS mode.
|
||||
allowedFolderRoots: [] # e.g. ["/data/inbox", "/data/outbox"]
|
||||
scheduleSweepSeconds: 60 # How often (seconds) scheduled policies are checked for being due
|
||||
watchReconcileSeconds: 300 # How often (seconds) folder-watch re-syncs watches and re-runs as a safety net for missed events
|
||||
@@ -385,6 +424,8 @@ policies:
|
||||
mcp:
|
||||
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
|
||||
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
|
||||
maxRequestBytes: 10485760 # Max size (bytes) of an incoming MCP tool request payload (default 10 MB)
|
||||
maxInlineResponseBytes: 10485760 # Max size (bytes) of an MCP tool response returned inline before it is rejected (default 10 MB)
|
||||
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
|
||||
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
|
||||
auth:
|
||||
|
||||
+69
@@ -39,6 +39,11 @@ public class SecretMasker {
|
||||
"bearer",
|
||||
"signature");
|
||||
|
||||
// Keys whose nested map holds secrets under arbitrary, caller-named keys - a free-form HTTP
|
||||
// headers map is the case in point: the secret can sit under any header name (X-API-Key,
|
||||
// Ocp-Apim-Subscription-Key), so the name is no signal. Mask every value in these outright.
|
||||
private static final Set<String> SENSITIVE_VALUE_CONTAINERS = Set.of("headers");
|
||||
|
||||
/** Replace sensitive values with the mask (recursively) for safe display. */
|
||||
public Map<String, Object> mask(Map<String, Object> config) {
|
||||
return mask(config, 0);
|
||||
@@ -73,6 +78,12 @@ public class SecretMasker {
|
||||
if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) {
|
||||
continue;
|
||||
}
|
||||
if (isSensitiveContainer(e.getKey())
|
||||
&& e.getValue() instanceof Map<?, ?> m
|
||||
&& depth < MAX_DEPTH) {
|
||||
out.put(e.getKey(), sanitizeAllValues(castMap(m), depth + 1));
|
||||
continue;
|
||||
}
|
||||
out.put(
|
||||
e.getKey(),
|
||||
e.getValue() instanceof Map<?, ?> m && depth < MAX_DEPTH
|
||||
@@ -100,6 +111,14 @@ public class SecretMasker {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isSensitiveContainer(key)
|
||||
&& depth < MAX_DEPTH
|
||||
&& stored.get(key) instanceof Map<?, ?> s
|
||||
&& value instanceof Map<?, ?> i) {
|
||||
// Every value here is a secret, so restore a redacted one from stored per-entry.
|
||||
out.put(key, mergeAllValues(castMap(s), castMap(i), depth + 1));
|
||||
continue;
|
||||
}
|
||||
if (depth < MAX_DEPTH
|
||||
&& stored.get(key) instanceof Map<?, ?> s
|
||||
&& value instanceof Map<?, ?> i) {
|
||||
@@ -119,6 +138,9 @@ public class SecretMasker {
|
||||
}
|
||||
return MASK;
|
||||
}
|
||||
if (isSensitiveContainer(key) && value instanceof Map<?, ?> m && depth < MAX_DEPTH) {
|
||||
return maskAllValues(castMap(m), depth + 1);
|
||||
}
|
||||
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;
|
||||
@@ -141,6 +163,53 @@ public class SecretMasker {
|
||||
return SENSITIVE_HINTS.stream().anyMatch(lower::contains);
|
||||
}
|
||||
|
||||
private boolean isSensitiveContainer(String key) {
|
||||
return SENSITIVE_VALUE_CONTAINERS.contains(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/** Mask every value in a container map, whatever its keys are named. */
|
||||
private Map<String, Object> maskAllValues(Map<String, Object> map, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : map.entrySet()) {
|
||||
Object v = e.getValue();
|
||||
if (v == null || (v instanceof String s && s.isBlank())) {
|
||||
out.put(e.getKey(), v);
|
||||
} else if (v instanceof Map<?, ?> m && depth < MAX_DEPTH) {
|
||||
out.put(e.getKey(), maskAllValues(castMap(m), depth + 1));
|
||||
} else {
|
||||
out.put(e.getKey(), MASK);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge a container map treating every entry as a secret, restoring redacted from stored. */
|
||||
private Map<String, Object> mergeAllValues(
|
||||
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
if (isRedacted(e.getValue(), depth)) {
|
||||
if (stored.containsKey(e.getKey())) {
|
||||
out.put(e.getKey(), stored.get(e.getKey()));
|
||||
}
|
||||
} else {
|
||||
out.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop redacted entries from a container map on create, whatever their keys are named. */
|
||||
private Map<String, Object> sanitizeAllValues(Map<String, Object> map, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : map.entrySet()) {
|
||||
if (!isRedacted(e.getValue(), depth)) {
|
||||
out.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Blank, the mask placeholder, or any structure that still contains the mask. */
|
||||
private boolean isRedacted(Object value, int depth) {
|
||||
if (value == null) {
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
/** Meters a client-side classification run; SaaS charges PAYG, other flavors have no bean. */
|
||||
public interface ClassificationRunBiller {
|
||||
|
||||
/** Charge one classification policy run covering {@code documentCount} documents. */
|
||||
void recordClassificationRun(int documentCount);
|
||||
}
|
||||
+13
-2
@@ -54,10 +54,21 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
return;
|
||||
}
|
||||
String rid = MDC.get("requestId");
|
||||
String apiKeyLabel =
|
||||
MDC.get(
|
||||
stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
|
||||
|
||||
if (rid != null) {
|
||||
if (rid != null || apiKeyLabel != null) {
|
||||
clean = new java.util.HashMap<>(clean);
|
||||
clean.put("requestId", rid);
|
||||
if (rid != null) {
|
||||
clean.put("requestId", rid);
|
||||
}
|
||||
// Named key that made the request; surfaces as the doc source in the processor
|
||||
// feed.
|
||||
if (apiKeyLabel != null) {
|
||||
clean.put("__apiKeyLabel", apiKeyLabel);
|
||||
}
|
||||
}
|
||||
|
||||
String source = MDC.get("auditSource");
|
||||
|
||||
+15
-6
@@ -7,7 +7,6 @@ import java.util.concurrent.Executor;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -28,6 +27,7 @@ import jakarta.validation.Valid;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
@@ -38,6 +38,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiEngineEndpointResolver;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.AiWorkflowService;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
@@ -60,15 +61,14 @@ public class AiEngineController {
|
||||
private final TaskManager taskManager;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
|
||||
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
|
||||
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
|
||||
* SSE emitter timeout (ms), long enough for multi-gigabyte PDF workflows without completing out
|
||||
* from under the executor. Derived from {@code aiEngine.streamTimeoutSeconds}.
|
||||
*/
|
||||
@Value("${stirling.ai.streamTimeoutMs:1800000}")
|
||||
private long streamTimeoutMs;
|
||||
private final long streamTimeoutMs;
|
||||
|
||||
public AiEngineController(
|
||||
AiEngineClient aiEngineClient,
|
||||
@@ -78,6 +78,8 @@ public class AiEngineController {
|
||||
TaskManager taskManager,
|
||||
JobOwnershipService jobOwnershipService,
|
||||
AiEngineEndpointResolver endpointResolver,
|
||||
AiFeatureGate aiFeatureGate,
|
||||
ApplicationProperties applicationProperties,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiWorkflowService = aiWorkflowService;
|
||||
@@ -86,7 +88,10 @@ public class AiEngineController {
|
||||
this.taskManager = taskManager;
|
||||
this.jobOwnershipService = jobOwnershipService;
|
||||
this.endpointResolver = endpointResolver;
|
||||
this.aiFeatureGate = aiFeatureGate;
|
||||
this.userService = userService;
|
||||
this.streamTimeoutMs =
|
||||
applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L;
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
@@ -111,6 +116,7 @@ public class AiEngineController {
|
||||
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
|
||||
public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
|
||||
throws IOException {
|
||||
aiFeatureGate.requireConversationalWorkflow();
|
||||
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
|
||||
registerFileResultAsJob(result);
|
||||
return result;
|
||||
@@ -123,6 +129,7 @@ public class AiEngineController {
|
||||
"Accepts a PDF upload and a user message, returns SSE events with progress"
|
||||
+ " updates followed by the final AI workflow result")
|
||||
public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
|
||||
aiFeatureGate.requireConversationalWorkflow();
|
||||
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
|
||||
|
||||
emitter.onTimeout(
|
||||
@@ -246,6 +253,8 @@ public class AiEngineController {
|
||||
"Sends a user message to the PDF edit agent which returns a structured plan"
|
||||
+ " of tool operations to perform")
|
||||
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
|
||||
// Same gate as /orchestrate: edit agent is a model call on the same conversational surface.
|
||||
aiFeatureGate.requireConversationalWorkflow();
|
||||
JsonNode parsed = parseJson(requestBody);
|
||||
if (!parsed.isObject()) {
|
||||
throw new ResponseStatusException(
|
||||
|
||||
+5
@@ -35,6 +35,7 @@ import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
@@ -67,6 +68,7 @@ public class ClassifyLabelController {
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final PdfMetadataService pdfMetadataService;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@@ -81,6 +83,7 @@ public class ClassifyLabelController {
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
AiFeatureGate aiFeatureGate,
|
||||
ObjectMapper objectMapper,
|
||||
ClassificationLabelProvider labelProvider,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
@@ -89,6 +92,7 @@ public class ClassifyLabelController {
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiFeatureGate = aiFeatureGate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.labelProvider = labelProvider;
|
||||
this.userService = userService;
|
||||
@@ -104,6 +108,7 @@ public class ClassifyLabelController {
|
||||
+ " intended for direct client use.")
|
||||
public ResponseEntity<Resource> classifyAndLabel(
|
||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||
aiFeatureGate.requireClassify();
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
|
||||
|
||||
+3
-1
@@ -34,6 +34,7 @@ import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
@@ -59,6 +60,7 @@ public class CreatePdfAgentController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiDocumentHtmlRenderer htmlRenderer;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
|
||||
/**
|
||||
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||
@@ -93,10 +95,10 @@ public class CreatePdfAgentController {
|
||||
public ResponseEntity<Resource> createPdf(
|
||||
@RequestParam("document") String document, @RequestParam("filename") String filename)
|
||||
throws Exception {
|
||||
|
||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
aiFeatureGate.requireCreatePdf();
|
||||
|
||||
AiDocument model;
|
||||
try {
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVPrinter;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.model.api.docparse.ExtractTablesApiRequest;
|
||||
import stirling.software.proprietary.model.api.docparse.RagIngestApiRequest;
|
||||
import stirling.software.proprietary.model.docparse.DocChunk;
|
||||
import stirling.software.proprietary.model.docparse.DocTable;
|
||||
import stirling.software.proprietary.model.docparse.DocparseCapabilitiesView;
|
||||
import stirling.software.proprietary.model.docparse.DocparseMode;
|
||||
import stirling.software.proprietary.model.docparse.ExtractTablesResponse;
|
||||
import stirling.software.proprietary.model.docparse.RagIngestResponse;
|
||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||
import stirling.software.proprietary.service.DocParseService;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Public DocParse ingestion API. Thin HTTP layer over {@link DocParseService}, which owns the
|
||||
* engine wire contract; this class owns the pipeline step shape (report header, export ZIP).
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/docparse")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(
|
||||
name = "DocParse",
|
||||
description =
|
||||
"Document ingestion: chunk, embed, and index documents into the searchable"
|
||||
+ " knowledge base, or export the parsed content (markdown, chunks JSONL)"
|
||||
+ " for external systems.")
|
||||
public class DocParseController {
|
||||
|
||||
private static final MediaType CSV = MediaType.parseMediaType("text/csv");
|
||||
|
||||
private final DocParseService docParseService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/rag-ingest",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Chunk, embed, and index a document into the RAG store (pipeline shape)",
|
||||
description =
|
||||
"Ingests the document into the engine's RAG store under a stable documentId"
|
||||
+ " (default: content hash). Returns the ORIGINAL PDF unchanged as the"
|
||||
+ " body, with the ingest summary JSON in the X-Stirling-Tool-Report"
|
||||
+ " header so policy pipelines pick it up as the step report. With"
|
||||
+ " exportMarkdown/exportChunksJsonl the body becomes a ZIP holding the"
|
||||
+ " original plus the corpus files, ready for delivery to external"
|
||||
+ " systems. Input:PDF Output:PDF/ZIP Type:SISO")
|
||||
public ResponseEntity<Resource> ragIngest(@ModelAttribute RagIngestApiRequest request)
|
||||
throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
boolean export = request.isExportMarkdown() || request.isExportChunksJsonl();
|
||||
RagIngestResponse result =
|
||||
docParseService.ragIngest(
|
||||
file,
|
||||
request.getDocumentId(),
|
||||
request.getChunkSize(),
|
||||
request.getOverlap(),
|
||||
DocparseMode.fromWire(request.getMode()),
|
||||
request.isIndex(),
|
||||
request.isExportMarkdown(),
|
||||
request.isExportChunksJsonl());
|
||||
|
||||
// The report header must stay small: summary fields only, never the echoed content.
|
||||
ObjectNode report = objectMapper.createObjectNode();
|
||||
report.put("mode", result.mode().wire());
|
||||
report.put("documentId", result.documentId());
|
||||
report.put("chunksIndexed", result.chunksIndexed());
|
||||
report.put("pages", result.pages());
|
||||
report.put("indexed", request.isIndex());
|
||||
|
||||
String fileName = DocParseService.fileName(file);
|
||||
byte[] original = file.getBytes();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set(AiToolResponseHeaders.TOOL_REPORT, objectMapper.writeValueAsString(report));
|
||||
|
||||
if (!export) {
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentDispositionFormData("attachment", fileName);
|
||||
headers.setContentLength(original.length);
|
||||
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(original));
|
||||
}
|
||||
|
||||
byte[] zip = exportZip(fileName, original, result, request);
|
||||
headers.setContentType(MediaType.parseMediaType("application/zip"));
|
||||
headers.setContentDispositionFormData("attachment", baseName(fileName) + "-ingested.zip");
|
||||
headers.setContentLength(zip.length);
|
||||
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(zip));
|
||||
}
|
||||
|
||||
@GetMapping("/capabilities")
|
||||
@Operation(
|
||||
summary = "DocParse capability summary",
|
||||
description =
|
||||
"Merged view of the Java settings and the engine's capability probe, so"
|
||||
+ " clients can gate advanced-tier UI.")
|
||||
public ResponseEntity<DocparseCapabilitiesView> capabilities() {
|
||||
return ResponseEntity.ok(docParseService.capabilitiesView());
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/extract-tables",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Extract tables from a document",
|
||||
description =
|
||||
"Extracts table structure and returns CSV (all tables concatenated, blank line"
|
||||
+ " between them) or the structured JSON table list."
|
||||
+ " Input:PDF Output:CSV/JSON Type:SISO")
|
||||
public ResponseEntity<?> extractTables(@ModelAttribute ExtractTablesApiRequest request)
|
||||
throws IOException {
|
||||
ExtractTablesResponse result = docParseService.tables(request.getFileInput());
|
||||
if ("json".equalsIgnoreCase(request.getOutputFormat())) {
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
tablesToCsv(result.tables()).getBytes(StandardCharsets.UTF_8),
|
||||
outputName(request.getFileInput(), "_tables.csv"),
|
||||
CSV);
|
||||
}
|
||||
|
||||
/** Original + requested corpus files in one ZIP, so destinations receive them together. */
|
||||
private byte[] exportZip(
|
||||
String fileName, byte[] original, RagIngestResponse result, RagIngestApiRequest request)
|
||||
throws IOException {
|
||||
String base = baseName(fileName);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(out)) {
|
||||
zip.putNextEntry(new ZipEntry(fileName));
|
||||
zip.write(original);
|
||||
zip.closeEntry();
|
||||
if (request.isExportMarkdown()) {
|
||||
zip.putNextEntry(new ZipEntry(base + ".md"));
|
||||
zip.write(
|
||||
(result.markdown() == null ? "" : result.markdown())
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
if (request.isExportChunksJsonl()) {
|
||||
zip.putNextEntry(new ZipEntry(base + ".chunks.jsonl"));
|
||||
zip.write(chunksJsonl(result).getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/** One chunk per line, each self-describing (documentId + source travel on every line). */
|
||||
private String chunksJsonl(RagIngestResponse result) {
|
||||
if (result.chunks() == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder lines = new StringBuilder();
|
||||
for (DocChunk chunk : result.chunks()) {
|
||||
ObjectNode line = objectMapper.createObjectNode();
|
||||
line.put("documentId", result.documentId());
|
||||
line.put("index", chunk.index());
|
||||
line.put("text", chunk.text());
|
||||
if (chunk.pageStart() != null) {
|
||||
line.put("pageStart", chunk.pageStart());
|
||||
}
|
||||
if (chunk.pageEnd() != null) {
|
||||
line.put("pageEnd", chunk.pageEnd());
|
||||
}
|
||||
var headings = line.putArray("headingPath");
|
||||
chunk.headingPath().forEach(headings::add);
|
||||
lines.append(objectMapper.writeValueAsString(line)).append('\n');
|
||||
}
|
||||
return lines.toString();
|
||||
}
|
||||
|
||||
private static String baseName(String fileName) {
|
||||
int dot = fileName.lastIndexOf('.');
|
||||
return dot > 0 ? fileName.substring(0, dot) : fileName;
|
||||
}
|
||||
|
||||
private static String tablesToCsv(List<DocTable> tables) throws IOException {
|
||||
CSVFormat format = CSVFormat.EXCEL.builder().setEscape('"').build();
|
||||
StringWriter writer = new StringWriter();
|
||||
try (CSVPrinter printer = format.print(writer)) {
|
||||
boolean first = true;
|
||||
for (DocTable table : tables) {
|
||||
if (!first) {
|
||||
printer.println();
|
||||
}
|
||||
first = false;
|
||||
for (List<String> row : table.cells()) {
|
||||
printer.printRecord(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return writer.toString();
|
||||
}
|
||||
|
||||
private static String outputName(MultipartFile file, String suffix) {
|
||||
return GeneralUtils.removeExtension(DocParseService.fileName(file)) + suffix;
|
||||
}
|
||||
}
|
||||
+3
@@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.Verdict;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.AiToolInputValidator;
|
||||
import stirling.software.proprietary.service.MathAuditorOrchestrator;
|
||||
|
||||
@@ -49,6 +50,7 @@ public class MathAuditorAgentController {
|
||||
|
||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
|
||||
private final MathAuditorOrchestrator orchestrator;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
|
||||
@PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
@@ -79,6 +81,7 @@ public class MathAuditorAgentController {
|
||||
+ " ignored (default: 0.01)")
|
||||
@RequestParam(value = "tolerance", defaultValue = "0.01")
|
||||
BigDecimal tolerance) {
|
||||
aiFeatureGate.requireMathAuditor();
|
||||
|
||||
AiToolInputValidator.validatePdfUpload(fileInput);
|
||||
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
|
||||
|
||||
+3
@@ -21,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||
@@ -49,6 +50,7 @@ public class PdfCommentAgentController {
|
||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
|
||||
private final PdfCommentAgentOrchestrator orchestrator;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiFeatureGate aiFeatureGate;
|
||||
|
||||
@PostMapping(
|
||||
value = "/pdf-comment-agent",
|
||||
@@ -79,6 +81,7 @@ public class PdfCommentAgentController {
|
||||
@RequestParam("prompt")
|
||||
String prompt)
|
||||
throws IOException {
|
||||
aiFeatureGate.requirePdfComment();
|
||||
|
||||
String originalFilename = fileInput.getOriginalFilename();
|
||||
String safeName =
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
|
||||
import stirling.software.proprietary.security.service.ApiKeyManagementService;
|
||||
|
||||
/**
|
||||
* Real backing for the portal Infrastructure → API Keys tab: list/create/revoke named, personal API
|
||||
* keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API
|
||||
* keys are a core auth feature available on every self-hosted instance.
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
public class PortalApiKeysController {
|
||||
|
||||
private final ApiKeyManagementService apiKeyManagementService;
|
||||
|
||||
// tier accepted for endpoint symmetry with the other infra tabs; ignored here.
|
||||
@GetMapping("/infrastructure/api-keys")
|
||||
@Operation(summary = "List API keys", description = "The caller's personal API keys.")
|
||||
public ResponseEntity<PortalApiKeysResponse> list(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
return ResponseEntity.ok(apiKeyManagementService.listVisibleKeys());
|
||||
}
|
||||
|
||||
@PostMapping("/infrastructure/api-keys")
|
||||
@Operation(
|
||||
summary = "Create an API key",
|
||||
description = "Mints a personal key and returns its one-time secret.")
|
||||
public ResponseEntity<CreatedApiKeyDto> create(@RequestBody CreateApiKeyRequest request) {
|
||||
return ResponseEntity.ok(apiKeyManagementService.createKey(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/infrastructure/api-keys/{id}")
|
||||
@Operation(summary = "Revoke an API key", description = "Disables a key the caller owns.")
|
||||
public ResponseEntity<Void> revoke(@PathVariable("id") Long id) {
|
||||
apiKeyManagementService.revokeKey(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
/**
|
||||
* How an {@link stirling.software.proprietary.integration.model.IntegrationType#API} connection
|
||||
* authenticates.
|
||||
*/
|
||||
public enum ApiAuthType {
|
||||
/** No credentials; the endpoint is open or authorises by network position. */
|
||||
NONE,
|
||||
/** {@code Authorization: Bearer <token>}. */
|
||||
BEARER,
|
||||
/** {@code Authorization: Basic base64(username:password)}. */
|
||||
BASIC,
|
||||
/** The token in a caller-named header, e.g. {@code X-API-Key: <token>}. */
|
||||
HEADER,
|
||||
/**
|
||||
* The connection logs in first and reuses the short-lived token it gets back.
|
||||
*
|
||||
* <p>For the large class of enterprise APIs - ConsignO Cloud, OAuth2 client-credentials, and
|
||||
* others - where credentials buy a token rather than authenticating a call directly. Without
|
||||
* this a step could not reach them at all: each call needs a token, and a stateless step has
|
||||
* nowhere to obtain or keep one. See {@link ApiTokenLogin}.
|
||||
*/
|
||||
TOKEN_LOGIN
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Dereferences a step's {@code connectionId} to a stored integration config.
|
||||
*
|
||||
* <p>Mirrors {@code S3ConnectionResolver}. When an authenticated caller is present the connection
|
||||
* must be usable by them; a background worker thread carries no {@code SecurityContext} and skips
|
||||
* that check, relying on the step having been access-checked when the policy was saved or when an
|
||||
* ad-hoc run was dispatched - see {@link IntegrationStepValidator}, which is what makes that
|
||||
* assumption true rather than merely hoped for.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class ApiConnectionResolver {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final IntegrationConfigRepository connections;
|
||||
private final OwnershipService ownership;
|
||||
private final UserService userService;
|
||||
|
||||
/** The raw config map for a connection of the given type. */
|
||||
public Map<String, Object> resolveConfig(Long connectionId, IntegrationType type) {
|
||||
IntegrationConfig connection =
|
||||
connections
|
||||
.findById(connectionId)
|
||||
.filter(cfg -> cfg.getIntegrationType() == type)
|
||||
.filter(this::usableByCurrentUser)
|
||||
// Existence and access collapse into one error so a caller cannot tell
|
||||
// "no such connection" from "someone else's connection" and enumerate ids.
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"unknown or inaccessible "
|
||||
+ type.name().toLowerCase()
|
||||
+ " connection"));
|
||||
if (!connection.isEnabled()) {
|
||||
throw new IllegalArgumentException(
|
||||
type.name().toLowerCase() + " connection is disabled");
|
||||
}
|
||||
return configOf(connection);
|
||||
}
|
||||
|
||||
/** The settings for a generic {@code API} connection. */
|
||||
public ApiConnectionSettings resolve(Long connectionId) {
|
||||
return ApiConnectionSettings.from(resolveConfig(connectionId, IntegrationType.API));
|
||||
}
|
||||
|
||||
/** Parse a {@code connectionId} step parameter; null when absent. */
|
||||
public static Long connectionId(Object reference) {
|
||||
if (reference == null || (reference instanceof String s && s.isBlank())) {
|
||||
return null;
|
||||
}
|
||||
if (reference instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(reference.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"'connectionId' is not a valid connection reference: " + reference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current caller may use this connection. A missing principal means a worker
|
||||
* thread, where access was established earlier; it must never be the only thing standing
|
||||
* between a caller and a connection, or the check becomes a confused deputy.
|
||||
*/
|
||||
private boolean usableByCurrentUser(IntegrationConfig connection) {
|
||||
User user = currentUser();
|
||||
return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user);
|
||||
}
|
||||
|
||||
// Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated.
|
||||
private User currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
if (principal instanceof UserDetails userDetails) {
|
||||
return userService.findByUsername(userDetails.getUsername()).orElse(null);
|
||||
}
|
||||
if (principal instanceof String username && !"anonymousUser".equals(username)) {
|
||||
return userService.findByUsername(username).orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, Object> configOf(IntegrationConfig connection) {
|
||||
String json = connection.getConfig();
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json, new TypeReference<LinkedHashMap<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException(
|
||||
"connection '" + connection.getName() + "' has unreadable config", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A resolved {@code API} connection: where to call, and how to authenticate.
|
||||
*
|
||||
* <p>{@code baseUrl} is the security anchor of the whole feature. It is set by whoever can manage
|
||||
* the connection (an admin or team owner) and is the only thing that decides which host is
|
||||
* contacted. A pipeline step supplies a <em>relative path</em> only, resolved under this base by
|
||||
* {@link ExternalApiPaths}, so a step author can never pivot the call to a host of their choosing.
|
||||
* Widening that - letting a step pass a full URL - would turn every policy into an SSRF primitive.
|
||||
*
|
||||
* <p>Whether the base URL may resolve to a private address is deliberately <em>not</em> a field
|
||||
* here. Any user may create an API connection (unlike S3, which {@code IntegrationConfigService}
|
||||
* restricts to admins), so a per-connection opt-in would let a user grant themselves a fetch of the
|
||||
* cloud metadata service. It is an operator property instead - {@code
|
||||
* policies.allowPrivateApiEndpoints} - checked by {@link ApiIntegrationValidator}.
|
||||
*/
|
||||
public record ApiConnectionSettings(
|
||||
String baseUrl,
|
||||
ApiAuthType authType,
|
||||
String headerName,
|
||||
String headerPrefix,
|
||||
String token,
|
||||
String username,
|
||||
String password,
|
||||
Map<String, String> headers,
|
||||
ApiTokenLogin tokenLogin,
|
||||
Set<String> resultUrlHosts,
|
||||
int timeoutSeconds) {
|
||||
|
||||
static final String BASE_URL_OPTION = "baseUrl";
|
||||
static final String AUTH_TYPE_OPTION = "authType";
|
||||
static final String HEADER_NAME_OPTION = "headerName";
|
||||
static final String HEADER_PREFIX_OPTION = "headerPrefix";
|
||||
// "token"/"password" contain SecretMasker hints, so they mask on read and merge on update.
|
||||
static final String TOKEN_OPTION = "token";
|
||||
static final String USERNAME_OPTION = "username";
|
||||
static final String PASSWORD_OPTION = "password";
|
||||
static final String HEADERS_OPTION = "headers";
|
||||
static final String RESULT_URL_HOSTS_OPTION = "resultUrlHosts";
|
||||
static final String TIMEOUT_SECONDS_OPTION = "timeoutSeconds";
|
||||
|
||||
static final int DEFAULT_TIMEOUT_SECONDS = 60;
|
||||
private static final int MAX_TIMEOUT_SECONDS = 600;
|
||||
|
||||
public ApiConnectionSettings {
|
||||
headers = headers == null ? Map.of() : Map.copyOf(headers);
|
||||
resultUrlHosts = resultUrlHosts == null ? Set.of() : Set.copyOf(resultUrlHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException if the config is unusable; the message is surfaced to the
|
||||
* operator editing the connection, so it names the offending option.
|
||||
*/
|
||||
public static ApiConnectionSettings from(Map<String, Object> options) {
|
||||
String baseUrl = trimmed(options.get(BASE_URL_OPTION));
|
||||
if (baseUrl == null) {
|
||||
throw new IllegalArgumentException("api config requires a 'baseUrl' option");
|
||||
}
|
||||
URI uri = parseHttpUrl(baseUrl);
|
||||
if (uri.getQuery() != null || uri.getFragment() != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'baseUrl' must not carry a query string or fragment");
|
||||
}
|
||||
|
||||
ApiAuthType authType = parseAuthType(trimmed(options.get(AUTH_TYPE_OPTION)));
|
||||
String headerName = trimmed(options.get(HEADER_NAME_OPTION));
|
||||
// Many APIs want a scheme before the token ("Authorization: Token abc",
|
||||
// "Authorization: DeepL-Auth-Key abc"). Without this a preset would have to make the
|
||||
// operator paste the scheme into the secret itself, which reads as a typo waiting to
|
||||
// happen.
|
||||
String headerPrefix = trimmed(options.get(HEADER_PREFIX_OPTION));
|
||||
String token = trimmed(options.get(TOKEN_OPTION));
|
||||
String username = trimmed(options.get(USERNAME_OPTION));
|
||||
String password = trimmed(options.get(PASSWORD_OPTION));
|
||||
|
||||
switch (authType) {
|
||||
case BEARER -> require(token, "api config authType 'BEARER' requires a 'token'");
|
||||
case HEADER -> {
|
||||
require(token, "api config authType 'HEADER' requires a 'token'");
|
||||
require(headerName, "api config authType 'HEADER' requires a 'headerName'");
|
||||
if (!ExternalApiHeaders.isValidName(headerName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'headerName' is not a valid HTTP header name: "
|
||||
+ headerName);
|
||||
}
|
||||
}
|
||||
case BASIC -> {
|
||||
require(username, "api config authType 'BASIC' requires a 'username'");
|
||||
require(password, "api config authType 'BASIC' requires a 'password'");
|
||||
}
|
||||
case TOKEN_LOGIN -> {
|
||||
/* validated by ApiTokenLogin.from below */
|
||||
}
|
||||
case NONE -> {
|
||||
/* nothing to check */
|
||||
}
|
||||
}
|
||||
|
||||
return new ApiConnectionSettings(
|
||||
stripTrailingSlash(baseUrl),
|
||||
authType,
|
||||
headerName,
|
||||
headerPrefix,
|
||||
token,
|
||||
username,
|
||||
password,
|
||||
parseHeaders(options.get(HEADERS_OPTION)),
|
||||
authType == ApiAuthType.TOKEN_LOGIN ? ApiTokenLogin.from(options) : null,
|
||||
parseResultUrlHosts(options.get(RESULT_URL_HOSTS_OPTION)),
|
||||
parseTimeout(options.get(TIMEOUT_SECONDS_OPTION)));
|
||||
}
|
||||
|
||||
/** The configured base as a URI; callers resolve step paths under it. */
|
||||
public URI baseUri() {
|
||||
return URI.create(baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of this connection's login for token-cache purposes. Includes the credentials, so
|
||||
* editing a password evicts the token cached under the old one rather than reusing it until it
|
||||
* expires.
|
||||
*/
|
||||
String tokenCacheKey() {
|
||||
return baseUrl + "|" + Objects.hash(tokenLogin);
|
||||
}
|
||||
|
||||
private static URI parseHttpUrl(String value) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(value);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException("api config 'baseUrl' is not a valid URL", e);
|
||||
}
|
||||
String scheme = uri.getScheme() == null ? null : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'baseUrl' must be an http(s) URL, e.g. https://api.example.com");
|
||||
}
|
||||
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||
throw new IllegalArgumentException("api config 'baseUrl' must include a host");
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private static ApiAuthType parseAuthType(String value) {
|
||||
if (value == null) {
|
||||
return ApiAuthType.NONE;
|
||||
}
|
||||
try {
|
||||
return ApiAuthType.valueOf(value.toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'authType' must be one of NONE, BEARER, BASIC, HEADER; got "
|
||||
+ value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Static headers sent on every call. Rejects anything auth-bearing to keep one auth path. */
|
||||
private static Map<String, String> parseHeaders(Object value) {
|
||||
if (value == null) {
|
||||
return Map.of();
|
||||
}
|
||||
if (!(value instanceof Map<?, ?> raw)) {
|
||||
throw new IllegalArgumentException("api config 'headers' must be an object");
|
||||
}
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : raw.entrySet()) {
|
||||
String name = trimmed(entry.getKey());
|
||||
String headerValue = entry.getValue() == null ? null : entry.getValue().toString();
|
||||
if (name == null) {
|
||||
continue;
|
||||
}
|
||||
if (!ExternalApiHeaders.isValidName(name)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'headers' has an invalid header name: " + name);
|
||||
}
|
||||
if (ExternalApiHeaders.isReserved(name)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'headers' must not set '"
|
||||
+ name
|
||||
+ "'; use 'authType' and 'token' instead");
|
||||
}
|
||||
if (headerValue == null || !ExternalApiHeaders.isValidValue(headerValue)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'headers' has an invalid value for '" + name + "'");
|
||||
}
|
||||
headers.put(name, headerValue);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts a result may be fetched from, beyond the connection's own. Declared by the operator
|
||||
* because the alternative - trusting the host named in the API's response - is an SSRF.
|
||||
*/
|
||||
private static Set<String> parseResultUrlHosts(Object value) {
|
||||
if (value == null) {
|
||||
return Set.of();
|
||||
}
|
||||
if (!(value instanceof java.util.List<?> list)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'resultUrlHosts' must be a list of hostnames");
|
||||
}
|
||||
Set<String> out = new java.util.LinkedHashSet<>();
|
||||
for (Object entry : list) {
|
||||
String host = trimmed(entry);
|
||||
if (host == null) {
|
||||
continue;
|
||||
}
|
||||
if (host.contains("/") || host.contains(":") || host.contains("*")) {
|
||||
// A URL, port or wildcard here would read as broader than it is; subdomains are
|
||||
// already covered by the "endsWith('.' + host)" rule at match time.
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'resultUrlHosts' takes bare hostnames, e.g."
|
||||
+ " cdn.vendor.com; got "
|
||||
+ host);
|
||||
}
|
||||
out.add(host.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int parseTimeout(Object value) {
|
||||
if (value == null) {
|
||||
return DEFAULT_TIMEOUT_SECONDS;
|
||||
}
|
||||
int seconds;
|
||||
try {
|
||||
seconds = Integer.parseInt(value.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("api config 'timeoutSeconds' must be a number");
|
||||
}
|
||||
if (seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'timeoutSeconds' must be between 1 and " + MAX_TIMEOUT_SECONDS);
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
|
||||
private static void require(String value, String message) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String value) {
|
||||
String out = value;
|
||||
while (out.endsWith("/")) {
|
||||
out = out.substring(0, out.length() - 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String trimmed(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/** Never prints the credentials, so an accidental log line cannot leak them. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ApiConnectionSettings[baseUrl="
|
||||
+ baseUrl
|
||||
+ ", authType="
|
||||
+ authType
|
||||
+ ", timeoutSeconds="
|
||||
+ timeoutSeconds
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
|
||||
|
||||
/**
|
||||
* The {@code API} connection schema, enforced when the config is saved: an http(s) base URL, a
|
||||
* coherent auth block, and a host that must not reach private addresses without the operator
|
||||
* opt-in.
|
||||
*
|
||||
* <p>The host check runs here so a bad connection fails in the form rather than mid-run. It is not
|
||||
* the only check - {@link ExternalApiCaller} re-checks before dispatch, because DNS can be
|
||||
* re-pointed at a private address long after save time (a check-then-use gap this validator alone
|
||||
* cannot close).
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiIntegrationValidator implements IntegrationConfigValidator {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
public IntegrationType type() {
|
||||
return IntegrationType.API;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Map<String, Object> config) {
|
||||
ApiConnectionSettings settings = ApiConnectionSettings.from(config);
|
||||
requirePublicHost(settings, applicationProperties, "API connection base URL");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by every integration type that dials an operator-supplied host, so they cannot drift
|
||||
* apart on what counts as reachable.
|
||||
*/
|
||||
static void requirePublicHost(
|
||||
ApiConnectionSettings settings,
|
||||
ApplicationProperties applicationProperties,
|
||||
String settingName) {
|
||||
// Block the cloud metadata service unconditionally - before the opt-in check. The private-
|
||||
// endpoint opt-in exists for on-prem services (RFC1918, an internal gateway), but the
|
||||
// metadata endpoint is never a real integration and reaching it is the highest-value SSRF:
|
||||
// it hands out the instance's own IAM credentials. So it stays blocked even when the
|
||||
// operator has allowed private endpoints.
|
||||
denyCloudMetadata(settings.baseUri(), settingName);
|
||||
try {
|
||||
S3Clients.validateEndpointHost(
|
||||
settings.baseUri(),
|
||||
applicationProperties.getPolicies().isAllowPrivateApiEndpoints(),
|
||||
settingName,
|
||||
"set policies.allowPrivateApiEndpoints=true to opt in (e.g. for an on-prem"
|
||||
+ " integration).");
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** AWS/GCP/Azure, Oracle and IBM metadata addresses; mirrors {@code SsrfProtectionService}. */
|
||||
private static final java.util.Set<String> CLOUD_METADATA_IPS =
|
||||
java.util.Set.of(
|
||||
"169.254.169.254", "169.254.169.253", "169.254.169.250", "fd00:ec2::254");
|
||||
|
||||
private static void denyCloudMetadata(java.net.URI uri, String settingName) {
|
||||
String host = uri.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
return; // a missing host is S3Clients' error to report, with its own message
|
||||
}
|
||||
java.net.InetAddress[] addresses;
|
||||
try {
|
||||
addresses = java.net.InetAddress.getAllByName(host);
|
||||
} catch (java.net.UnknownHostException e) {
|
||||
return; // an unresolvable host is likewise left to S3Clients to reject
|
||||
}
|
||||
for (java.net.InetAddress address : addresses) {
|
||||
String ip = normalise(address.getHostAddress());
|
||||
if (CLOUD_METADATA_IPS.stream().anyMatch(ip::startsWith)) {
|
||||
throw new IllegalArgumentException(
|
||||
settingName
|
||||
+ " host '"
|
||||
+ host
|
||||
+ "' resolves to the cloud metadata service ("
|
||||
+ ip
|
||||
+ "), which is never a valid integration target.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip an IPv4-mapped-IPv6 prefix and any zone id so the compare sees a bare address. */
|
||||
private static String normalise(String ip) {
|
||||
String out = ip;
|
||||
int zone = out.indexOf('%');
|
||||
if (zone >= 0) {
|
||||
out = out.substring(0, zone);
|
||||
}
|
||||
if (out.startsWith("::ffff:")) {
|
||||
out = out.substring(7);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Obtains and caches the short-lived tokens of {@link ApiAuthType#TOKEN_LOGIN} connections.
|
||||
*
|
||||
* <p>The step that uses a token is stateless and runs once per document, so without a cache a
|
||||
* hundred-document policy would perform a hundred logins - which many vendors rate-limit, and some
|
||||
* treat as suspicious. The cache is keyed on the connection's login identity (credentials included)
|
||||
* so that editing a password does not keep reusing the token bought with the old one.
|
||||
*
|
||||
* <p>Entries expire well inside the vendor's stated lifetime, and a 401 additionally evicts and
|
||||
* retries once ({@link ExternalApiCaller}), so a token that expires early - or is revoked - costs
|
||||
* one retry rather than a failed run.
|
||||
*/
|
||||
@Slf4j
|
||||
public class ApiTokenCache {
|
||||
|
||||
/** Bounded so a deployment with many connections cannot grow this without limit. */
|
||||
private static final int MAX_ENTRIES = 500;
|
||||
|
||||
private final Cache<String, String> tokens;
|
||||
private final HttpClient httpClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
ApiTokenCache(HttpClient httpClient, ObjectMapper objectMapper) {
|
||||
this.httpClient = httpClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.tokens =
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(MAX_ENTRIES)
|
||||
// Per-entry, because each connection states its own lifetime.
|
||||
.expireAfter(
|
||||
new com.github.benmanes.caffeine.cache.Expiry<String, String>() {
|
||||
@Override
|
||||
public long expireAfterCreate(
|
||||
String key, String value, long currentTime) {
|
||||
return ttlNanos(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long expireAfterUpdate(
|
||||
String key,
|
||||
String value,
|
||||
long currentTime,
|
||||
long currentDuration) {
|
||||
return ttlNanos(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long expireAfterRead(
|
||||
String key,
|
||||
String value,
|
||||
long currentTime,
|
||||
long currentDuration) {
|
||||
// Reading must not extend a token's life: the vendor's
|
||||
// clock is running regardless of how often we use it.
|
||||
return currentDuration;
|
||||
}
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
// The TTL travels in the key so the Expiry callbacks can see it without a second lookup.
|
||||
private static long ttlNanos(String key) {
|
||||
int seconds = Integer.parseInt(key.substring(key.lastIndexOf('#') + 1));
|
||||
return TimeUnit.SECONDS.toNanos(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection's current token, logging in if there is not a live one.
|
||||
*
|
||||
* @throws IOException if the login call fails or returns no token
|
||||
*/
|
||||
String token(ApiConnectionSettings settings) throws IOException {
|
||||
String key = cacheKey(settings);
|
||||
String cached = tokens.getIfPresent(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
String token = login(settings);
|
||||
tokens.put(key, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Drop the cached token, e.g. after a 401 says it is no longer accepted. */
|
||||
void invalidate(ApiConnectionSettings settings) {
|
||||
tokens.invalidate(cacheKey(settings));
|
||||
}
|
||||
|
||||
private static String cacheKey(ApiConnectionSettings settings) {
|
||||
return settings.tokenCacheKey() + "#" + settings.tokenLogin().tokenTtlSeconds();
|
||||
}
|
||||
|
||||
private String login(ApiConnectionSettings settings) throws IOException {
|
||||
ApiTokenLogin login = settings.tokenLogin();
|
||||
URI target = ExternalApiPaths.resolve(settings.baseUri(), login.loginPath());
|
||||
|
||||
HttpRequest.Builder request =
|
||||
HttpRequest.newBuilder(target)
|
||||
.timeout(Duration.ofSeconds(settings.timeoutSeconds()))
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.POST(
|
||||
HttpRequest.BodyPublishers.ofByteArray(
|
||||
objectMapper.writeValueAsBytes(login.loginBody())));
|
||||
login.loginHeaders().forEach(request::header);
|
||||
|
||||
ExternalApiCaller.Response response =
|
||||
ExternalApiCaller.send(httpClient, request.build(), target);
|
||||
if (!response.isSuccess()) {
|
||||
// Deliberately does not echo the body: a login failure response can repeat the
|
||||
// credentials back, and this message reaches the run log.
|
||||
throw new IOException(
|
||||
"Login to "
|
||||
+ target.getHost()
|
||||
+ login.loginPath()
|
||||
+ " returned HTTP "
|
||||
+ response.status());
|
||||
}
|
||||
try {
|
||||
String token = login.extractToken(response, objectMapper);
|
||||
log.debug("[external-api] obtained a token from {}", target.getHost());
|
||||
return token;
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IOException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The auth header for an authenticated call. */
|
||||
Map.Entry<String, String> authHeader(ApiConnectionSettings settings) throws IOException {
|
||||
return settings.tokenLogin().authHeader(token(settings));
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* How a connection turns credentials into a short-lived token.
|
||||
*
|
||||
* <p>Modelled on what real APIs actually do rather than on one vendor. The two axes that vary are
|
||||
* where the token comes back ({@code tokenResponseHeader} or {@code tokenResponseJsonPath}) and how
|
||||
* it is then presented ({@code tokenHeaderName} + {@code tokenPrefix}). That covers both ends of
|
||||
* the spectrum:
|
||||
*
|
||||
* <ul>
|
||||
* <li>ConsignO Cloud - {@code POST /auth/login} with {@code X-Client-Id}/{@code X-Client-Secret}
|
||||
* headers and a {@code {username, password, tenantId}} body, returning the token in the
|
||||
* {@code X-Auth-Token} <em>response header</em>, which is then sent back as {@code
|
||||
* X-Auth-Token}.
|
||||
* <li>OAuth2 client-credentials - a form or JSON post returning {@code {"access_token": ...}} in
|
||||
* the body, sent back as {@code Authorization: Bearer ...}.
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@code loginBody} and {@code loginHeaders} are stored as nested maps rather than a
|
||||
* pre-rendered JSON string so {@code SecretMasker} can recurse and mask the {@code password} /
|
||||
* {@code X-Client-Secret} entries inside them. A flat string would sail past it and hand the
|
||||
* password back in plaintext on every read of the connection.
|
||||
*/
|
||||
record ApiTokenLogin(
|
||||
String loginPath,
|
||||
Map<String, Object> loginBody,
|
||||
Map<String, String> loginHeaders,
|
||||
String tokenResponseHeader,
|
||||
String tokenResponseJsonPath,
|
||||
String tokenHeaderName,
|
||||
String tokenPrefix,
|
||||
int tokenTtlSeconds) {
|
||||
|
||||
static final String LOGIN_PATH_OPTION = "loginPath";
|
||||
static final String LOGIN_BODY_OPTION = "loginBody";
|
||||
static final String LOGIN_HEADERS_OPTION = "loginHeaders";
|
||||
static final String TOKEN_RESPONSE_HEADER_OPTION = "tokenResponseHeader";
|
||||
static final String TOKEN_RESPONSE_JSON_PATH_OPTION = "tokenResponseJsonPath";
|
||||
static final String TOKEN_HEADER_NAME_OPTION = "tokenHeaderName";
|
||||
static final String TOKEN_PREFIX_OPTION = "tokenPrefix";
|
||||
static final String TOKEN_TTL_SECONDS_OPTION = "tokenTtlSeconds";
|
||||
|
||||
/**
|
||||
* Conservative default. ConsignO's token lasts 30 minutes; caching for 25 leaves room for a
|
||||
* slow call to finish on a token that was still valid when it started. A cache that expired
|
||||
* exactly on the vendor's boundary would fail intermittently and look like a network fault.
|
||||
*/
|
||||
static final int DEFAULT_TOKEN_TTL_SECONDS = 1500;
|
||||
|
||||
private static final int MAX_TOKEN_TTL_SECONDS = 86400;
|
||||
|
||||
ApiTokenLogin {
|
||||
loginBody = loginBody == null ? Map.of() : Map.copyOf(loginBody);
|
||||
loginHeaders = loginHeaders == null ? Map.of() : Map.copyOf(loginHeaders);
|
||||
}
|
||||
|
||||
static ApiTokenLogin from(Map<String, Object> options) {
|
||||
String loginPath = trimmed(options.get(LOGIN_PATH_OPTION));
|
||||
if (loginPath == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config authType 'TOKEN_LOGIN' requires a 'loginPath', e.g. /auth/login");
|
||||
}
|
||||
String responseHeader = trimmed(options.get(TOKEN_RESPONSE_HEADER_OPTION));
|
||||
String responseJsonPath = trimmed(options.get(TOKEN_RESPONSE_JSON_PATH_OPTION));
|
||||
if ((responseHeader == null) == (responseJsonPath == null)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config authType 'TOKEN_LOGIN' needs exactly one of"
|
||||
+ " 'tokenResponseHeader' (e.g. X-Auth-Token) or"
|
||||
+ " 'tokenResponseJsonPath' (e.g. access_token) to say where the token"
|
||||
+ " comes back");
|
||||
}
|
||||
String tokenHeaderName = trimmed(options.get(TOKEN_HEADER_NAME_OPTION));
|
||||
if (tokenHeaderName == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config authType 'TOKEN_LOGIN' requires a 'tokenHeaderName' saying which"
|
||||
+ " header carries the token back, e.g. X-Auth-Token or Authorization");
|
||||
}
|
||||
if (!ExternalApiHeaders.isValidName(tokenHeaderName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'tokenHeaderName' is not a valid HTTP header name: "
|
||||
+ tokenHeaderName);
|
||||
}
|
||||
if (responseHeader != null && !ExternalApiHeaders.isValidName(responseHeader)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'tokenResponseHeader' is not a valid HTTP header name: "
|
||||
+ responseHeader);
|
||||
}
|
||||
|
||||
return new ApiTokenLogin(
|
||||
loginPath,
|
||||
nestedObject(options.get(LOGIN_BODY_OPTION), LOGIN_BODY_OPTION),
|
||||
loginHeaders(options.get(LOGIN_HEADERS_OPTION)),
|
||||
responseHeader,
|
||||
responseJsonPath,
|
||||
tokenHeaderName,
|
||||
trimmed(options.get(TOKEN_PREFIX_OPTION)) == null
|
||||
? ""
|
||||
: trimmed(options.get(TOKEN_PREFIX_OPTION)) + " ",
|
||||
ttl(options.get(TOKEN_TTL_SECONDS_OPTION)));
|
||||
}
|
||||
|
||||
/** Pull the token out of a login response. */
|
||||
String extractToken(ExternalApiCaller.Response response, ObjectMapper objectMapper) {
|
||||
if (tokenResponseHeader != null) {
|
||||
String value = response.header(tokenResponseHeader);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"Login succeeded but returned no '"
|
||||
+ tokenResponseHeader
|
||||
+ "' response header");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
JsonNode node = response.bodyAsJson(objectMapper);
|
||||
for (String segment : tokenResponseJsonPath.split("\\.")) {
|
||||
if (node == null) {
|
||||
break;
|
||||
}
|
||||
node = node.get(segment);
|
||||
}
|
||||
if (node == null || !node.isValueNode() || node.asString().isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"Login succeeded but its body had no token at '" + tokenResponseJsonPath + "'");
|
||||
}
|
||||
return node.asString();
|
||||
}
|
||||
|
||||
/** The header to send on an authenticated call. */
|
||||
Map.Entry<String, String> authHeader(String token) {
|
||||
return Map.entry(tokenHeaderName, tokenPrefix + token);
|
||||
}
|
||||
|
||||
private static Map<String, Object> nestedObject(Object value, String option) {
|
||||
if (value == null) {
|
||||
return Map.of();
|
||||
}
|
||||
if (!(value instanceof Map<?, ?> raw)) {
|
||||
throw new IllegalArgumentException("api config '" + option + "' must be an object");
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
raw.forEach((key, entry) -> out.put(String.valueOf(key), entry));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, String> loginHeaders(Object value) {
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
nestedObject(value, LOGIN_HEADERS_OPTION)
|
||||
.forEach(
|
||||
(name, entry) -> {
|
||||
String headerValue = entry == null ? null : entry.toString();
|
||||
if (!ExternalApiHeaders.isValidName(name)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'loginHeaders' has an invalid header name: "
|
||||
+ name);
|
||||
}
|
||||
if (headerValue == null
|
||||
|| !ExternalApiHeaders.isValidValue(headerValue)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'loginHeaders' has an invalid value for '"
|
||||
+ name
|
||||
+ "'");
|
||||
}
|
||||
out.put(name, headerValue);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int ttl(Object value) {
|
||||
if (value == null) {
|
||||
return DEFAULT_TOKEN_TTL_SECONDS;
|
||||
}
|
||||
int seconds;
|
||||
try {
|
||||
seconds = Integer.parseInt(value.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("api config 'tokenTtlSeconds' must be a number");
|
||||
}
|
||||
if (seconds < 1 || seconds > MAX_TOKEN_TTL_SECONDS) {
|
||||
throw new IllegalArgumentException(
|
||||
"api config 'tokenTtlSeconds' must be between 1 and " + MAX_TOKEN_TTL_SECONDS);
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
|
||||
private static String trimmed(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/** Never prints the login body or headers: both carry the credentials. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ApiTokenLogin[loginPath="
|
||||
+ loginPath
|
||||
+ ", tokenTtlSeconds="
|
||||
+ tokenTtlSeconds
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Calendar;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.proprietary.integration.purview.PdfSensitivityLabels;
|
||||
import stirling.software.proprietary.integration.purview.SensitivityLabel;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Everything Stirling already knows about the document and the run, as one JSON object.
|
||||
*
|
||||
* <p>An external API almost always wants more than the bytes: what the file is, what it was called,
|
||||
* whether it is already classified or labelled, and which policy sent it. All of that is in hand at
|
||||
* the moment of the call, so it is offered rather than left for the operator to re-derive - most
|
||||
* usefully the Purview label and the classifier's verdict, which turn a call-out into something the
|
||||
* receiving system can make a decision with.
|
||||
*
|
||||
* <p>The shape is also the namespace for placeholders (see {@link Placeholders}), so {@code
|
||||
* {{document.sha256}}} or {@code {{sensitivityLabel.name}}} in a field, path, or header resolves
|
||||
* against exactly what is documented here:
|
||||
*
|
||||
* <pre>
|
||||
* document.filename | .extension | .contentType | .sizeBytes | .sha256 | .base64
|
||||
* .pageCount | .encrypted | .title | .author | .subject | .keywords
|
||||
* .creator | .producer | .created | .modified
|
||||
* classification.* the classifier policy's verdict, when it has run
|
||||
* sensitivityLabel.labelId | .name | .siteId | .method | .protected
|
||||
* run.policyName | .runId | .timestamp
|
||||
* </pre>
|
||||
*
|
||||
* <p>Every field is best-effort: a non-PDF, an unparseable PDF, or an ad-hoc run with no policy
|
||||
* simply omits what it cannot know. Building the context must never be the reason a step fails.
|
||||
*/
|
||||
@Slf4j
|
||||
final class DocumentContext {
|
||||
|
||||
private DocumentContext() {}
|
||||
|
||||
static ObjectNode build(
|
||||
MultipartFile file,
|
||||
byte[] content,
|
||||
String policyName,
|
||||
String runId,
|
||||
ObjectMapper objectMapper) {
|
||||
ObjectNode root = objectMapper.createObjectNode();
|
||||
ObjectNode document = root.putObject("document");
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
document.put("filename", filename);
|
||||
document.put("extension", extensionOf(filename));
|
||||
document.put("contentType", file.getContentType());
|
||||
document.put("sizeBytes", content.length);
|
||||
document.put("sha256", sha256(content));
|
||||
// The bytes themselves, for steps that carry the document inside a JSON body
|
||||
// (an attachment field, a signing payload) rather than as multipart.
|
||||
document.put("base64", Base64.getEncoder().encodeToString(content));
|
||||
|
||||
if (looksLikePdf(content)) {
|
||||
addPdfFacts(document, root, content, objectMapper);
|
||||
}
|
||||
|
||||
ObjectNode run = root.putObject("run");
|
||||
run.put("policyName", policyName);
|
||||
run.put("runId", runId);
|
||||
run.put("timestamp", Instant.now().toString());
|
||||
return root;
|
||||
}
|
||||
|
||||
/** PDF-only facts. A document we cannot parse still gets the basics above. */
|
||||
private static void addPdfFacts(
|
||||
ObjectNode document, ObjectNode root, byte[] content, ObjectMapper objectMapper) {
|
||||
try (PDDocument pdf = Loader.loadPDF(content)) {
|
||||
document.put("pageCount", pdf.getNumberOfPages());
|
||||
document.put("encrypted", pdf.isEncrypted());
|
||||
|
||||
PDDocumentInformation info = pdf.getDocumentInformation();
|
||||
document.put("title", info.getTitle());
|
||||
document.put("author", info.getAuthor());
|
||||
document.put("subject", info.getSubject());
|
||||
document.put("keywords", info.getKeywords());
|
||||
document.put("creator", info.getCreator());
|
||||
document.put("producer", info.getProducer());
|
||||
document.put("created", toIso(info.getCreationDate()));
|
||||
document.put("modified", toIso(info.getModificationDate()));
|
||||
|
||||
addClassification(root, info, objectMapper);
|
||||
addSensitivityLabel(root, pdf);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
// An encrypted or malformed PDF is a normal thing to send to an external API; the
|
||||
// extra facts are a convenience, not a precondition.
|
||||
log.debug("Could not read PDF facts for the step context: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** The classifier policy's verdict, so a call-out can act on it without re-classifying. */
|
||||
private static void addClassification(
|
||||
ObjectNode root, PDDocumentInformation info, ObjectMapper objectMapper) {
|
||||
String raw = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY);
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JsonNode parsed = objectMapper.readTree(raw);
|
||||
root.set("classification", parsed);
|
||||
} catch (RuntimeException e) {
|
||||
// Written by another tool; if it is not JSON, pass it through as text rather than drop
|
||||
// it - the receiving system may still recognise it.
|
||||
root.put("classification", raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** The Purview label already on the document, if any. */
|
||||
private static void addSensitivityLabel(ObjectNode root, PDDocument pdf) {
|
||||
List<SensitivityLabel> labels = PdfSensitivityLabels.readAll(pdf);
|
||||
if (labels.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
SensitivityLabel label = labels.get(0);
|
||||
ObjectNode node = root.putObject("sensitivityLabel");
|
||||
node.put("labelId", label.labelId());
|
||||
node.put("name", label.name());
|
||||
node.put("siteId", label.siteId());
|
||||
node.put("method", label.method() == null ? null : label.method().name());
|
||||
node.put("protected", label.isProtected());
|
||||
}
|
||||
|
||||
/** Cheap check so a non-PDF never pays for a parse attempt. */
|
||||
private static boolean looksLikePdf(byte[] content) {
|
||||
return content.length > 4
|
||||
&& content[0] == '%'
|
||||
&& content[1] == 'P'
|
||||
&& content[2] == 'D'
|
||||
&& content[3] == 'F';
|
||||
}
|
||||
|
||||
/**
|
||||
* A content hash is the field external systems most often key on - dedupe, chain-of-custody,
|
||||
* "have I already scanned this" - and they cannot compute it without the bytes we are sending.
|
||||
*/
|
||||
private static String sha256(byte[] content) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 is required by the Java platform", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String toIso(Calendar calendar) {
|
||||
return calendar == null ? null : calendar.toInstant().toString();
|
||||
}
|
||||
|
||||
private static String extensionOf(String filename) {
|
||||
if (filename == null) {
|
||||
return null;
|
||||
}
|
||||
int dot = filename.lastIndexOf('.');
|
||||
return dot < 0 || dot == filename.length() - 1
|
||||
? null
|
||||
: filename.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.AutomationRunContext;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Posts the document flowing through a policy to a third-party HTTP API and folds the answer back
|
||||
* into the run.
|
||||
*
|
||||
* <p>This is the generic integration step: rather than a bespoke connector per vendor, an operator
|
||||
* defines an {@code API} connection (base URL + credentials) once and any policy can call a path
|
||||
* under it. The connection owns the host and the credentials; the step owns only the path and the
|
||||
* form fields, so a policy author can never aim the call somewhere else or read the secret.
|
||||
*
|
||||
* <p>Response handling is explicit rather than inferred, because the two useful behaviours destroy
|
||||
* different things when guessed wrong:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code report} (default) - the document continues untouched and the API's answer rides
|
||||
* along in {@link AiToolResponseHeaders#TOOL_REPORT}. For call-outs that inspect or notify. A
|
||||
* {@code requireTrue} field turns the answer into a gate: the named JSON verdict must be true
|
||||
* or the step fails, so a scanner's "not clean" actually stops the run.
|
||||
* <li>{@code replace} - the response body <em>becomes</em> the document. For call-outs that
|
||||
* transform. Fails loudly if the API returns JSON or an empty body, instead of silently
|
||||
* dropping the document from the pipeline.
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/integration")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Integrations", description = "Third-party integration steps.")
|
||||
public class ExternalApiCallController {
|
||||
|
||||
static final String MODE_REPORT = "report";
|
||||
static final String MODE_REPLACE = "replace";
|
||||
|
||||
/**
|
||||
* The report travels as an HTTP header, and Jetty caps a response header at 8KB by default. A
|
||||
* body larger than this is summarised rather than risking a header the container refuses to
|
||||
* write - which would fail the whole step over a merely verbose API.
|
||||
*/
|
||||
static final int MAX_REPORT_BODY_CHARS = 4096;
|
||||
|
||||
static final String BODY_MULTIPART = "multipart";
|
||||
static final String BODY_JSON = "json";
|
||||
static final String BODY_BINARY = "binary";
|
||||
|
||||
/** Field (multipart) and property (json) the auto-populated context is offered under. */
|
||||
static final String CONTEXT_FIELD = "stirlingContext";
|
||||
|
||||
private final ApiConnectionResolver connectionResolver;
|
||||
private final ExternalApiCaller caller;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@PostMapping(value = "/external-api-call", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Send the document to an external API",
|
||||
description =
|
||||
"Sends the document to a path under a stored API connection's base URL and"
|
||||
+ " either records the response as a step report or replaces the"
|
||||
+ " document with it. Fields, path and headers may reference"
|
||||
+ " {{document.*}}, {{classification.*}}, {{sensitivityLabel.*}} and"
|
||||
+ " {{run.*}}. Type:SISO")
|
||||
public ResponseEntity<Resource> call(
|
||||
@RequestParam("fileInput") MultipartFile fileInput,
|
||||
@RequestParam("connectionId") String connectionId,
|
||||
@RequestParam(value = "path", required = false) String path,
|
||||
@RequestParam(value = "method", defaultValue = "POST") String method,
|
||||
@RequestParam(value = "bodyMode", defaultValue = BODY_MULTIPART) String bodyMode,
|
||||
@RequestParam(value = "fileFieldName", defaultValue = "file") String fileFieldName,
|
||||
@RequestParam(value = "responseMode", defaultValue = MODE_REPORT) String responseMode,
|
||||
@RequestParam(value = "resultUrlPath", required = false) String resultUrlPath,
|
||||
@RequestParam(value = "resultUrlHeader", required = false) String resultUrlHeader,
|
||||
@RequestParam(value = "responseSelect", required = false) String responseSelect,
|
||||
@RequestParam(value = "requireTrue", required = false) String requireTrue,
|
||||
@RequestParam(value = "fields", required = false) String fields,
|
||||
@RequestParam(value = "bodyTemplate", required = false) String bodyTemplate,
|
||||
@RequestParam(value = "headers", required = false) String headers,
|
||||
@RequestParam(value = "includeContext", defaultValue = "false") boolean includeContext,
|
||||
@RequestParam(value = "includeFile", defaultValue = "true") boolean includeFile,
|
||||
@RequestHeader(value = InternalApiClient.POLICY_NAME_HEADER, required = false)
|
||||
String policyName,
|
||||
@RequestHeader(value = AutomationRunContext.RUN_ID_HEADER, required = false)
|
||||
String runId)
|
||||
throws IOException {
|
||||
|
||||
String mode = normalise(responseMode, MODE_REPORT, MODE_REPORT, MODE_REPLACE);
|
||||
String body = normalise(bodyMode, BODY_MULTIPART, BODY_MULTIPART, BODY_JSON, BODY_BINARY);
|
||||
String verb = parseMethod(method);
|
||||
|
||||
Long id = ApiConnectionResolver.connectionId(connectionId);
|
||||
if (id == null) {
|
||||
throw new IllegalArgumentException("'connectionId' is required");
|
||||
}
|
||||
ApiConnectionSettings settings = connectionResolver.resolve(id);
|
||||
|
||||
String filename = safeFileName(fileInput.getOriginalFilename());
|
||||
String contentType =
|
||||
fileInput.getContentType() == null
|
||||
? MediaType.APPLICATION_OCTET_STREAM_VALUE
|
||||
: fileInput.getContentType();
|
||||
byte[] content = fileInput.getBytes();
|
||||
|
||||
ObjectNode context =
|
||||
DocumentContext.build(fileInput, content, policyName, runId, objectMapper);
|
||||
|
||||
ExternalApiCaller.Response response =
|
||||
caller.dispatch(
|
||||
settings,
|
||||
verb,
|
||||
Placeholders.resolve(path, context, Placeholders.Escaping.URL_PATH),
|
||||
buildBody(
|
||||
body,
|
||||
bodyTemplate,
|
||||
includeFile,
|
||||
includeContext,
|
||||
context,
|
||||
fileFieldName,
|
||||
filename,
|
||||
contentType,
|
||||
content,
|
||||
resolveAll(parseJsonObject(fields, "fields"), context)),
|
||||
validatedHeaders(resolveAll(parseJsonObject(headers, "headers"), context)));
|
||||
|
||||
if (!response.isSuccess()) {
|
||||
// Fail the step: a policy that silently continued past a rejected call-out would
|
||||
// deliver documents the external system believes it never approved.
|
||||
throw new IOException(
|
||||
"External API returned HTTP " + response.status() + summarise(response));
|
||||
}
|
||||
|
||||
enforceVerdict(response, requireTrue);
|
||||
|
||||
return MODE_REPLACE.equals(mode)
|
||||
? replaceDocument(
|
||||
settings,
|
||||
response,
|
||||
filename,
|
||||
resultUrlPath,
|
||||
resultUrlHeader,
|
||||
responseSelect)
|
||||
: reportOnly(fileInput, filename, contentType, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the outbound body.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code multipart} - the file plus form fields, what most upload APIs expect.
|
||||
* <li>{@code json} - a JSON object of the fields, with the context merged in and the file
|
||||
* base64'd under {@code content}. For APIs that take a document as JSON, and for
|
||||
* notify-style call-outs (with {@code includeFile=false}) that want the facts only.
|
||||
* <li>{@code binary} - the raw bytes as the body. For APIs that want the file and nothing
|
||||
* else; fields would have nowhere to go, so they are refused rather than dropped.
|
||||
* </ul>
|
||||
*/
|
||||
private ExternalApiCaller.Body buildBody(
|
||||
String bodyMode,
|
||||
String bodyTemplate,
|
||||
boolean includeFile,
|
||||
boolean includeContext,
|
||||
ObjectNode context,
|
||||
String fileFieldName,
|
||||
String filename,
|
||||
String contentType,
|
||||
byte[] content,
|
||||
Map<String, String> fields)
|
||||
throws IOException {
|
||||
|
||||
if (bodyTemplate != null && !bodyTemplate.isBlank()) {
|
||||
return templatedBody(bodyTemplate, context, filename, contentType, content);
|
||||
}
|
||||
switch (bodyMode) {
|
||||
case BODY_BINARY -> {
|
||||
if (!fields.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"bodyMode 'binary' sends only the document, so 'fields' cannot be"
|
||||
+ " sent; use 'headers' instead, or bodyMode 'multipart'.");
|
||||
}
|
||||
if (!includeFile) {
|
||||
throw new IllegalArgumentException(
|
||||
"bodyMode 'binary' with includeFile=false would send an empty body");
|
||||
}
|
||||
return ExternalApiCaller.raw(contentType, content);
|
||||
}
|
||||
case BODY_JSON -> {
|
||||
ObjectNode json = objectMapper.createObjectNode();
|
||||
fields.forEach(json::put);
|
||||
if (includeContext) {
|
||||
json.setAll(context);
|
||||
}
|
||||
if (includeFile) {
|
||||
json.put("filename", filename);
|
||||
json.put("contentType", contentType);
|
||||
json.put("content", Base64.getEncoder().encodeToString(content));
|
||||
}
|
||||
return ExternalApiCaller.raw(
|
||||
MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(json));
|
||||
}
|
||||
default -> {
|
||||
Map<String, String> all = new LinkedHashMap<>(fields);
|
||||
if (includeContext) {
|
||||
all.put(CONTEXT_FIELD, objectMapper.writeValueAsString(context));
|
||||
}
|
||||
if (!includeFile) {
|
||||
// Fields-only multipart: a notify-style call-out that wants the facts, not
|
||||
// the document.
|
||||
MultipartBody body = new MultipartBody();
|
||||
body.addFields(all);
|
||||
return new ExternalApiCaller.Body(body.contentType(), body.build());
|
||||
}
|
||||
return ExternalApiCaller.multipart(
|
||||
fileFieldName, filename, contentType, content, all);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller-shaped JSON body: the template is resolved against the context, so an arbitrary
|
||||
* vendor payload can be expressed as config. {@code {{document.base64}}} carries the file
|
||||
* itself, which is how APIs that take a document nested inside a JSON document are reached.
|
||||
*
|
||||
* <p>The base64 is added to a copy of the context rather than the context proper: it is the
|
||||
* size of the file, and {@code stirlingContext} must not silently grow by a whole document.
|
||||
*/
|
||||
private ExternalApiCaller.Body templatedBody(
|
||||
String bodyTemplate,
|
||||
ObjectNode context,
|
||||
String filename,
|
||||
String contentType,
|
||||
byte[] content)
|
||||
throws IOException {
|
||||
JsonNode template;
|
||||
try {
|
||||
template = objectMapper.readTree(bodyTemplate);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("api step 'bodyTemplate' must be valid JSON", e);
|
||||
}
|
||||
ObjectNode withFile = context.deepCopy();
|
||||
ObjectNode document = (ObjectNode) withFile.get("document");
|
||||
if (document != null) {
|
||||
document.put("base64", Base64.getEncoder().encodeToString(content));
|
||||
document.put("safeFilename", filename);
|
||||
document.put("resolvedContentType", contentType);
|
||||
}
|
||||
JsonNode resolved = Placeholders.resolveTree(template, withFile);
|
||||
return ExternalApiCaller.raw(
|
||||
MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(resolved));
|
||||
}
|
||||
|
||||
/** Resolve every value's placeholders against the context. */
|
||||
private Map<String, String> resolveAll(Map<String, String> values, ObjectNode context) {
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
values.forEach(
|
||||
(key, value) ->
|
||||
out.put(
|
||||
key,
|
||||
Placeholders.resolve(value, context, Placeholders.Escaping.NONE)));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Per-step headers, held to the same rules as a connection's static headers. */
|
||||
private Map<String, String> validatedHeaders(Map<String, String> headers) {
|
||||
headers.forEach(
|
||||
(name, value) -> {
|
||||
if (!ExternalApiHeaders.isValidName(name)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'headers' has an invalid header name: " + name);
|
||||
}
|
||||
if (ExternalApiHeaders.isReserved(name)) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'headers' must not set '"
|
||||
+ name
|
||||
+ "'; it is set by the connection or the client");
|
||||
}
|
||||
if (!ExternalApiHeaders.isValidValue(value)) {
|
||||
// A resolved placeholder could carry a newline out of document metadata.
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'headers' has an invalid value for '" + name + "'");
|
||||
}
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static String parseMethod(String method) {
|
||||
String verb = method == null ? "POST" : method.trim().toUpperCase(Locale.ROOT);
|
||||
// Only the verbs that carry a body; GET/DELETE would silently drop the document.
|
||||
if (!List.of("POST", "PUT", "PATCH").contains(verb)) {
|
||||
throw new IllegalArgumentException(
|
||||
"'method' must be POST, PUT or PATCH; got " + method);
|
||||
}
|
||||
return verb;
|
||||
}
|
||||
|
||||
private static String normalise(String value, String fallback, String... allowed) {
|
||||
String out =
|
||||
value == null || value.isBlank() ? fallback : value.trim().toLowerCase(Locale.ROOT);
|
||||
if (!List.of(allowed).contains(out)) {
|
||||
throw new IllegalArgumentException(
|
||||
"must be one of " + String.join(", ", allowed) + "; got " + value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the response into the document that continues down the pipeline.
|
||||
*
|
||||
* <p>Three shapes of answer are accepted, because real APIs use all three: the document inline,
|
||||
* a URL to fetch it from, or an archive to pick it out of. Anything else fails the step rather
|
||||
* than putting a non-document into the pipeline for a later step to trip over.
|
||||
*/
|
||||
private ResponseEntity<Resource> replaceDocument(
|
||||
ApiConnectionSettings settings,
|
||||
ExternalApiCaller.Response response,
|
||||
String requestFilename,
|
||||
String resultUrlPath,
|
||||
String resultUrlHeader,
|
||||
String responseSelect)
|
||||
throws IOException {
|
||||
|
||||
ExternalApiCaller.Response payload = response;
|
||||
boolean followed = false;
|
||||
String url = resultUrl(response, resultUrlPath, resultUrlHeader);
|
||||
if (url != null) {
|
||||
// The URL came out of the response, so ResultUrls decides whether it may be fetched.
|
||||
payload =
|
||||
caller.getResult(
|
||||
settings, ResultUrls.validate(settings, url, applicationProperties));
|
||||
followed = true;
|
||||
if (!payload.isSuccess()) {
|
||||
throw new IOException(
|
||||
"Fetching the API's result URL returned HTTP " + payload.status());
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.body().length == 0) {
|
||||
throw new IOException(
|
||||
"External API returned an empty body, so there is no document to replace with;"
|
||||
+ " use responseMode=report to keep the original.");
|
||||
}
|
||||
if (payload.isJson() && !followed) {
|
||||
throw new IOException(
|
||||
"External API returned JSON, which cannot replace the document. Use"
|
||||
+ " responseMode=report to keep the original and record the answer, or"
|
||||
+ " set resultUrlPath if the JSON points at the document.");
|
||||
}
|
||||
|
||||
String filename = ResultFiles.nameFor(payload, requestFilename);
|
||||
Resource result = ResultFiles.asResource(payload.body(), filename);
|
||||
|
||||
if (ResultFiles.isArchive(result)) {
|
||||
if (responseSelect == null || responseSelect.isBlank()) {
|
||||
// Handing a .zip to a step that expects a PDF fails later and more obscurely.
|
||||
throw new IOException(
|
||||
"External API returned an archive; set 'responseSelect' (e.g. *.pdf, or an"
|
||||
+ " index) to say which entry becomes the document.");
|
||||
}
|
||||
result = ResultFiles.selectFromArchive(result, responseSelect, tempFileManager);
|
||||
filename = result.getFilename();
|
||||
} else if (responseSelect != null && !responseSelect.isBlank()) {
|
||||
throw new IOException(
|
||||
"'responseSelect' was set but the API returned a single file, not an archive");
|
||||
}
|
||||
|
||||
MediaType type =
|
||||
payload.contentType() == null || ResultFiles.isArchiveName(filename)
|
||||
? MediaType.APPLICATION_OCTET_STREAM
|
||||
: MediaType.parseMediaType(payload.contentType().split(";")[0].trim());
|
||||
return ResponseEntity.ok()
|
||||
.contentType(type)
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"")
|
||||
.body(result);
|
||||
}
|
||||
|
||||
/** The result URL the API pointed at, from the body or a header; null when neither is set. */
|
||||
private String resultUrl(
|
||||
ExternalApiCaller.Response response, String resultUrlPath, String resultUrlHeader) {
|
||||
if (resultUrlHeader != null && !resultUrlHeader.isBlank()) {
|
||||
String value = response.header(resultUrlHeader.trim());
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"'resultUrlHeader' names '"
|
||||
+ resultUrlHeader
|
||||
+ "' but the response had no such header");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (resultUrlPath == null || resultUrlPath.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode node = response.bodyAsJson(objectMapper);
|
||||
for (String segment : resultUrlPath.trim().split("\\.")) {
|
||||
if (node == null) {
|
||||
break;
|
||||
}
|
||||
node = node.get(segment);
|
||||
}
|
||||
if (node == null || !node.isValueNode() || node.asString().isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"'resultUrlPath' found no URL at '" + resultUrlPath + "' in the response");
|
||||
}
|
||||
return node.asString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate the run on a boolean verdict in the API's JSON answer (e.g. Cloudmersive's {@code
|
||||
* CleanResult}). When {@code requireTrue} names a field - dotted for a nested one - that field
|
||||
* must be JSON {@code true}, or the step fails so the document is parked rather than delivered.
|
||||
* Fail-closed: a missing field, a non-boolean, a false, or a non-JSON body all stop the run.
|
||||
* This is what makes a scanner's "not clean" actually stop the pipeline.
|
||||
*/
|
||||
private void enforceVerdict(ExternalApiCaller.Response response, String requireTrue)
|
||||
throws IOException {
|
||||
if (requireTrue == null || requireTrue.isBlank()) {
|
||||
return;
|
||||
}
|
||||
JsonNode node = response.isJson() ? response.bodyAsJson(objectMapper) : null;
|
||||
for (String segment : requireTrue.trim().split("\\.")) {
|
||||
if (node == null) {
|
||||
break;
|
||||
}
|
||||
node = node.get(segment);
|
||||
}
|
||||
if (node == null || !node.asBoolean(false)) {
|
||||
throw new IOException(
|
||||
"External API verdict '"
|
||||
+ requireTrue.trim()
|
||||
+ "' was not true"
|
||||
+ summarise(response)
|
||||
+ "; the document was not approved, so the run was stopped.");
|
||||
}
|
||||
}
|
||||
|
||||
/** The document passes through; the API's answer rides in the report header. */
|
||||
private ResponseEntity<Resource> reportOnly(
|
||||
MultipartFile fileInput,
|
||||
String filename,
|
||||
String contentType,
|
||||
ExternalApiCaller.Response response)
|
||||
throws IOException {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"")
|
||||
.header(AiToolResponseHeaders.TOOL_REPORT, buildReport(response))
|
||||
.body(new ByteArrayResource(fileInput.getBytes()));
|
||||
}
|
||||
|
||||
/** A JSON object describing the call, small enough to survive as a header. */
|
||||
private String buildReport(ExternalApiCaller.Response response) {
|
||||
ObjectNode report = objectMapper.createObjectNode();
|
||||
report.put("status", response.status());
|
||||
report.put("contentType", response.contentType());
|
||||
if (response.isJson()) {
|
||||
try {
|
||||
JsonNode parsed = objectMapper.readTree(response.bodyAsText());
|
||||
String rendered = objectMapper.writeValueAsString(parsed);
|
||||
if (rendered.length() <= MAX_REPORT_BODY_CHARS) {
|
||||
report.set("body", parsed);
|
||||
} else {
|
||||
report.put("bodyTruncated", true);
|
||||
report.put("body", rendered.substring(0, MAX_REPORT_BODY_CHARS));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Content-Type said JSON but the body is not; keep the step alive and say so.
|
||||
report.put("bodyParseError", e.getMessage());
|
||||
report.put("body", truncate(response.bodyAsText()));
|
||||
}
|
||||
} else {
|
||||
report.put("bodyBytes", response.body().length);
|
||||
}
|
||||
return objectMapper.writeValueAsString(report);
|
||||
}
|
||||
|
||||
/** A JSON object of string values, e.g. {@code {"policy":"strict"}}. */
|
||||
private Map<String, String> parseJsonObject(String json, String what) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> raw;
|
||||
try {
|
||||
raw =
|
||||
objectMapper.readValue(
|
||||
json, new TypeReference<LinkedHashMap<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("api step '" + what + "' must be a JSON object", e);
|
||||
}
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
raw.forEach((key, value) -> out.put(key, value == null ? "" : value.toString()));
|
||||
return out;
|
||||
}
|
||||
|
||||
private String summarise(ExternalApiCaller.Response response) {
|
||||
String text = truncate(response.bodyAsText());
|
||||
return text.isBlank() ? "" : ": " + text;
|
||||
}
|
||||
|
||||
private static String truncate(String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
String oneLine = text.replaceAll("\\s+", " ").trim();
|
||||
return oneLine.length() <= MAX_REPORT_BODY_CHARS
|
||||
? oneLine
|
||||
: oneLine.substring(0, MAX_REPORT_BODY_CHARS) + "…";
|
||||
}
|
||||
|
||||
private static String safeFileName(String originalFilename) {
|
||||
String name = Filenames.toSimpleFileName(originalFilename);
|
||||
return (name == null || name.isBlank()) ? "document" : name;
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
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.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Performs the outbound call for an {@code API} connection.
|
||||
*
|
||||
* <p>Follows the established self-hosted outbound pattern (JDK {@link HttpClient}; see {@code
|
||||
* AccountLinkClient}): the client is injectable so tests can drive a real local server without
|
||||
* reaching the network.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ExternalApiCaller {
|
||||
|
||||
/**
|
||||
* Cap on a response we will read into memory. An external API returning something enormous is a
|
||||
* misconfiguration, and without a cap it would be a trivial way to OOM the server.
|
||||
*/
|
||||
static final int MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApiTokenCache tokenCache;
|
||||
|
||||
@Autowired
|
||||
public ExternalApiCaller(
|
||||
ApplicationProperties applicationProperties, ObjectMapper objectMapper) {
|
||||
this(
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
// Following a redirect would re-target the request at a host the base URL
|
||||
// never authorised, undoing ExternalApiPaths. Let the caller see the 3xx.
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.build(),
|
||||
applicationProperties,
|
||||
objectMapper);
|
||||
}
|
||||
|
||||
ExternalApiCaller(
|
||||
HttpClient httpClient,
|
||||
ApplicationProperties applicationProperties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.httpClient = httpClient;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.tokenCache = new ApiTokenCache(httpClient, objectMapper);
|
||||
}
|
||||
|
||||
/** What the external API sent back, before the step decides what to do with it. */
|
||||
public record Response(
|
||||
int status, String contentType, byte[] body, Map<String, String> headers) {
|
||||
|
||||
public Response {
|
||||
headers = headers == null ? Map.of() : Map.copyOf(headers);
|
||||
}
|
||||
|
||||
/** A response header by name, case-insensitively; null when absent. */
|
||||
public String header(String name) {
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase(name)) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonNode bodyAsJson(ObjectMapper objectMapper) {
|
||||
try {
|
||||
return objectMapper.readTree(bodyAsText());
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return status >= 200 && status < 300;
|
||||
}
|
||||
|
||||
public boolean isJson() {
|
||||
return contentType != null && contentType.toLowerCase().contains("json");
|
||||
}
|
||||
|
||||
public String bodyAsText() {
|
||||
return new String(body, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a document to {@code path} under the connection's base URL as multipart/form-data.
|
||||
*
|
||||
* @throws IOException on transport failure or an oversized response
|
||||
*/
|
||||
public Response postFile(
|
||||
ApiConnectionSettings settings,
|
||||
String path,
|
||||
String fileFieldName,
|
||||
String filename,
|
||||
String fileContentType,
|
||||
byte[] content,
|
||||
Map<String, String> fields)
|
||||
throws IOException {
|
||||
return dispatch(
|
||||
settings,
|
||||
"POST",
|
||||
path,
|
||||
multipart(fileFieldName, filename, fileContentType, content, fields),
|
||||
Map.of());
|
||||
}
|
||||
|
||||
/** A request body plus the Content-Type that describes it. */
|
||||
record Body(String contentType, HttpRequest.BodyPublisher publisher) {}
|
||||
|
||||
static Body multipart(
|
||||
String fileFieldName,
|
||||
String filename,
|
||||
String fileContentType,
|
||||
byte[] content,
|
||||
Map<String, String> fields)
|
||||
throws IOException {
|
||||
MultipartBody body = new MultipartBody();
|
||||
body.addFields(fields);
|
||||
body.addFile(fileFieldName, filename, fileContentType, content);
|
||||
return new Body(body.contentType(), body.build());
|
||||
}
|
||||
|
||||
/** A body of caller-built bytes, e.g. a JSON document or the raw file. */
|
||||
static Body raw(String contentType, byte[] content) {
|
||||
return new Body(contentType, HttpRequest.BodyPublishers.ofByteArray(content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send {@code body} to {@code path} under the connection's base URL.
|
||||
*
|
||||
* @param method POST, PUT or PATCH - the verbs that carry a body
|
||||
* @param extraHeaders per-step headers, already validated by the caller
|
||||
*/
|
||||
public Response dispatch(
|
||||
ApiConnectionSettings settings,
|
||||
String method,
|
||||
String path,
|
||||
Body body,
|
||||
Map<String, String> extraHeaders)
|
||||
throws IOException {
|
||||
|
||||
URI target = ExternalApiPaths.resolve(settings.baseUri(), path);
|
||||
// Re-check at dispatch: save-time validation cannot see a DNS record re-pointed at a
|
||||
// private address afterwards.
|
||||
ApiIntegrationValidator.requirePublicHost(
|
||||
settings, applicationProperties, "API connection base URL");
|
||||
|
||||
Response response = attempt(settings, method, target, body, extraHeaders);
|
||||
if (response.status() == 401 && settings.authType() == ApiAuthType.TOKEN_LOGIN) {
|
||||
// The cached token was rejected - expired early, or revoked. One fresh login and
|
||||
// one retry; if that also 401s the credentials are wrong and the step says so.
|
||||
log.debug("[external-api] token rejected by {}; re-authenticating", target.getHost());
|
||||
tokenCache.invalidate(settings);
|
||||
response = attempt(settings, method, target, body, extraHeaders);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private Response attempt(
|
||||
ApiConnectionSettings settings,
|
||||
String method,
|
||||
URI target,
|
||||
Body body,
|
||||
Map<String, String> extraHeaders)
|
||||
throws IOException {
|
||||
HttpRequest.Builder request =
|
||||
HttpRequest.newBuilder(target)
|
||||
.timeout(Duration.ofSeconds(settings.timeoutSeconds()))
|
||||
.header("Content-Type", body.contentType())
|
||||
.method(method, body.publisher());
|
||||
applyHeaders(request, settings);
|
||||
// Step headers last so a step can override a connection default, but never the auth
|
||||
// header: ExternalApiHeaders rejects reserved names before we get here.
|
||||
extraHeaders.forEach(request::header);
|
||||
return send(httpClient, request.build(), target);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET an absolute result URL the API pointed us at.
|
||||
*
|
||||
* <p>Takes a {@link URI} rather than a string so it cannot be called with something unchecked:
|
||||
* the only way to obtain one is {@link ResultUrls#validate}, which is where the host allowlist
|
||||
* lives. Credentials are deliberately not sent - the URL is usually a presigned link on another
|
||||
* host, and forwarding the connection's token there would leak it to a third party.
|
||||
*/
|
||||
public Response getResult(ApiConnectionSettings settings, URI target) throws IOException {
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder(target)
|
||||
.timeout(Duration.ofSeconds(settings.timeoutSeconds()))
|
||||
.GET()
|
||||
.build();
|
||||
return send(httpClient, request, target);
|
||||
}
|
||||
|
||||
/** GET {@code path} under the connection's base URL. */
|
||||
public Response get(ApiConnectionSettings settings, String path) throws IOException {
|
||||
URI target = ExternalApiPaths.resolve(settings.baseUri(), path);
|
||||
ApiIntegrationValidator.requirePublicHost(
|
||||
settings, applicationProperties, "API connection base URL");
|
||||
|
||||
HttpRequest.Builder request =
|
||||
HttpRequest.newBuilder(target)
|
||||
.timeout(Duration.ofSeconds(settings.timeoutSeconds()))
|
||||
.GET();
|
||||
applyHeaders(request, settings);
|
||||
return send(httpClient, request.build(), target);
|
||||
}
|
||||
|
||||
static Response send(HttpClient httpClient, HttpRequest request, URI target)
|
||||
throws IOException {
|
||||
HttpResponse<byte[]> response;
|
||||
try {
|
||||
response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted calling " + safeTarget(target), e);
|
||||
} catch (IOException e) {
|
||||
// The message can carry the host but never the credentials, which live in headers.
|
||||
throw new IOException(
|
||||
"Failed to call " + safeTarget(target) + ": " + e.getMessage(), e);
|
||||
}
|
||||
byte[] body = response.body() == null ? new byte[0] : response.body();
|
||||
if (body.length > MAX_RESPONSE_BYTES) {
|
||||
throw new IOException(
|
||||
"Response from "
|
||||
+ safeTarget(target)
|
||||
+ " exceeds the "
|
||||
+ MAX_RESPONSE_BYTES
|
||||
+ " byte limit");
|
||||
}
|
||||
String contentType = response.headers().firstValue("content-type").orElse(null);
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
response.headers()
|
||||
.map()
|
||||
.forEach((name, values) -> headers.put(name, String.join(", ", values)));
|
||||
log.debug("[external-api] {} -> HTTP {}", safeTarget(target), response.statusCode());
|
||||
return new Response(response.statusCode(), contentType, body, headers);
|
||||
}
|
||||
|
||||
private void applyHeaders(HttpRequest.Builder request, ApiConnectionSettings settings)
|
||||
throws IOException {
|
||||
settings.headers().forEach(request::header);
|
||||
switch (settings.authType()) {
|
||||
case BEARER -> request.header("Authorization", "Bearer " + settings.token());
|
||||
case HEADER ->
|
||||
request.header(
|
||||
settings.headerName(),
|
||||
settings.headerPrefix() == null
|
||||
? settings.token()
|
||||
: settings.headerPrefix() + " " + settings.token());
|
||||
case BASIC ->
|
||||
request.header(
|
||||
"Authorization",
|
||||
"Basic "
|
||||
+ Base64.getEncoder()
|
||||
.encodeToString(
|
||||
(settings.username()
|
||||
+ ":"
|
||||
+ settings.password())
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
case TOKEN_LOGIN -> {
|
||||
Map.Entry<String, String> auth = tokenCache.authHeader(settings);
|
||||
request.header(auth.getKey(), auth.getValue());
|
||||
}
|
||||
case NONE -> {
|
||||
/* no credentials */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheme, host and path only: a query string could carry a token an operator put there. */
|
||||
private static String safeTarget(URI target) {
|
||||
return target.getScheme() + "://" + target.getAuthority() + target.getPath();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Validation for operator-supplied HTTP header names and values.
|
||||
*
|
||||
* <p>Header values reach the wire verbatim, so a value carrying CR/LF could splice extra headers -
|
||||
* or a whole second request - into the stream. Names and values are therefore checked against the
|
||||
* RFC 7230 grammar rather than trusted.
|
||||
*/
|
||||
public final class ExternalApiHeaders {
|
||||
|
||||
/**
|
||||
* Headers a connection may not set as a static header. Authentication has exactly one path
|
||||
* ({@code authType} + {@code token}) so credentials cannot be smuggled in as a "static" header
|
||||
* that bypasses the auth validation; the rest are framing headers owned by the HTTP client,
|
||||
* where a caller-set value would contradict the body actually sent.
|
||||
*/
|
||||
private static final Set<String> RESERVED =
|
||||
Set.of(
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"upgrade",
|
||||
"expect");
|
||||
|
||||
private ExternalApiHeaders() {}
|
||||
|
||||
/** RFC 7230 {@code token}: the only characters legal in a header name. */
|
||||
public static boolean isValidName(String name) {
|
||||
if (name == null || name.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
if (!isTokenChar(name.charAt(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Visible ASCII, space and horizontal tab. Excludes CR/LF and NUL, which would inject. */
|
||||
public static boolean isValidValue(String value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
boolean printable = c >= 0x20 && c <= 0x7E;
|
||||
if (!printable && c != '\t') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean isReserved(String name) {
|
||||
return name != null && RESERVED.contains(name.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
private static boolean isTokenChar(char c) {
|
||||
return (c >= 'a' && c <= 'z')
|
||||
|| (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9')
|
||||
|| "!#$%&'*+-.^_`|~".indexOf(c) >= 0;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Resolves a step-supplied relative path under a connection's operator-set base URL.
|
||||
*
|
||||
* <p>This is the control that keeps the external-API step from being an SSRF primitive. The base
|
||||
* URL comes from an {@code IntegrationConfig} only someone with manage rights can edit; the path
|
||||
* comes from a pipeline step, which is a far weaker trust boundary. Everything here exists to
|
||||
* guarantee that a path can address a resource <em>under</em> the base and nothing else.
|
||||
*
|
||||
* <p>{@link URI#resolve} is deliberately not used: resolving the protocol-relative {@code
|
||||
* //evil.example} against {@code https://api.example.com/v1} yields {@code https://evil.example},
|
||||
* silently changing host. Instead the path is screened, appended textually, normalised, and then
|
||||
* the result is re-checked against the base - so a miss in the screen is still caught by the check.
|
||||
*/
|
||||
public final class ExternalApiPaths {
|
||||
|
||||
private ExternalApiPaths() {}
|
||||
|
||||
/**
|
||||
* @param base the connection's base URL, already validated as http(s) with a host
|
||||
* @param path a relative path, optionally with a query string; blank means the base itself
|
||||
* @throws IllegalArgumentException if the path is absolute, escapes the base, or carries
|
||||
* characters that could split the request line
|
||||
*/
|
||||
public static URI resolve(URI base, String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return base;
|
||||
}
|
||||
String candidate = path.trim();
|
||||
screen(candidate);
|
||||
|
||||
if (!candidate.startsWith("/")) {
|
||||
candidate = "/" + candidate;
|
||||
}
|
||||
|
||||
URI resolved;
|
||||
try {
|
||||
resolved = new URI(base + candidate).normalize();
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'path' is not a valid URL path: " + path, e);
|
||||
}
|
||||
requireSameOrigin(base, resolved, path);
|
||||
requireUnderBasePath(base, resolved, path);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Reject the shapes that could retarget the request before it is even assembled. */
|
||||
private static void screen(String path) {
|
||||
if (path.contains("://") || path.startsWith("//")) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'path' must be relative to the connection's base URL, not an"
|
||||
+ " absolute or protocol-relative URL: "
|
||||
+ path);
|
||||
}
|
||||
for (int i = 0; i < path.length(); i++) {
|
||||
char c = path.charAt(i);
|
||||
// Control characters and spaces can split the request line; a backslash is normalised
|
||||
// to '/' by some servers and would sidestep the traversal check below.
|
||||
if (c <= 0x20 || c == 0x7F || c == '\\') {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'path' contains an illegal character: " + path);
|
||||
}
|
||||
}
|
||||
if (path.indexOf('#') >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'path' must not contain a fragment: " + path);
|
||||
}
|
||||
// Percent-encoded dots would survive the normalise() below and be decoded by the target, so
|
||||
// a traversal must not be smuggled past us in encoded form.
|
||||
//
|
||||
// Only dots are rejected. An encoded slash or backslash is legitimate: Placeholders encodes
|
||||
// substituted values, so a filename containing '/' arrives here as %2F, where it is data
|
||||
// inside one segment rather than structure. Rejecting those would refuse ordinary filenames
|
||||
// while doing nothing for traversal, which needs the dots.
|
||||
String lower = path.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("%2e")) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'path' must not percent-encode dots: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSameOrigin(URI base, URI resolved, String original) {
|
||||
boolean sameOrigin =
|
||||
equalsIgnoreCase(base.getScheme(), resolved.getScheme())
|
||||
&& equalsIgnoreCase(base.getHost(), resolved.getHost())
|
||||
&& base.getPort() == resolved.getPort()
|
||||
&& resolved.getUserInfo() == null;
|
||||
if (!sameOrigin) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'path' would change the target host; it must stay under the"
|
||||
+ " connection's base URL: "
|
||||
+ original);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireUnderBasePath(URI base, URI resolved, String original) {
|
||||
String basePath = base.getPath() == null ? "" : base.getPath();
|
||||
String resolvedPath = resolved.getPath() == null ? "" : resolved.getPath();
|
||||
// The base URL has its trailing slash stripped at parse time, so a base path of "/v1"
|
||||
// must match "/v1" exactly or be followed by a separator - never "/v1betray".
|
||||
boolean under =
|
||||
basePath.isEmpty()
|
||||
|| resolvedPath.equals(basePath)
|
||||
|| resolvedPath.startsWith(basePath + "/");
|
||||
if (!under) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step 'path' escapes the connection's base path: " + original);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean equalsIgnoreCase(String a, String b) {
|
||||
return a == null ? b == null : a.equalsIgnoreCase(b);
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.policy.engine.PipelineStepValidator;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
|
||||
/**
|
||||
* Authorization-checks the {@code connectionId} of any integration step, on the request thread.
|
||||
*
|
||||
* <p>This is what stops an integration step being a confused deputy. A step names a connection by
|
||||
* id, and the worker thread that runs it has no principal - so {@link ApiConnectionResolver} lets
|
||||
* the lookup through unchecked there, exactly as the S3 resolver does. Without this validator a
|
||||
* caller could put any id in a step and have the server dial that tenant's endpoint with that
|
||||
* tenant's stored credentials. Resolving here, while the caller is still on the thread, forces the
|
||||
* ownership check to run.
|
||||
*
|
||||
* <p>Registered as a {@link PipelineStepValidator} so both entry points cover it: save-time
|
||||
* validation of a stored policy, and {@code PolicyController}'s ad-hoc gate.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class IntegrationStepValidator implements PipelineStepValidator {
|
||||
|
||||
static final String CONNECTION_ID_PARAM = "connectionId";
|
||||
private static final String INTEGRATION_PREFIX = "/api/v1/integration/";
|
||||
|
||||
/**
|
||||
* Which connection type each integration step dereferences. A step under {@link
|
||||
* #INTEGRATION_PREFIX} that is absent here is rejected rather than waved through, so a new
|
||||
* endpoint cannot quietly skip this check by forgetting to register.
|
||||
*/
|
||||
private static final Map<String, IntegrationType> STEP_CONNECTION_TYPES =
|
||||
Map.of(
|
||||
"/api/v1/integration/external-api-call", IntegrationType.API,
|
||||
"/api/v1/integration/purview-apply-label", IntegrationType.PURVIEW,
|
||||
"/api/v1/integration/purview-read-label", IntegrationType.PURVIEW,
|
||||
"/api/v1/integration/consigno-submit", IntegrationType.CONSIGNO,
|
||||
"/api/v1/integration/consigno-fetch-signed", IntegrationType.CONSIGNO);
|
||||
|
||||
private final ApiConnectionResolver connectionResolver;
|
||||
|
||||
@Override
|
||||
public void validate(PipelineStep step) {
|
||||
String operation = step.operation();
|
||||
if (operation == null || !operation.startsWith(INTEGRATION_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
IntegrationType type = STEP_CONNECTION_TYPES.get(operation);
|
||||
if (type == null) {
|
||||
throw new IllegalArgumentException("unknown integration step: " + operation);
|
||||
}
|
||||
Long connectionId =
|
||||
ApiConnectionResolver.connectionId(step.parameters().get(CONNECTION_ID_PARAM));
|
||||
if (connectionId == null) {
|
||||
throw new IllegalArgumentException(
|
||||
operation + " requires a '" + CONNECTION_ID_PARAM + "' parameter");
|
||||
}
|
||||
// Throws if the connection is missing, the wrong type, disabled, or not usable by the
|
||||
// caller. The parsed settings are discarded: this call is the check.
|
||||
connectionResolver.resolveConfig(connectionId, type);
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Builds a {@code multipart/form-data} body for the JDK HTTP client, which has no multipart
|
||||
* publisher of its own.
|
||||
*
|
||||
* <p>The body is assembled in memory. Callers bound the document size before getting here; the
|
||||
* external-API step is for API-shaped payloads, not bulk transfer.
|
||||
*/
|
||||
final class MultipartBody {
|
||||
|
||||
private final String boundary;
|
||||
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
|
||||
MultipartBody() {
|
||||
byte[] random = new byte[16];
|
||||
new SecureRandom().nextBytes(random);
|
||||
this.boundary =
|
||||
"StirlingBoundary" + Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
}
|
||||
|
||||
String contentType() {
|
||||
return "multipart/form-data; boundary=" + boundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException if the <em>name</em> could break out of its part header;
|
||||
* names come from step parameters, so they are checked rather than trusted
|
||||
*/
|
||||
MultipartBody addField(String name, String value) throws IOException {
|
||||
requireSafe(name, "field name");
|
||||
writeAscii("--" + boundary + "\r\n");
|
||||
writeAscii("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n");
|
||||
// The value is body, not header: quotes, newlines and backslashes are ordinary data here
|
||||
// and must survive untouched. Checking it like a header rejected every JSON value - which
|
||||
// is most of them, the auto-populated context included.
|
||||
out.write(value.getBytes(StandardCharsets.UTF_8));
|
||||
writeAscii("\r\n");
|
||||
return this;
|
||||
}
|
||||
|
||||
MultipartBody addFile(String name, String filename, String contentType, byte[] content)
|
||||
throws IOException {
|
||||
requireSafe(name, "file field name");
|
||||
requireSafe(filename, "filename");
|
||||
writeAscii("--" + boundary + "\r\n");
|
||||
writeAscii(
|
||||
"Content-Disposition: form-data; name=\""
|
||||
+ name
|
||||
+ "\"; filename=\""
|
||||
+ filename
|
||||
+ "\"\r\n");
|
||||
writeAscii("Content-Type: " + contentType + "\r\n\r\n");
|
||||
out.write(content);
|
||||
writeAscii("\r\n");
|
||||
return this;
|
||||
}
|
||||
|
||||
HttpRequest.BodyPublisher build() throws IOException {
|
||||
writeAscii("--" + boundary + "--\r\n");
|
||||
return HttpRequest.BodyPublishers.ofByteArray(out.toByteArray());
|
||||
}
|
||||
|
||||
MultipartBody addFields(Map<String, String> fields) throws IOException {
|
||||
for (Map.Entry<String, String> entry : fields.entrySet()) {
|
||||
addField(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A quote, CR, LF or backslash in a <em>part header</em> - a field name or filename - would let
|
||||
* it close the quoted string and forge headers of its own. Values are not checked: they are
|
||||
* body, and the boundary that delimits them is 16 random bytes minted per request, so a value
|
||||
* cannot end its own part.
|
||||
*/
|
||||
private static void requireSafe(String value, String what) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("api step " + what + " must not be null");
|
||||
}
|
||||
if (value.indexOf('"') >= 0
|
||||
|| value.indexOf('\r') >= 0
|
||||
|| value.indexOf('\n') >= 0
|
||||
|| value.indexOf('\\') >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"api step " + what + " contains an illegal character: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeAscii(String text) throws IOException {
|
||||
out.write(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import tools.jackson.databind.node.StringNode;
|
||||
|
||||
/**
|
||||
* Substitutes {@code {{dotted.path}}} references against the {@link DocumentContext}.
|
||||
*
|
||||
* <p>This is what lets one step satisfy APIs that disagree about payload shape. Rather than a
|
||||
* connector per vendor, an operator writes the field names the vendor expects and fills them from
|
||||
* context - {@code {"sha256": "{{document.sha256}}", "class": "{{sensitivityLabel.name}}"}}.
|
||||
*
|
||||
* <p>Deliberately not a template language: dotted lookup and nothing else. No expressions, no
|
||||
* control flow, no method calls - a step definition is lower-trust than server config, and the
|
||||
* whole point of a template engine (evaluating what it is given) is the thing to avoid here.
|
||||
*/
|
||||
final class Placeholders {
|
||||
|
||||
private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{\\s*([\\w.]+)\\s*}}");
|
||||
|
||||
/** How a resolved value is escaped for the position it lands in. */
|
||||
enum Escaping {
|
||||
/** Verbatim: form fields and header values, which are validated separately. */
|
||||
NONE,
|
||||
/** Percent-encoded: a path segment, where a stray slash would change the target. */
|
||||
URL_PATH
|
||||
}
|
||||
|
||||
private Placeholders() {}
|
||||
|
||||
/**
|
||||
* @param template text that may contain {@code {{...}}} references; null passes through
|
||||
* @param context the object to resolve against
|
||||
* @throws IllegalArgumentException if a reference names something the context does not hold, so
|
||||
* a typo surfaces as an error instead of silently sending an empty value
|
||||
*/
|
||||
static String resolve(String template, JsonNode context, Escaping escaping) {
|
||||
if (template == null || template.isEmpty()) {
|
||||
return template;
|
||||
}
|
||||
Matcher matcher = PLACEHOLDER.matcher(template);
|
||||
StringBuilder out = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
String path = matcher.group(1);
|
||||
JsonNode value = lookup(context, path);
|
||||
if (value == null || value.isMissingNode()) {
|
||||
throw new IllegalArgumentException(
|
||||
"unknown placeholder '{{"
|
||||
+ path
|
||||
+ "}}'; available: document.*, classification.*,"
|
||||
+ " sensitivityLabel.*, run.*");
|
||||
}
|
||||
matcher.appendReplacement(out, Matcher.quoteReplacement(render(value, escaping)));
|
||||
}
|
||||
matcher.appendTail(out);
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every string in a JSON tree, in place, leaving structure and non-strings alone.
|
||||
*
|
||||
* <p>This is what lets one step post an arbitrary vendor-shaped body - a nested {@code
|
||||
* documents[0].data} as readily as a flat field - without a connector per vendor.
|
||||
*/
|
||||
static JsonNode resolveTree(JsonNode node, JsonNode context) {
|
||||
if (node instanceof ObjectNode object) {
|
||||
for (String name : new java.util.ArrayList<>(object.propertyNames())) {
|
||||
object.set(name, resolveTree(object.get(name), context));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
if (node instanceof ArrayNode array) {
|
||||
for (int i = 0; i < array.size(); i++) {
|
||||
array.set(i, resolveTree(array.get(i), context));
|
||||
}
|
||||
return array;
|
||||
}
|
||||
if (node != null && node.isString()) {
|
||||
return StringNode.valueOf(resolve(node.asString(), context, Escaping.NONE));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Whether the text references anything at all, so callers can skip resolving. */
|
||||
static boolean hasPlaceholder(String text) {
|
||||
return text != null && PLACEHOLDER.matcher(text).find();
|
||||
}
|
||||
|
||||
private static JsonNode lookup(JsonNode context, String path) {
|
||||
JsonNode node = context;
|
||||
for (String segment : path.split("\\.")) {
|
||||
if (node == null || !node.isObject()) {
|
||||
return null;
|
||||
}
|
||||
node = node.get(segment);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* A null in context renders empty rather than the literal "null": absent metadata is a normal
|
||||
* state, and "null" in a vendor's field would be a value, not an absence.
|
||||
*/
|
||||
private static String render(JsonNode value, Escaping escaping) {
|
||||
String text;
|
||||
if (value.isNull()) {
|
||||
text = "";
|
||||
} else if (value.isValueNode()) {
|
||||
text = value.asString();
|
||||
} else {
|
||||
// An object or array (e.g. {{classification}}) renders as its JSON.
|
||||
text = value.toString();
|
||||
}
|
||||
return escaping == Escaping.URL_PATH ? urlEncodePathSegment(text) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode for a path segment: a filename is the likeliest value to land in a path and may carry
|
||||
* a slash, which would otherwise read as structure rather than data.
|
||||
*
|
||||
* <p>Dots are left alone even though a traversal is made of them. Encoding them would be worse:
|
||||
* {@code %2E%2E} survives {@link java.net.URI#normalize()} and gets decoded by the target, so
|
||||
* the traversal would arrive intact and unexamined. Left raw, {@code ..} normalises here and is
|
||||
* caught by {@code ExternalApiPaths}' under-the-base check - the one place that can actually
|
||||
* see it.
|
||||
*/
|
||||
private static String urlEncodePathSegment(String text) {
|
||||
StringBuilder out = new StringBuilder(text.length());
|
||||
for (byte b : text.getBytes(java.nio.charset.StandardCharsets.UTF_8)) {
|
||||
char c = (char) (b & 0xFF);
|
||||
// RFC 3986 unreserved.
|
||||
boolean unreserved =
|
||||
(c >= 'a' && c <= 'z')
|
||||
|| (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9')
|
||||
|| c == '-'
|
||||
|| c == '.'
|
||||
|| c == '_'
|
||||
|| c == '~';
|
||||
if (unreserved) {
|
||||
out.append(c);
|
||||
} else {
|
||||
out.append('%').append(String.format("%02X", b & 0xFF));
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.ZipExtractionUtils;
|
||||
|
||||
/**
|
||||
* Works out which bytes, and under which name, a response should contribute to the pipeline.
|
||||
*
|
||||
* <p>Three things go wrong if this is left implicit:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>The name.</b> A step that replaces the document must name it for what came back, not for
|
||||
* what went out. Keeping the inbound name means a PDF-to-DOCX call-out yields a DOCX called
|
||||
* {@code .pdf}, and the next step's type check either waves it through or rejects it for the
|
||||
* wrong reason. The response's own {@code Content-Disposition} or {@code Content-Type} is the
|
||||
* only honest source.
|
||||
* <li><b>Archives.</b> Plenty of APIs answer with a ZIP even when one file was sent - ConsignO
|
||||
* returns "PDF (single) or ZIP (multiple)". Handing a {@code .zip} to a step expecting a PDF
|
||||
* is a confusing failure, so a step can select what it wanted out of the archive.
|
||||
* <li><b>Nothing useful at all.</b> An empty body or an error page is not a document, and saying
|
||||
* so beats letting it flow onward as one.
|
||||
* </ul>
|
||||
*/
|
||||
final class ResultFiles {
|
||||
|
||||
/** Extensions we can name from a content type; anything else keeps the server's filename. */
|
||||
private static final Map<String, String> EXTENSION_BY_TYPE =
|
||||
Map.ofEntries(
|
||||
Map.entry("application/pdf", "pdf"),
|
||||
Map.entry("application/zip", "zip"),
|
||||
Map.entry("application/json", "json"),
|
||||
Map.entry("text/plain", "txt"),
|
||||
Map.entry("text/html", "html"),
|
||||
Map.entry("image/png", "png"),
|
||||
Map.entry("image/jpeg", "jpg"),
|
||||
Map.entry("image/tiff", "tiff"),
|
||||
Map.entry("application/msword", "doc"),
|
||||
Map.entry(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"docx"),
|
||||
Map.entry("application/vnd.ms-excel", "xls"),
|
||||
Map.entry(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"xlsx"));
|
||||
|
||||
private ResultFiles() {}
|
||||
|
||||
/**
|
||||
* The filename to give the returned bytes.
|
||||
*
|
||||
* <p>Prefers what the server said ({@code Content-Disposition}), then the base name of the
|
||||
* request with an extension derived from {@code Content-Type}, and only then the original name
|
||||
* unchanged.
|
||||
*/
|
||||
static String nameFor(ExternalApiCaller.Response response, String requestFilename) {
|
||||
String disposition = response.header("content-disposition");
|
||||
String fromServer = filenameFromDisposition(disposition);
|
||||
if (fromServer != null) {
|
||||
return fromServer;
|
||||
}
|
||||
String extension = extensionFor(response.contentType());
|
||||
if (extension == null) {
|
||||
return requestFilename;
|
||||
}
|
||||
return baseName(requestFilename) + "." + extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the file a step asked for out of an archive.
|
||||
*
|
||||
* @param select a glob such as {@code *.pdf}, or a 0-based index such as {@code 1}
|
||||
* @throws IOException if nothing in the archive matches, naming what was there - a silent pick
|
||||
* of the wrong file would be worse than a failed step
|
||||
*/
|
||||
static Resource selectFromArchive(
|
||||
Resource archive, String select, TempFileManager tempFileManager) throws IOException {
|
||||
List<Resource> entries = ZipExtractionUtils.extractZip(archive, tempFileManager);
|
||||
if (entries.isEmpty()) {
|
||||
throw new IOException("The API returned an empty archive");
|
||||
}
|
||||
Integer index = asIndex(select);
|
||||
if (index != null) {
|
||||
if (index < 0 || index >= entries.size()) {
|
||||
throw new IOException(
|
||||
"'responseSelect' asked for entry "
|
||||
+ index
|
||||
+ " but the archive has "
|
||||
+ entries.size()
|
||||
+ ": "
|
||||
+ names(entries));
|
||||
}
|
||||
return entries.get(index);
|
||||
}
|
||||
List<Resource> matches = new ArrayList<>();
|
||||
for (Resource entry : entries) {
|
||||
if (matchesGlob(entry.getFilename(), select)) {
|
||||
matches.add(entry);
|
||||
}
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
throw new IOException(
|
||||
"'responseSelect' matched nothing in the archive; it holds " + names(entries));
|
||||
}
|
||||
if (matches.size() > 1) {
|
||||
// Taking the first would be a coin toss the operator did not ask for.
|
||||
throw new IOException(
|
||||
"'responseSelect' matched "
|
||||
+ matches.size()
|
||||
+ " entries ("
|
||||
+ names(matches)
|
||||
+ "); narrow it, or use an index");
|
||||
}
|
||||
return matches.get(0);
|
||||
}
|
||||
|
||||
/** Whether the chosen name is itself an archive, so its content type is not the entry's. */
|
||||
static boolean isArchiveName(String filename) {
|
||||
return filename != null && filename.toLowerCase(Locale.ROOT).endsWith(".zip");
|
||||
}
|
||||
|
||||
static boolean isArchive(Resource resource) throws IOException {
|
||||
return ZipExtractionUtils.isZip(resource);
|
||||
}
|
||||
|
||||
static Resource asResource(byte[] content, String filename) {
|
||||
return new ByteArrayResource(content) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Only {@code *} is supported, and only against the entry's own name. */
|
||||
private static boolean matchesGlob(String filename, String glob) {
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
String name = filename.toLowerCase(Locale.ROOT);
|
||||
String pattern = glob.trim().toLowerCase(Locale.ROOT);
|
||||
String regex =
|
||||
java.util.Arrays.stream(pattern.split("\\*", -1))
|
||||
.map(java.util.regex.Pattern::quote)
|
||||
.reduce((a, b) -> a + ".*" + b)
|
||||
.orElse("");
|
||||
return name.matches(regex);
|
||||
}
|
||||
|
||||
private static Integer asIndex(String select) {
|
||||
try {
|
||||
return Integer.valueOf(select.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String names(List<Resource> entries) {
|
||||
return entries.stream().map(Resource::getFilename).toList().toString();
|
||||
}
|
||||
|
||||
/** {@code attachment; filename="signed.pdf"} or its RFC 5987 {@code filename*} form. */
|
||||
private static String filenameFromDisposition(String disposition) {
|
||||
if (disposition == null) {
|
||||
return null;
|
||||
}
|
||||
for (String part : disposition.split(";")) {
|
||||
String token = part.trim();
|
||||
String value = null;
|
||||
if (token.regionMatches(true, 0, "filename=", 0, 9)) {
|
||||
value = token.substring(9).trim();
|
||||
} else if (token.regionMatches(true, 0, "filename*=", 0, 10)) {
|
||||
value = token.substring(10).trim();
|
||||
int tick = value.lastIndexOf('\'');
|
||||
if (tick >= 0) {
|
||||
value = value.substring(tick + 1);
|
||||
}
|
||||
}
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
value = value.substring(1, value.length() - 1);
|
||||
}
|
||||
// The name comes from the remote server, so it is treated as data: strip any path it
|
||||
// tries to bring with it rather than letting it steer where anything is written.
|
||||
String simple = io.github.pixee.security.Filenames.toSimpleFileName(value);
|
||||
if (simple != null && !simple.isBlank()) {
|
||||
return simple;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extensionFor(String contentType) {
|
||||
if (contentType == null) {
|
||||
return null;
|
||||
}
|
||||
String type = contentType.split(";")[0].trim().toLowerCase(Locale.ROOT);
|
||||
return EXTENSION_BY_TYPE.get(type);
|
||||
}
|
||||
|
||||
private static String baseName(String filename) {
|
||||
int dot = filename.lastIndexOf('.');
|
||||
return dot <= 0 ? filename : filename.substring(0, dot);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package stirling.software.proprietary.integration.api;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
|
||||
/**
|
||||
* Validates a result URL an external API asked us to fetch.
|
||||
*
|
||||
* <p>This is the most dangerous input in the whole feature and deserves saying plainly: unlike a
|
||||
* step's {@code path}, which an operator wrote, this URL is <em>chosen by the remote service at run
|
||||
* time</em>. Fetching whatever it names would hand any integration - or anything that has
|
||||
* compromised, spoofed, or MITM'd one - a server-side GET of its choosing, i.e. the cloud metadata
|
||||
* service. {@link ExternalApiPaths} cannot help here: the whole point of a result URL is that it
|
||||
* usually lives on a different host (a CDN or presigned object store), so "must be under the base
|
||||
* URL" would reject the normal case.
|
||||
*
|
||||
* <p>The rule is therefore an <em>operator-declared</em> allowlist: a result may come from the
|
||||
* connection's own host, or from a host named in the connection's {@code resultUrlHosts}. The
|
||||
* decision of which hosts are legitimate stays with whoever configured the connection, and never
|
||||
* with the response.
|
||||
*/
|
||||
final class ResultUrls {
|
||||
|
||||
private ResultUrls() {}
|
||||
|
||||
/**
|
||||
* @param url exactly as the API returned it
|
||||
* @return the URL to fetch
|
||||
* @throws IllegalArgumentException if the response named a host the operator did not authorise
|
||||
*/
|
||||
static URI validate(
|
||||
ApiConnectionSettings settings,
|
||||
String url,
|
||||
ApplicationProperties applicationProperties) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(url.trim());
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"The API returned a result URL that is not a valid URL: " + url, e);
|
||||
}
|
||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
// file:, gopher:, jar: and friends are how a URL fetch becomes a local file read.
|
||||
throw new IllegalArgumentException(
|
||||
"The API returned a result URL that is not http(s): " + url);
|
||||
}
|
||||
String host = uri.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"The API returned a result URL with no host: " + url);
|
||||
}
|
||||
if (uri.getUserInfo() != null) {
|
||||
// Credentials in a URL are also the classic way to make a host look like another one.
|
||||
throw new IllegalArgumentException(
|
||||
"The API returned a result URL carrying credentials, which is not accepted");
|
||||
}
|
||||
|
||||
if (!isAllowedHost(settings, host)) {
|
||||
throw new IllegalArgumentException(
|
||||
"The API returned a result URL on '"
|
||||
+ host
|
||||
+ "', which this connection does not allow. Add it to the connection's"
|
||||
+ " 'resultUrlHosts' if results are meant to come from there.");
|
||||
}
|
||||
// Even an allowlisted name must not resolve somewhere internal: a hostile or compromised
|
||||
// DNS record for cdn.vendor.example pointing at 169.254.169.254 would otherwise be obeyed.
|
||||
try {
|
||||
S3Clients.validateEndpointHost(
|
||||
uri,
|
||||
applicationProperties.getPolicies().isAllowPrivateApiEndpoints(),
|
||||
"API result URL",
|
||||
"set policies.allowPrivateApiEndpoints=true to opt in (e.g. for an on-prem"
|
||||
+ " integration).");
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection's own host is implicitly allowed; anything else must be declared.
|
||||
*
|
||||
* <p>Package-private so the matching rule can be tested without a DNS lookup: {@link #validate}
|
||||
* additionally resolves the host, which fails closed and so cannot run against example hosts.
|
||||
*/
|
||||
static boolean isAllowedHost(ApiConnectionSettings settings, String host) {
|
||||
String candidate = host.toLowerCase(Locale.ROOT);
|
||||
if (candidate.equalsIgnoreCase(settings.baseUri().getHost())) {
|
||||
return true;
|
||||
}
|
||||
Set<String> allowed = settings.resultUrlHosts();
|
||||
for (String entry : allowed) {
|
||||
String allowedHost = entry.toLowerCase(Locale.ROOT);
|
||||
// An exact host, or a subdomain of it. Not a bare suffix match: "evilvendor.com"
|
||||
// must not be admitted by an entry of "vendor.com".
|
||||
if (candidate.equals(allowedHost) || candidate.endsWith("." + allowedHost)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+19
@@ -52,6 +52,25 @@ public class IntegrationConfigController {
|
||||
return ResponseEntity.ok(service.toResponse(service.create(request, user), user));
|
||||
}
|
||||
|
||||
/**
|
||||
* What this caller may set up, so the UI offers the vendor presets and the free-form "custom
|
||||
* API" option only to those who can actually use them. The answer is computed here rather than
|
||||
* inferred client-side: hiding a button is presentation, and the service still refuses the call
|
||||
* regardless of what the client believed.
|
||||
*/
|
||||
@GetMapping("/capabilities")
|
||||
public ResponseEntity<IntegrationCapabilitiesResponse> capabilities(
|
||||
@AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(
|
||||
new IntegrationCapabilitiesResponse(service.canAuthorCustomApi(user)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param customApi whether the caller may author a free-form API integration
|
||||
*/
|
||||
public record IntegrationCapabilitiesResponse(boolean customApi) {}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<IntegrationConfigResponse> get(
|
||||
@PathVariable Long id, @AuthenticationPrincipal User user) {
|
||||
|
||||
+6
-1
@@ -4,5 +4,10 @@ package stirling.software.proprietary.integration.model;
|
||||
public enum IntegrationType {
|
||||
S3,
|
||||
MCP,
|
||||
API
|
||||
/** A generic outbound HTTP endpoint a pipeline step can post a document to. */
|
||||
API,
|
||||
/** Microsoft Purview Information Protection: sensitivity-label taxonomy via Graph. */
|
||||
PURVIEW,
|
||||
/** ConsignO Cloud (Notarius) e-signature and notarization. */
|
||||
CONSIGNO
|
||||
}
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package stirling.software.proprietary.integration.purview;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Reads and writes Microsoft Purview sensitivity labels on a PDF.
|
||||
*
|
||||
* <p>Microsoft documents <em>what</em> a label is - the {@code MSIP_Label_<GUID>_<Attribute>}
|
||||
* key/value set - but not <em>where</em> it lives inside a PDF; that detail sits inside the MIP
|
||||
* SDK, which is C++/.NET only and has no Java binding. This class therefore treats the two places a
|
||||
* PDF can hold such pairs as equally valid:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the Document Information dictionary, whose custom entries are literally a key/value map;
|
||||
* <li>the XMP packet, where the same keys appear as properties.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Reading is deliberately tolerant - it scans both and takes whichever yields a label - so a
|
||||
* document labelled by Acrobat, the MIP client, or another vendor is still understood. Writing
|
||||
* populates both, because a downstream reader may only look at one.
|
||||
*
|
||||
* <p>Scope: this applies the label <em>metadata</em>. It does not encrypt, and cannot: protection
|
||||
* is enforced by the Azure Rights Management service through the MIP SDK. A label whose policy
|
||||
* demands encryption will be marked here but not protected, which {@link #apply} refuses to do
|
||||
* silently.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class PdfSensitivityLabels {
|
||||
|
||||
/** Captures the GUID and the attribute name out of {@code MSIP_Label_<guid>_<attr>}. */
|
||||
private static final Pattern LABEL_KEY =
|
||||
Pattern.compile("^MSIP_Label_([0-9a-fA-F-]{36})_(\\w+)$");
|
||||
|
||||
/** Finds the same keys inside a raw XMP packet, whatever schema wraps them. */
|
||||
private static final Pattern XMP_LABEL_ENTRY =
|
||||
Pattern.compile(
|
||||
"<([\\w-]+:)?(MSIP_Label_[0-9a-fA-F-]{36}_\\w+)>([^<]*)</\\1?\\2>",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/**
|
||||
* Adobe's extension schema for carrying arbitrary Document Info entries in XMP. Using it keeps
|
||||
* the XMP copy standards-shaped instead of inventing a namespace.
|
||||
*/
|
||||
private static final String PDFX_NAMESPACE = "http://ns.adobe.com/pdfx/1.3/";
|
||||
|
||||
private static final int MAX_XMP_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
private PdfSensitivityLabels() {}
|
||||
|
||||
/**
|
||||
* The label on this document, if any.
|
||||
*
|
||||
* <p>A document carries at most one label per organisation, but may carry labels from several.
|
||||
* When more than one is present the first found is returned - callers that care about a
|
||||
* specific tenant should compare {@link SensitivityLabel#siteId()}.
|
||||
*/
|
||||
public static Optional<SensitivityLabel> read(PDDocument document) {
|
||||
List<SensitivityLabel> all = readAll(document);
|
||||
return all.isEmpty() ? Optional.empty() : Optional.of(all.get(0));
|
||||
}
|
||||
|
||||
/** Every label on the document, across both metadata surfaces, de-duplicated by GUID. */
|
||||
public static List<SensitivityLabel> readAll(PDDocument document) {
|
||||
Map<String, Map<String, String>> byLabelId = new LinkedHashMap<>();
|
||||
collect(infoPairs(document), byLabelId);
|
||||
collect(xmpPairs(document), byLabelId);
|
||||
|
||||
List<SensitivityLabel> labels = new ArrayList<>();
|
||||
byLabelId.forEach(
|
||||
(labelId, attributes) -> {
|
||||
SensitivityLabel label = SensitivityLabel.fromAttributes(labelId, attributes);
|
||||
if (label != null) {
|
||||
labels.add(label);
|
||||
}
|
||||
});
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a label, replacing any the same tenant already set.
|
||||
*
|
||||
* @throws IllegalArgumentException if the label claims encryption, which this cannot honour
|
||||
*/
|
||||
public static void apply(PDDocument document, SensitivityLabel label) throws IOException {
|
||||
if (label.isProtected()) {
|
||||
// Writing ContentBits=ENCRYPT onto an unencrypted file would tell every downstream
|
||||
// reader the content is protected when it is plaintext. Refuse rather than lie.
|
||||
throw new IllegalArgumentException(
|
||||
"This label requires encryption, which needs the Microsoft Purview client or"
|
||||
+ " MIP SDK; Stirling can apply the label metadata but cannot protect"
|
||||
+ " the content.");
|
||||
}
|
||||
// "An object can only have one label from the same organization." Replace this tenant's
|
||||
// labels on both surfaces, but leave other tenants' labels untouched on both.
|
||||
Set<String> replaced = labelIdsOfTenant(document, label.siteId());
|
||||
replaced.add(label.labelId());
|
||||
Map<String, String> pairs = label.toMetadata();
|
||||
removeInfoLabels(document, replaced::contains);
|
||||
writeInfo(document, pairs);
|
||||
writeXmp(document, pairs, replaced::contains);
|
||||
}
|
||||
|
||||
/** Strip every label, e.g. before re-labelling or when downgrading a document. */
|
||||
public static void clear(PDDocument document) throws IOException {
|
||||
removeInfoLabels(document, labelId -> true);
|
||||
writeXmp(document, Map.of(), labelId -> true);
|
||||
}
|
||||
|
||||
/** The GUIDs of labels this tenant already set, so both surfaces can drop exactly those. */
|
||||
private static Set<String> labelIdsOfTenant(PDDocument document, String siteId) {
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
for (SensitivityLabel existing : readAll(document)) {
|
||||
if (siteId.equalsIgnoreCase(existing.siteId())) {
|
||||
ids.add(existing.labelId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Drop info-dictionary label entries whose GUID the predicate selects. */
|
||||
private static void removeInfoLabels(PDDocument document, Predicate<String> removeLabelId) {
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
for (String key : new ArrayList<>(info.getMetadataKeys())) {
|
||||
Matcher matcher = LABEL_KEY.matcher(key);
|
||||
if (matcher.matches() && removeLabelId.test(matcher.group(1))) {
|
||||
info.setCustomMetadataValue(key, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeInfo(PDDocument document, Map<String, String> pairs) {
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
pairs.forEach(info::setCustomMetadataValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the XMP packet's label properties, leaving the rest of the packet untouched.
|
||||
*
|
||||
* <p>The packet is edited textually rather than re-serialised through xmpbox: a document's XMP
|
||||
* may carry schemas xmpbox does not model, and a round-trip through it would silently drop
|
||||
* them.
|
||||
*/
|
||||
private static void writeXmp(
|
||||
PDDocument document, Map<String, String> pairs, Predicate<String> removeLabelId)
|
||||
throws IOException {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
String existing = readXmpString(catalog);
|
||||
if (existing == null) {
|
||||
if (pairs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
existing = emptyPacket();
|
||||
}
|
||||
String stripped = stripLabels(existing, removeLabelId);
|
||||
String updated = insertLabelProperties(stripped, pairs);
|
||||
if (updated == null) {
|
||||
log.debug("XMP packet has no rdf:Description to hold the label; info dictionary only");
|
||||
return;
|
||||
}
|
||||
PDMetadata metadata = new PDMetadata(document);
|
||||
metadata.importXMPMetadata(updated.getBytes(StandardCharsets.UTF_8));
|
||||
catalog.setMetadata(metadata);
|
||||
}
|
||||
|
||||
/** Remove only the XMP label entries whose GUID the predicate selects, keeping the rest. */
|
||||
private static String stripLabels(String packet, Predicate<String> removeLabelId) {
|
||||
Matcher matcher = XMP_LABEL_ENTRY.matcher(packet);
|
||||
StringBuilder out = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
Matcher key = LABEL_KEY.matcher(matcher.group(2));
|
||||
boolean remove = key.matches() && removeLabelId.test(key.group(1));
|
||||
matcher.appendReplacement(out, Matcher.quoteReplacement(remove ? "" : matcher.group()));
|
||||
}
|
||||
matcher.appendTail(out);
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** Splice the properties into the first {@code rdf:Description}; null when there is none. */
|
||||
private static String insertLabelProperties(String packet, Map<String, String> pairs) {
|
||||
if (pairs.isEmpty()) {
|
||||
return packet;
|
||||
}
|
||||
Matcher description = Pattern.compile("<rdf:Description\\b[^>]*>").matcher(packet);
|
||||
if (!description.find()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder properties = new StringBuilder();
|
||||
pairs.forEach(
|
||||
(key, value) ->
|
||||
properties
|
||||
.append("\n <pdfx:")
|
||||
.append(key)
|
||||
.append('>')
|
||||
.append(escapeXml(value))
|
||||
.append("</pdfx:")
|
||||
.append(key)
|
||||
.append('>'));
|
||||
String opening = description.group();
|
||||
String withNamespace =
|
||||
opening.contains("xmlns:pdfx=")
|
||||
? opening
|
||||
: opening.substring(0, opening.length() - 1)
|
||||
+ " xmlns:pdfx=\""
|
||||
+ PDFX_NAMESPACE
|
||||
+ "\">";
|
||||
return packet.substring(0, description.start())
|
||||
+ withNamespace
|
||||
+ properties
|
||||
+ packet.substring(description.end());
|
||||
}
|
||||
|
||||
private static Map<String, String> infoPairs(PDDocument document) {
|
||||
Map<String, String> pairs = new LinkedHashMap<>();
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
String value = info.getCustomMetadataValue(key);
|
||||
if (value != null) {
|
||||
pairs.put(key, value);
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private static Map<String, String> xmpPairs(PDDocument document) {
|
||||
Map<String, String> pairs = new LinkedHashMap<>();
|
||||
String packet;
|
||||
try {
|
||||
packet = readXmpString(document.getDocumentCatalog());
|
||||
} catch (IOException e) {
|
||||
log.debug(
|
||||
"Unreadable XMP packet; falling back to the info dictionary: {}",
|
||||
e.getMessage());
|
||||
return pairs;
|
||||
}
|
||||
if (packet == null) {
|
||||
return pairs;
|
||||
}
|
||||
Matcher matcher = XMP_LABEL_ENTRY.matcher(packet);
|
||||
while (matcher.find()) {
|
||||
pairs.put(matcher.group(2), unescapeXml(matcher.group(3).trim()));
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** Group raw pairs by label GUID, keeping the attribute name as the key. */
|
||||
private static void collect(Map<String, String> pairs, Map<String, Map<String, String>> into) {
|
||||
pairs.forEach(
|
||||
(key, value) -> {
|
||||
Matcher matcher = LABEL_KEY.matcher(key);
|
||||
if (!matcher.matches()) {
|
||||
return;
|
||||
}
|
||||
into.computeIfAbsent(matcher.group(1), id -> new LinkedHashMap<>())
|
||||
// Info-dictionary pairs are collected first and win: a stale XMP copy
|
||||
// must not override the value the labelling client wrote.
|
||||
.putIfAbsent(matcher.group(2), value);
|
||||
});
|
||||
}
|
||||
|
||||
private static String readXmpString(PDDocumentCatalog catalog) throws IOException {
|
||||
PDMetadata metadata = catalog.getMetadata();
|
||||
if (metadata == null) {
|
||||
return null;
|
||||
}
|
||||
try (InputStream is = metadata.exportXMPMetadata()) {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[8192];
|
||||
int read;
|
||||
int total = 0;
|
||||
while ((read = is.read(chunk)) != -1) {
|
||||
total += read;
|
||||
if (total > MAX_XMP_BYTES) {
|
||||
// A hostile document could otherwise hand us an unbounded packet to hold.
|
||||
throw new IOException("XMP packet exceeds " + MAX_XMP_BYTES + " bytes");
|
||||
}
|
||||
buffer.write(chunk, 0, read);
|
||||
}
|
||||
return buffer.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static String emptyPacket() {
|
||||
return "<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>"
|
||||
+ "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">"
|
||||
+ "<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">"
|
||||
+ "<rdf:Description rdf:about=\"\"></rdf:Description>"
|
||||
+ "</rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>";
|
||||
}
|
||||
|
||||
private static String escapeXml(String value) {
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
|
||||
private static String unescapeXml(String value) {
|
||||
return value.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&");
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package stirling.software.proprietary.integration.purview;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A Microsoft Purview tenant connection.
|
||||
*
|
||||
* <p>Only {@code tenantId} is required, because labelling a document needs nothing else: a label is
|
||||
* a set of key/value pairs and the tenant id is the {@code SiteId} among them. No call to Microsoft
|
||||
* is involved, so the step works with no network and no app registration.
|
||||
*
|
||||
* <p>The app-registration fields are optional and buy exactly one thing: reading the tenant's label
|
||||
* taxonomy from Graph, so the UI can offer a list of labels instead of asking someone to paste a
|
||||
* GUID. They are not needed to apply or read a label. Graph cannot apply labels for an application
|
||||
* anyway - "application permissions are not supported when updating assignedLabels" - which is why
|
||||
* labelling here goes through the published metadata contract instead.
|
||||
*/
|
||||
public record PurviewConnectionSettings(
|
||||
String tenantId,
|
||||
String clientId,
|
||||
String clientSecret,
|
||||
String graphBaseUrl,
|
||||
String loginBaseUrl) {
|
||||
|
||||
static final String TENANT_ID_OPTION = "tenantId";
|
||||
static final String CLIENT_ID_OPTION = "clientId";
|
||||
// Contains a SecretMasker hint, so it masks on read and merges on update.
|
||||
static final String CLIENT_SECRET_OPTION = "clientSecret";
|
||||
static final String GRAPH_BASE_URL_OPTION = "graphBaseUrl";
|
||||
static final String LOGIN_BASE_URL_OPTION = "loginBaseUrl";
|
||||
|
||||
public static final String DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com";
|
||||
public static final String DEFAULT_LOGIN_BASE_URL = "https://login.microsoftonline.com";
|
||||
|
||||
/** Entra tenant ids are GUIDs; the value ends up in document metadata, so it is checked. */
|
||||
private static final Pattern GUID =
|
||||
Pattern.compile("^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$");
|
||||
|
||||
public static PurviewConnectionSettings from(Map<String, Object> options) {
|
||||
String tenantId = trimmed(options.get(TENANT_ID_OPTION));
|
||||
if (tenantId == null) {
|
||||
throw new IllegalArgumentException("purview config requires a 'tenantId'");
|
||||
}
|
||||
if (!GUID.matcher(tenantId).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"purview config 'tenantId' must be a GUID, e.g."
|
||||
+ " cb46c030-1825-4e81-a295-151c039dbf02");
|
||||
}
|
||||
String clientId = trimmed(options.get(CLIENT_ID_OPTION));
|
||||
String clientSecret = trimmed(options.get(CLIENT_SECRET_OPTION));
|
||||
// Half an app registration would fail only when someone opened the label picker, which is
|
||||
// a confusing place to discover it.
|
||||
if ((clientId == null) != (clientSecret == null)) {
|
||||
throw new IllegalArgumentException(
|
||||
"purview config needs both 'clientId' and 'clientSecret' to read the label"
|
||||
+ " list, or neither");
|
||||
}
|
||||
return new PurviewConnectionSettings(
|
||||
tenantId.toLowerCase(Locale.ROOT),
|
||||
clientId,
|
||||
clientSecret,
|
||||
orDefault(trimmed(options.get(GRAPH_BASE_URL_OPTION)), DEFAULT_GRAPH_BASE_URL),
|
||||
orDefault(trimmed(options.get(LOGIN_BASE_URL_OPTION)), DEFAULT_LOGIN_BASE_URL));
|
||||
}
|
||||
|
||||
/** Whether this connection can read the tenant's label taxonomy from Graph. */
|
||||
public boolean canListLabels() {
|
||||
return clientId != null && clientSecret != null;
|
||||
}
|
||||
|
||||
private static String orDefault(String value, String fallback) {
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
|
||||
private static String trimmed(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/** Never prints the client secret, so an accidental log line cannot leak it. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PurviewConnectionSettings[tenantId="
|
||||
+ tenantId
|
||||
+ ", canListLabels="
|
||||
+ canListLabels()
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.integration.purview;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
|
||||
|
||||
/** The Purview connection schema, enforced when the config is saved. */
|
||||
@Component
|
||||
public class PurviewIntegrationValidator implements IntegrationConfigValidator {
|
||||
|
||||
@Override
|
||||
public IntegrationType type() {
|
||||
return IntegrationType.PURVIEW;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Map<String, Object> config) {
|
||||
PurviewConnectionSettings.from(config);
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package stirling.software.proprietary.integration.purview;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.integration.api.ApiConnectionResolver;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod;
|
||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Purview sensitivity labelling as policy steps.
|
||||
*
|
||||
* <p>Both steps are local: a label is metadata, so applying and reading one involves no call to
|
||||
* Microsoft. The connection supplies the tenant id that becomes the label's {@code SiteId}.
|
||||
*
|
||||
* <p>{@code purview-read-label} exists to make labels <em>actionable</em>: it reports what a
|
||||
* document already carries, so a policy can branch on it - the case Purview itself does not cover,
|
||||
* since it labels documents but does not process them.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/integration")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Integrations", description = "Third-party integration steps.")
|
||||
public class PurviewLabelController {
|
||||
|
||||
private final ApiConnectionResolver connectionResolver;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping(value = "/purview-apply-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Apply a Microsoft Purview sensitivity label",
|
||||
description =
|
||||
"Writes the Purview label metadata (MSIP_Label_<GUID>_*) onto the PDF, so"
|
||||
+ " Purview-aware tools recognise the label. Applies the label only;"
|
||||
+ " it cannot encrypt, which requires the Microsoft client."
|
||||
+ " Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> applyLabel(
|
||||
@RequestParam("fileInput") MultipartFile fileInput,
|
||||
@RequestParam("connectionId") String connectionId,
|
||||
@RequestParam("labelId") String labelId,
|
||||
@RequestParam(value = "labelName", required = false) String labelName,
|
||||
@RequestParam(value = "method", defaultValue = "STANDARD") String method,
|
||||
@RequestParam(value = "contentBits", required = false) Integer contentBits)
|
||||
throws IOException {
|
||||
|
||||
PurviewConnectionSettings settings = settings(connectionId);
|
||||
AssignmentMethod assignment = parseMethod(method);
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
SensitivityLabel label =
|
||||
new SensitivityLabel(
|
||||
labelId.trim(),
|
||||
labelName,
|
||||
settings.tenantId(),
|
||||
assignment,
|
||||
Instant.now(),
|
||||
contentBits);
|
||||
PdfSensitivityLabels.apply(document, label);
|
||||
log.debug("[purview-apply-label] labelled {} as {}", fileName, labelId);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/purview-read-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Read the Microsoft Purview sensitivity label on a PDF",
|
||||
description =
|
||||
"Reports the Purview labels a PDF already carries so a policy can act on"
|
||||
+ " them. The document passes through unchanged."
|
||||
+ " Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> readLabel(
|
||||
@RequestParam("fileInput") MultipartFile fileInput,
|
||||
@RequestParam("connectionId") String connectionId)
|
||||
throws IOException {
|
||||
|
||||
PurviewConnectionSettings settings = settings(connectionId);
|
||||
|
||||
List<SensitivityLabel> labels;
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
labels = PdfSensitivityLabels.readAll(document);
|
||||
}
|
||||
// The document is returned byte-for-byte rather than re-saved: a read must not perturb the
|
||||
// file it inspected, and a PDFBox round-trip would rewrite its structure.
|
||||
byte[] bytes = fileInput.getBytes();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentDispositionFormData(
|
||||
"attachment", safeFileName(fileInput.getOriginalFilename()));
|
||||
headers.setContentLength(bytes.length);
|
||||
headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReport(labels, settings));
|
||||
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* The labels found, and which of them is this tenant's - a document can carry labels from
|
||||
* several organisations, and only the matching one reflects this tenant's policy.
|
||||
*/
|
||||
private String buildReport(List<SensitivityLabel> labels, PurviewConnectionSettings settings) {
|
||||
Optional<SensitivityLabel> own =
|
||||
labels.stream()
|
||||
.filter(label -> settings.tenantId().equalsIgnoreCase(label.siteId()))
|
||||
.findFirst();
|
||||
ObjectNode report = objectMapper.createObjectNode();
|
||||
report.put("labelled", own.isPresent());
|
||||
own.ifPresent(
|
||||
label -> {
|
||||
report.put("labelId", label.labelId());
|
||||
report.put("labelName", label.name());
|
||||
report.put("method", label.method() == null ? null : label.method().name());
|
||||
report.put(
|
||||
"setDate", label.setDate() == null ? null : label.setDate().toString());
|
||||
report.put("contentBits", label.contentBits());
|
||||
report.put("protected", label.isProtected());
|
||||
});
|
||||
ArrayNode others = report.putArray("otherTenantLabels");
|
||||
labels.stream()
|
||||
.filter(label -> !settings.tenantId().equalsIgnoreCase(label.siteId()))
|
||||
.forEach(
|
||||
label -> {
|
||||
ObjectNode node = others.addObject();
|
||||
node.put("labelId", label.labelId());
|
||||
node.put("siteId", label.siteId());
|
||||
});
|
||||
return objectMapper.writeValueAsString(report);
|
||||
}
|
||||
|
||||
private PurviewConnectionSettings settings(String connectionId) {
|
||||
Long id = ApiConnectionResolver.connectionId(connectionId);
|
||||
if (id == null) {
|
||||
throw new IllegalArgumentException("'connectionId' is required");
|
||||
}
|
||||
return PurviewConnectionSettings.from(
|
||||
connectionResolver.resolveConfig(id, IntegrationType.PURVIEW));
|
||||
}
|
||||
|
||||
private static AssignmentMethod parseMethod(String method) {
|
||||
AssignmentMethod parsed = AssignmentMethod.parse(method);
|
||||
if (parsed == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"'method' must be STANDARD (applied automatically) or PRIVILEGED (chosen by a"
|
||||
+ " person); got "
|
||||
+ method);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static String safeFileName(String originalFilename) {
|
||||
String name = Filenames.toSimpleFileName(originalFilename);
|
||||
return (name == null || name.isBlank()) ? "labelled.pdf" : name;
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package stirling.software.proprietary.integration.purview;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* One Microsoft Purview Information Protection label as it is written to a document.
|
||||
*
|
||||
* <p>Microsoft persists a label as a flat set of key/value pairs named {@code
|
||||
* MSIP_Label_<GUID>_<Attribute>}, and documents that contract publicly so third-party software can
|
||||
* read a label and act on it. That published contract - not the MIP SDK, which has no Java binding
|
||||
* - is what this type implements. See <a
|
||||
* href="https://learn.microsoft.com/en-us/information-protection/develop/concept-mip-metadata">Label
|
||||
* metadata in the MIP SDK</a>.
|
||||
*
|
||||
* <p>Only {@code Enabled} and {@code SiteId} are mandatory in that contract; the rest are optional
|
||||
* and may be absent on a label written by an older client, so readers here tolerate their absence.
|
||||
*/
|
||||
public record SensitivityLabel(
|
||||
String labelId,
|
||||
String name,
|
||||
String siteId,
|
||||
AssignmentMethod method,
|
||||
Instant setDate,
|
||||
Integer contentBits) {
|
||||
|
||||
/** How the label came to be applied. */
|
||||
public enum AssignmentMethod {
|
||||
/** Applied by default or automatically - e.g. by a policy like this one. */
|
||||
STANDARD,
|
||||
/** Chosen deliberately by a person. */
|
||||
PRIVILEGED;
|
||||
|
||||
String wireValue() {
|
||||
return name().charAt(0) + name().substring(1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static AssignmentMethod parse(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return valueOf(value.trim().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final String KEY_PREFIX = "MSIP_Label_";
|
||||
|
||||
/** Content marks the labelling application applied; a bitmask, per the MIP contract. */
|
||||
public static final int CONTENT_BITS_HEADER = 0x1;
|
||||
|
||||
public static final int CONTENT_BITS_FOOTER = 0x2;
|
||||
public static final int CONTENT_BITS_WATERMARK = 0x4;
|
||||
public static final int CONTENT_BITS_ENCRYPT = 0x8;
|
||||
|
||||
/**
|
||||
* Extended ISO 8601, matching the {@code 2018-11-08T21:13:16-0800} form Microsoft documents.
|
||||
*/
|
||||
private static final DateTimeFormatter SET_DATE =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT)
|
||||
.withZone(ZoneOffset.UTC);
|
||||
|
||||
/**
|
||||
* Microsoft caps each key and value at 255 characters "to maintain compatibility across common
|
||||
* applications".
|
||||
*/
|
||||
static final int MAX_VALUE_LENGTH = 255;
|
||||
|
||||
/** The GUID shape a labelId must take, matching what the read path accepts from a document. */
|
||||
private static final Pattern LABEL_ID = Pattern.compile("^[0-9a-fA-F-]{36}$");
|
||||
|
||||
public SensitivityLabel {
|
||||
if (labelId == null || labelId.isBlank()) {
|
||||
throw new IllegalArgumentException("a sensitivity label needs a labelId");
|
||||
}
|
||||
if (!LABEL_ID.matcher(labelId).matches()) {
|
||||
// labelId is spliced verbatim into XMP/info key names; a non-GUID would let a stray
|
||||
// character (a space, or <, >, &) corrupt or inject the metadata it is written into.
|
||||
throw new IllegalArgumentException("a sensitivity label needs a GUID labelId");
|
||||
}
|
||||
if (siteId == null || siteId.isBlank()) {
|
||||
throw new IllegalArgumentException("a sensitivity label needs a siteId (tenant id)");
|
||||
}
|
||||
}
|
||||
|
||||
/** The {@code MSIP_Label_<GUID>_} prefix this label's keys share. */
|
||||
public String keyPrefix() {
|
||||
return KEY_PREFIX + labelId + "_";
|
||||
}
|
||||
|
||||
/**
|
||||
* This label as the key/value pairs to persist. Optional attributes are omitted when unset
|
||||
* rather than written empty, so a reader cannot mistake "not recorded" for "recorded as blank".
|
||||
*/
|
||||
public Map<String, String> toMetadata() {
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
String prefix = keyPrefix();
|
||||
out.put(prefix + "Enabled", "true");
|
||||
out.put(prefix + "SiteId", siteId);
|
||||
if (method != null) {
|
||||
out.put(prefix + "Method", method.wireValue());
|
||||
}
|
||||
if (setDate != null) {
|
||||
out.put(prefix + "SetDate", SET_DATE.format(setDate));
|
||||
}
|
||||
if (name != null && !name.isBlank()) {
|
||||
out.put(prefix + "Name", truncate(name));
|
||||
}
|
||||
if (contentBits != null) {
|
||||
out.put(prefix + "ContentBits", String.valueOf(contentBits));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a label from the pairs found on a document.
|
||||
*
|
||||
* @param labelId the GUID between the prefix and the attribute name
|
||||
* @param attributes attribute name (e.g. {@code Name}) to value, for that GUID only
|
||||
* @return null when the pairs do not describe an enabled label
|
||||
*/
|
||||
static SensitivityLabel fromAttributes(String labelId, Map<String, String> attributes) {
|
||||
// "DLP products typically validate the existence of this key to identify the
|
||||
// classification label" - an absent or false Enabled means there is no label here.
|
||||
if (!"true".equalsIgnoreCase(attributes.get("Enabled"))) {
|
||||
return null;
|
||||
}
|
||||
String siteId = attributes.get("SiteId");
|
||||
if (siteId == null || siteId.isBlank()) {
|
||||
// SiteId is mandatory in the contract, but a label written by something non-compliant
|
||||
// is still a label; keep it readable rather than throwing on someone else's file.
|
||||
siteId = "unknown";
|
||||
}
|
||||
return new SensitivityLabel(
|
||||
labelId,
|
||||
attributes.get("Name"),
|
||||
siteId,
|
||||
AssignmentMethod.parse(attributes.get("Method")),
|
||||
parseDate(attributes.get("SetDate")),
|
||||
parseInt(attributes.get("ContentBits")));
|
||||
}
|
||||
|
||||
private static Instant parseDate(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return SET_DATE.parse(value.trim(), Instant::from);
|
||||
} catch (RuntimeException e) {
|
||||
try {
|
||||
// Tolerate the plain ISO form some writers use instead.
|
||||
return Instant.parse(value.trim());
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer parseInt(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String truncate(String value) {
|
||||
return value.length() <= MAX_VALUE_LENGTH ? value : value.substring(0, MAX_VALUE_LENGTH);
|
||||
}
|
||||
|
||||
/** Whether the labelling application encrypted the content. */
|
||||
public boolean isProtected() {
|
||||
return contentBits != null && (contentBits & CONTENT_BITS_ENCRYPT) != 0;
|
||||
}
|
||||
}
|
||||
+32
@@ -13,6 +13,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
@@ -43,6 +44,7 @@ public class IntegrationConfigService {
|
||||
private final OwnershipService ownership;
|
||||
private final SecretMasker secretMasker;
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
// Bean-discovered extension points: features that understand a type contribute its config
|
||||
// schema and report what still references a config, without this module depending on them.
|
||||
private final List<IntegrationConfigValidator> validators;
|
||||
@@ -62,6 +64,7 @@ public class IntegrationConfigService {
|
||||
&& !ownership.isAdmin(currentUser)) {
|
||||
throw forbidden("S3 connections can only be created by administrators or team owners");
|
||||
}
|
||||
requireCustomApiAllowed(cfg.getIntegrationType(), currentUser);
|
||||
cfg.setName(require(request.name(), "name"));
|
||||
cfg.setEnabled(request.enabled() == null || request.enabled());
|
||||
cfg.setLocked(request.locked() != null && request.locked());
|
||||
@@ -113,6 +116,9 @@ public class IntegrationConfigService {
|
||||
cfg.setDefaultAccess(request.defaultAccess());
|
||||
}
|
||||
if (request.config() != null) {
|
||||
// Editing the config of a custom integration is the same authoring power as creating
|
||||
// one - it is where the base URL and body live - so it is gated identically.
|
||||
requireCustomApiAllowed(cfg.getIntegrationType(), currentUser);
|
||||
Map<String, Object> merged =
|
||||
secretMasker.merge(readJson(cfg.getConfig()), request.config());
|
||||
validateConfig(cfg.getIntegrationType(), merged);
|
||||
@@ -121,6 +127,32 @@ public class IntegrationConfigService {
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom API integration names its own host, path and body, so it can point the server
|
||||
* anywhere. That is authoring power rather than self-serve configuration: admins only, and the
|
||||
* operator can withdraw it entirely. The vendor presets are not gated here - they carry a fixed
|
||||
* shape, so the worst a user can do is misconfigure their own connection.
|
||||
*/
|
||||
private void requireCustomApiAllowed(IntegrationType type, User currentUser) {
|
||||
if (type != IntegrationType.API) {
|
||||
return;
|
||||
}
|
||||
if (!applicationProperties.getPolicies().isAllowCustomApiIntegrations()) {
|
||||
throw forbidden(
|
||||
"Custom API integrations are disabled on this server"
|
||||
+ " (policies.allowCustomApiIntegrations)");
|
||||
}
|
||||
if (!ownership.isAdmin(currentUser)) {
|
||||
throw forbidden("Custom API integrations can only be created by administrators");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this caller may author custom API integrations, for the UI to offer or hide it. */
|
||||
public boolean canAuthorCustomApi(User currentUser) {
|
||||
return applicationProperties.getPolicies().isAllowCustomApiIntegrations()
|
||||
&& ownership.isAdmin(currentUser);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, User currentUser) {
|
||||
IntegrationConfig cfg = load(id);
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to
|
||||
* that user with the MCP scopes.
|
||||
* API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that
|
||||
* user with the MCP scopes.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
@@ -14,6 +14,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Entity
|
||||
@Table(name = "teams")
|
||||
@EntityListeners(TeamEntityListener.class)
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -28,7 +29,9 @@ public class Team implements Serializable {
|
||||
@Column(name = "team_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", unique = true, nullable = false)
|
||||
// Not unique: SaaS personal teams all share the name "My Team". TeamController enforces
|
||||
// uniqueness for admin-created teams.
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package stirling.software.proprietary.model;
|
||||
|
||||
/** Published once a new {@link Team} row is inserted, so listeners can seed per-team defaults. */
|
||||
public record TeamCreatedEvent(Long teamId, String teamName) {}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package stirling.software.proprietary.model;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.persistence.PostPersist;
|
||||
|
||||
/** Publishes {@link TeamCreatedEvent} on insert; Spring bridges the publisher via a static. */
|
||||
@Component
|
||||
public class TeamEntityListener {
|
||||
|
||||
private static ApplicationEventPublisher publisher;
|
||||
|
||||
@Autowired
|
||||
void setPublisher(ApplicationEventPublisher applicationEventPublisher) {
|
||||
TeamEntityListener.publisher = applicationEventPublisher;
|
||||
}
|
||||
|
||||
@PostPersist
|
||||
public void onCreate(Team team) {
|
||||
if (publisher != null) {
|
||||
publisher.publishEvent(new TeamCreatedEvent(team.getId(), team.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
/** Create-key request body from the portal: just a display name for the new personal key. */
|
||||
public record CreateApiKeyRequest(String name) {}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** Returned once when a key is created: the row plus the plaintext secret, never persisted. */
|
||||
@Builder
|
||||
public record CreatedApiKeyDto(PortalApiKeyDto key, String secret) {}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* One API key as shown in the portal Infrastructure → API Keys tab. Never carries the secret; that
|
||||
* is returned once from {@link CreatedApiKeyDto} at creation time.
|
||||
*/
|
||||
@Builder
|
||||
public record PortalApiKeyDto(
|
||||
String id,
|
||||
String name,
|
||||
String prefix,
|
||||
String created,
|
||||
String lastUsed,
|
||||
/** "active" | "revoked". */
|
||||
String status,
|
||||
long usageToday,
|
||||
long usageMonth,
|
||||
/** Lifetime request count for the key. */
|
||||
long usageTotal) {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** Payload for the API Keys tab: the personal keys the caller owns. */
|
||||
@Builder
|
||||
public record PortalApiKeysResponse(List<PortalApiKeyDto> keys) {}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.proprietary.model.api.docparse;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ExtractTablesApiRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "Response format: CSV text or the structured JSON table list",
|
||||
allowableValues = {"csv", "json"},
|
||||
defaultValue = "csv")
|
||||
private String outputFormat = "csv";
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package stirling.software.proprietary.model.api.docparse;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class RagIngestApiRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Stable identifier for the ingested document; re-ingesting the same id replaces"
|
||||
+ " its chunks. Defaults to a content hash of the uploaded bytes.")
|
||||
private String documentId;
|
||||
|
||||
@Schema(description = "Target chunk size in characters (64-32768)", defaultValue = "512")
|
||||
private int chunkSize = 512;
|
||||
|
||||
@Schema(
|
||||
description = "Overlap between adjacent chunks in characters (0-4096)",
|
||||
defaultValue = "64")
|
||||
private int overlap = 64;
|
||||
|
||||
@Schema(
|
||||
description = "Tier to use: 'auto' picks per document, or force 'basic'/'advanced'",
|
||||
allowableValues = {"auto", "basic", "advanced"},
|
||||
defaultValue = "auto")
|
||||
private String mode = "auto";
|
||||
|
||||
@Schema(
|
||||
description = "Index the document into the built-in knowledge base",
|
||||
defaultValue = "true")
|
||||
private boolean index = true;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Also return the parsed document as a markdown file, for delivery to external"
|
||||
+ " systems (vector DBs, training corpora)",
|
||||
defaultValue = "false")
|
||||
private boolean exportMarkdown = false;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Also return the chunks as a JSONL file (one chunk per line with page span and"
|
||||
+ " heading breadcrumb), ready for external embedding or indexing",
|
||||
defaultValue = "false")
|
||||
private boolean exportChunksJsonl = false;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** One RAG chunk with page span and heading breadcrumb. Mirrors {@code docparse.py DocChunk}. */
|
||||
public record DocChunk(
|
||||
int index, String text, Integer pageStart, Integer pageEnd, List<String> headingPath) {
|
||||
|
||||
public DocChunk {
|
||||
headingPath = headingPath == null ? List.of() : headingPath;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** One extracted table. Mirrors {@code docparse.py DocTable}. */
|
||||
public record DocTable(
|
||||
int page, List<Double> bbox, List<List<String>> cells, String markdown, Double confidence) {
|
||||
|
||||
public DocTable {
|
||||
cells = cells == null ? List.of() : cells;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What the engine can actually do right now; Java caches and republishes this. Mirrors {@code
|
||||
* docparse.py DocparseCapabilities}.
|
||||
*/
|
||||
public record DocparseCapabilities(
|
||||
boolean advancedInstalled,
|
||||
String doclingVersion,
|
||||
String torchVersion,
|
||||
boolean modelsAvailable,
|
||||
String modelsPath,
|
||||
List<String> errors) {
|
||||
|
||||
public DocparseCapabilities {
|
||||
errors = errors == null ? List.of() : errors;
|
||||
}
|
||||
|
||||
/** The addon-absent view used when the engine is disabled, unreachable, or probing failed. */
|
||||
public static DocparseCapabilities absent(String reason) {
|
||||
return new DocparseCapabilities(false, null, null, false, null, List.of(reason));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
/** Merged capability view served by {@code GET /api/v1/docparse/capabilities} (Java side). */
|
||||
public record DocparseCapabilitiesView(
|
||||
boolean enabled,
|
||||
String mode,
|
||||
boolean advancedInstalled,
|
||||
boolean engineReachable,
|
||||
String doclingVersion) {}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* What the caller asked for; {@code AUTO} resolves per request. Wire values are lowercase to match
|
||||
* {@code engine/src/stirling/contracts/docparse.py DocparseMode}.
|
||||
*/
|
||||
public enum DocparseMode {
|
||||
AUTO("auto"),
|
||||
BASIC("basic"),
|
||||
ADVANCED("advanced");
|
||||
|
||||
private final String wire;
|
||||
|
||||
DocparseMode(String wire) {
|
||||
this.wire = wire;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String wire() {
|
||||
return wire;
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static DocparseMode fromWire(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return AUTO;
|
||||
}
|
||||
return valueOf(value.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Which implementation actually served a request. Wire values are lowercase to match {@code
|
||||
* engine/src/stirling/contracts/docparse.py DocparseTier}.
|
||||
*/
|
||||
public enum DocparseTier {
|
||||
BASIC("basic"),
|
||||
ADVANCED("advanced");
|
||||
|
||||
private final String wire;
|
||||
|
||||
DocparseTier(String wire) {
|
||||
this.wire = wire;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String wire() {
|
||||
return wire;
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static DocparseTier fromWire(String value) {
|
||||
return valueOf(value.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
/** Engine request for {@code POST /api/v1/docparse/tables}. */
|
||||
public record ExtractTablesRequest(String fileName, String contentBase64) {}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Engine response for {@code POST /api/v1/docparse/tables}. */
|
||||
public record ExtractTablesResponse(DocparseTier mode, List<DocTable> tables) {
|
||||
|
||||
public ExtractTablesResponse {
|
||||
tables = tables == null ? List.of() : tables;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
|
||||
/**
|
||||
* Engine request for {@code POST /api/v1/docparse/rag-ingest}. Owner semantics mirror {@code POST
|
||||
* /api/v1/documents}: {@code ownerId} is the tenant, {@code readPrincipals} the explicit readers,
|
||||
* and a null {@code expiresAt} keeps the ingested content until an explicit delete. {@code index}
|
||||
* false skips the store (export-only); {@code includeMarkdown}/{@code includeChunks} echo the
|
||||
* parsed content back so the caller can emit corpus files.
|
||||
*/
|
||||
public record RagIngestRequest(
|
||||
String fileName,
|
||||
String documentId,
|
||||
String source,
|
||||
String ownerId,
|
||||
List<String> readPrincipals,
|
||||
Instant expiresAt,
|
||||
List<AiPageText> pages,
|
||||
String contentBase64,
|
||||
int chunkSize,
|
||||
int overlap,
|
||||
DocparseMode mode,
|
||||
boolean index,
|
||||
boolean includeMarkdown,
|
||||
boolean includeChunks) {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.model.docparse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Engine response for {@code POST /api/v1/docparse/rag-ingest}. {@code markdown} and {@code chunks}
|
||||
* are only present when the request asked for them via includeMarkdown/includeChunks.
|
||||
*/
|
||||
public record RagIngestResponse(
|
||||
DocparseTier mode,
|
||||
String documentId,
|
||||
int chunksIndexed,
|
||||
int pages,
|
||||
String markdown,
|
||||
List<DocChunk> chunks) {}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.policy.config;
|
||||
|
||||
/**
|
||||
* A folder path was rejected only because it falls outside the configured/implied allowed roots - a
|
||||
* condition an admin can resolve by adding the root under the Folder Access settings. Distinct from
|
||||
* the guard's other rejections (SaaS mode, the protected config dir), which editing the allowlist
|
||||
* cannot fix, so callers can offer a "go to settings" affordance for this case alone.
|
||||
*/
|
||||
public class FolderAccessDeniedException extends IllegalArgumentException {
|
||||
|
||||
public FolderAccessDeniedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+55
-2
@@ -10,6 +10,7 @@ import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
@@ -22,6 +23,9 @@ import stirling.software.proprietary.policy.source.SourceStore;
|
||||
* <li>denied entirely under the {@code saas} profile;
|
||||
* <li>Stirling's own config dir always rejected, even if an allowed root were misconfigured to
|
||||
* contain it;
|
||||
* <li>Stirling-owned "implied" roots are always permitted (even with none configured): the local
|
||||
* server file-storage directory when that storage provider is enabled, and the pipeline
|
||||
* watched-folder directories, so automations use them without the admin listing them;
|
||||
* <li>must resolve within {@code policies.allowedFolderRoots}; none configured means all denied.
|
||||
* </ol>
|
||||
*
|
||||
@@ -33,18 +37,29 @@ public class FolderAccessGuard {
|
||||
|
||||
public static final String FOLDER_TYPE = "folder";
|
||||
|
||||
/** Reason keys for an implied root, surfaced to the admin UI so it can label each one. */
|
||||
public static final String IMPLIED_SERVER_STORAGE = "serverStorage";
|
||||
|
||||
public static final String IMPLIED_WATCHED_FOLDER = "watchedFolder";
|
||||
|
||||
/** A directory implicitly permitted regardless of {@code allowedFolderRoots}, and why. */
|
||||
public record ImpliedRoot(Path path, String reason) {}
|
||||
|
||||
private final boolean saasActive;
|
||||
private final List<Path> allowedRoots;
|
||||
private final List<ImpliedRoot> impliedRoots;
|
||||
private final List<Path> protectedRoots;
|
||||
private final SourceStore sourceStore;
|
||||
|
||||
public FolderAccessGuard(
|
||||
ApplicationProperties applicationProperties,
|
||||
RuntimePathConfig runtimePathConfig,
|
||||
Environment environment,
|
||||
SourceStore sourceStore) {
|
||||
this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
|
||||
this.allowedRoots =
|
||||
normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots());
|
||||
this.impliedRoots = impliedRoots(applicationProperties.getStorage(), runtimePathConfig);
|
||||
this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath())));
|
||||
this.sourceStore = sourceStore;
|
||||
}
|
||||
@@ -62,18 +77,28 @@ public class FolderAccessGuard {
|
||||
"folder may not point inside a protected Stirling directory");
|
||||
}
|
||||
}
|
||||
// Stirling-owned implied roots are always permitted, even with no configured roots, so
|
||||
// automations work against them out of the box.
|
||||
if (impliedRoots.stream().anyMatch(root -> normalized.startsWith(root.path()))) {
|
||||
return normalized;
|
||||
}
|
||||
if (allowedRoots.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
throw new FolderAccessDeniedException(
|
||||
"folder access is disabled; set policies.allowedFolderRoots to permit it");
|
||||
}
|
||||
boolean within = allowedRoots.stream().anyMatch(normalized::startsWith);
|
||||
if (!within) {
|
||||
throw new IllegalArgumentException(
|
||||
throw new FolderAccessDeniedException(
|
||||
"folder '" + normalized + "' is outside the allowed folder roots");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** The Stirling-owned directories always permitted, with a reason key for each (read-only). */
|
||||
public List<ImpliedRoot> impliedRoots() {
|
||||
return impliedRoots;
|
||||
}
|
||||
|
||||
/** Whether this policy touches a folder source/sink, and so is subject to these rules. */
|
||||
public boolean usesFolderAccess(Policy policy) {
|
||||
boolean readsFolder =
|
||||
@@ -86,6 +111,34 @@ public class FolderAccessGuard {
|
||||
return readsFolder || writesFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stirling-owned directories always permitted regardless of {@code allowedFolderRoots}, so
|
||||
* folder automations work against them out of the box.
|
||||
*/
|
||||
private static List<ImpliedRoot> impliedRoots(
|
||||
ApplicationProperties.Storage storage, RuntimePathConfig runtimePathConfig) {
|
||||
List<ImpliedRoot> roots = new ArrayList<>();
|
||||
for (Path path : serverStorageRoots(storage)) {
|
||||
roots.add(new ImpliedRoot(path, IMPLIED_SERVER_STORAGE));
|
||||
}
|
||||
for (Path path : normalizeAll(runtimePathConfig.getPipelineWatchedFoldersPaths())) {
|
||||
roots.add(new ImpliedRoot(path, IMPLIED_WATCHED_FOLDER));
|
||||
}
|
||||
return List.copyOf(roots);
|
||||
}
|
||||
|
||||
/** The local server file-storage directory, when that storage provider is enabled. */
|
||||
private static List<Path> serverStorageRoots(ApplicationProperties.Storage storage) {
|
||||
if (!storage.isEnabled() || !"local".equalsIgnoreCase(storage.getProvider())) {
|
||||
return List.of();
|
||||
}
|
||||
String basePath = storage.getLocal().getBasePath();
|
||||
if (basePath == null || basePath.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(normalize(Path.of(basePath)));
|
||||
}
|
||||
|
||||
private static List<Path> normalizeAll(List<String> roots) {
|
||||
List<Path> result = new ArrayList<>();
|
||||
for (String root : roots) {
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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 io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditContext;
|
||||
import stirling.software.proprietary.classification.ClassificationRunBiller;
|
||||
|
||||
/**
|
||||
* Meters + audits a client-side (non-AI) classification run so both classify paths bill
|
||||
* identically. Side-effect only; does no classification itself.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/policies")
|
||||
public class ClassificationMeterController {
|
||||
|
||||
/** Audit step label mirrors the AI classify tool so both paths read alike in the trail. */
|
||||
private static final String CLASSIFY_STEP = "/api/v1/ai/tools/classify-and-label";
|
||||
|
||||
/** Client-supplied count cap: the frontend meters one document per call. */
|
||||
private static final int MAX_DOCUMENTS = 10_000;
|
||||
|
||||
private final ObjectProvider<ClassificationRunBiller> biller;
|
||||
|
||||
public ClassificationMeterController(ObjectProvider<ClassificationRunBiller> biller) {
|
||||
this.biller = biller;
|
||||
}
|
||||
|
||||
@PostMapping("/classify/meter")
|
||||
@Operation(
|
||||
summary = "Meter a client-side classification run",
|
||||
description =
|
||||
"Records billing + audit for a non-AI classification performed in the browser."
|
||||
+ " Does no classification itself. Dispatched by the frontend, not for"
|
||||
+ " direct use.")
|
||||
public ResponseEntity<Void> meterClassification(
|
||||
@RequestBody(required = false) ClassifyMeterRequest body, HttpServletRequest request) {
|
||||
int documents = body != null && body.documentCount() != null ? body.documentCount() : 1;
|
||||
if (documents < 1) documents = 1;
|
||||
if (documents > MAX_DOCUMENTS) documents = MAX_DOCUMENTS;
|
||||
String policyName =
|
||||
body != null && body.policyName() != null && !body.policyName().isBlank()
|
||||
? body.policyName()
|
||||
: "Classification";
|
||||
|
||||
// Stamp the run so ControllerAuditAspect records it as a policy run, like the AI path.
|
||||
request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, policyName);
|
||||
request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, List.of(CLASSIFY_STEP));
|
||||
|
||||
ClassificationRunBiller runBiller = biller.getIfAvailable();
|
||||
if (runBiller != null) {
|
||||
try {
|
||||
runBiller.recordClassificationRun(documents);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn(
|
||||
"[classify meter] billing failed; classification proceeds unbilled: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
|
||||
/** Frontend payload: documents classified, plus the policy name for the audit label. */
|
||||
public record ClassifyMeterRequest(
|
||||
String policyName, Integer documentCount, List<String> labels) {}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.AdminApi;
|
||||
import stirling.software.proprietary.policy.config.FolderAccessGuard;
|
||||
|
||||
/**
|
||||
* Read-only admin view of the folder roots that are always permitted for folder automations,
|
||||
* regardless of {@code policies.allowedFolderRoots} (server storage, pipeline watched folders). The
|
||||
* Folder Access settings section renders these so an admin can see what is implicitly allowed and
|
||||
* why, without them being editable. The editable roots themselves live under the {@code policies}
|
||||
* settings section.
|
||||
*/
|
||||
@AdminApi
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
public class FolderAccessSettingsController {
|
||||
|
||||
private final FolderAccessGuard folderAccessGuard;
|
||||
|
||||
@GetMapping("/policies/implied-folder-roots")
|
||||
@Operation(
|
||||
summary = "Implied folder roots",
|
||||
description =
|
||||
"Stirling-managed directories always permitted for folder automations"
|
||||
+ " regardless of policies.allowedFolderRoots. Read-only.")
|
||||
public List<ImpliedFolderRoot> impliedFolderRoots() {
|
||||
return folderAccessGuard.impliedRoots().stream()
|
||||
.map(root -> new ImpliedFolderRoot(root.path().toString(), root.reason()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public record ImpliedFolderRoot(String path, String reason) {}
|
||||
}
|
||||
+55
-22
@@ -68,6 +68,7 @@ import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
|
||||
import stirling.software.proprietary.policy.overview.PolicyOverviewService;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.EditorSource;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceAccessGuard;
|
||||
import stirling.software.proprietary.policy.source.SourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
@@ -123,7 +124,7 @@ public class PolicyController {
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
validateAdHocRun(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
PolicyRunHandle handle =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
|
||||
@@ -144,7 +145,7 @@ public class PolicyController {
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
validateAdHocRun(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
|
||||
SseEmitter emitter =
|
||||
@@ -248,6 +249,7 @@ public class PolicyController {
|
||||
requirePolicyEditingAllowed();
|
||||
Policy owned = withStoredOutputSecrets(resolveOwnership(policy));
|
||||
requireAccessibleSources(owned);
|
||||
requireAccessibleOutput(owned);
|
||||
try {
|
||||
policyValidator.validate(owned);
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -290,6 +292,40 @@ public class PolicyController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A policy's output destination is a {@link Source} used as a write target: it must resolve to
|
||||
* a source in the caller's team, so a client can neither reference a non-existent location nor
|
||||
* reach across teams to write to another team's. The editor is virtual and has no writable
|
||||
* location, so it can't be a destination. The config is then validated on this (request) thread
|
||||
* so an S3 destination's connection is authorization-checked against the caller - the async
|
||||
* delivery worker has no principal. A policy with no reference (inline / editor / one-off) has
|
||||
* nothing to check.
|
||||
*/
|
||||
private void requireAccessibleOutput(Policy policy) {
|
||||
for (String outputId : policy.outputIds()) {
|
||||
Source destination =
|
||||
sourceStore
|
||||
.get(outputId)
|
||||
.filter(sourceAccessGuard::canAccess)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Unknown or inaccessible output source: "
|
||||
+ outputId));
|
||||
if (EditorSource.TYPE.equals(destination.type())) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"The editor can't be used as an output destination");
|
||||
}
|
||||
try {
|
||||
policyValidator.validateOutput(destination.toOutputSpec());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign owner + owning team server-side. Create stamps the current user and their team; update
|
||||
* preserves the existing owner and team after verifying the policy belongs to the caller's team
|
||||
@@ -323,6 +359,7 @@ public class PolicyController {
|
||||
policy.sourceIds(),
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
policy.outputIds(),
|
||||
teamId);
|
||||
}
|
||||
|
||||
@@ -358,16 +395,7 @@ public class PolicyController {
|
||||
}
|
||||
|
||||
private static Policy withOutput(Policy policy, OutputSpec output) {
|
||||
return new Policy(
|
||||
policy.id(),
|
||||
policy.name(),
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.sourceIds(),
|
||||
policy.steps(),
|
||||
output,
|
||||
policy.teamId());
|
||||
return policy.withOutput(output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,18 +588,23 @@ public class PolicyController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-check an ad-hoc run's output while the caller's principal is present (this
|
||||
* request thread). The worker thread that later delivers carries no security context, so an S3
|
||||
* output's connection-access check would be skipped there; without this gate a caller could
|
||||
* reference another tenant's connection by id and write to it (confused deputy). Stored
|
||||
* policies are covered by save-time {@link PolicyValidator#validate} instead.
|
||||
* Authorization-check an ad-hoc run's steps and output while the caller's principal is present
|
||||
* (this request thread). The worker thread that later runs and delivers carries no security
|
||||
* context, so a connection-access check would be skipped there; without this gate a caller
|
||||
* could reference another tenant's connection by id and write to it, or make the server call it
|
||||
* with its stored credentials (confused deputy). Stored policies are covered by save-time
|
||||
* {@link PolicyValidator#validate} instead.
|
||||
*/
|
||||
private void validateAdHocOutput(PipelineDefinition definition) {
|
||||
if (definition.output() == null) {
|
||||
return;
|
||||
}
|
||||
private void validateAdHocRun(PipelineDefinition definition) {
|
||||
try {
|
||||
policyValidator.validateOutput(definition.output());
|
||||
// Steps get the same treatment as the output, and for the same reason: an integration
|
||||
// step dereferences its connection by id on a principal-less worker thread, so this
|
||||
// request thread is the only place that reference can be checked against the caller.
|
||||
policyValidator.validateSteps(definition.steps());
|
||||
// Every destination is checked; an ad-hoc run with no destinations validates nothing.
|
||||
for (OutputSpec output : definition.outputs()) {
|
||||
policyValidator.validateOutput(output);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user