Merge branch 'main' into py_format_ruff_0_16_2

This commit is contained in:
Ludy
2026-08-19 19:45:38 +02:00
committed by GitHub
30 changed files with 487 additions and 73 deletions
+4 -1
View File
@@ -462,7 +462,10 @@ jobs:
});
cleanup-v2-deployment:
environment: pr-preview
# Tearing a preview down is not a deployment - no deployment object.
environment:
name: pr-preview
deployment: false
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
+4 -1
View File
@@ -9,7 +9,10 @@ permissions:
jobs:
cleanup:
environment: pr-preview
# Tearing a preview down is not a deployment - no deployment object.
environment:
name: pr-preview
deployment: false
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
+3 -1
View File
@@ -20,7 +20,9 @@ permissions:
jobs:
build:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
+6 -2
View File
@@ -42,7 +42,9 @@ jobs:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e-enterprise:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
needs: pick
# 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.
@@ -325,7 +327,9 @@ jobs:
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
multinode-e2e:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
+3 -1
View File
@@ -61,7 +61,9 @@ jobs:
filters: .github/config/.files.yaml
gradle-cache-prime:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
+3 -1
View File
@@ -10,7 +10,9 @@ permissions:
jobs:
check-licence:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
steps:
- name: Harden Runner
+3 -1
View File
@@ -11,7 +11,9 @@ permissions:
jobs:
check-generate-openapi-docs:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
steps:
- name: Harden Runner
+3 -1
View File
@@ -13,7 +13,9 @@ permissions:
jobs:
migration-test:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
+3 -1
View File
@@ -17,7 +17,9 @@ permissions:
jobs:
docker-compose-tests:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
permissions:
actions: write
+3 -1
View File
@@ -11,7 +11,9 @@ permissions:
jobs:
playwright-e2e-live:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -43,7 +43,9 @@ jobs:
generate-frontend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment: ci-bot
environment:
name: ci-bot
deployment: false
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: files-changed
@@ -319,7 +321,9 @@ jobs:
generate-backend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment: ci-bot
environment:
name: ci-bot
deployment: false
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: files-changed
name: Generate Backend License Report
+6 -2
View File
@@ -38,7 +38,9 @@ permissions:
jobs:
determine-matrix:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
@@ -116,7 +118,9 @@ jobs:
env:
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
build-jars:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
+3 -1
View File
@@ -127,7 +127,9 @@ jobs:
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
# of every other feature.
cucumber-nightly:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
name: Cucumber (nightly scenarios + full concurrency)
runs-on: ubuntu-latest
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
+3 -1
View File
@@ -63,7 +63,9 @@ jobs:
determine-matrix:
# Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
# signing environment - release-signing would block every PR run.
environment: ci-signing
environment:
name: ci-signing
deployment: false
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
+3 -1
View File
@@ -37,7 +37,9 @@ 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:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
+5
View File
@@ -27,3 +27,8 @@ app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
# Staging Supabase publishable key (public by design). Ignored here rather than with an
# inline gitleaks:allow because a trailing comment in a .properties file is part of the
# value, so the pragma would end up inside the key.
app/saas/src/main/resources/application-staging.properties:generic-api-key:16
+49 -6
View File
@@ -57,16 +57,57 @@ tasks:
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
platforms: [linux, darwin]
# SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3,
# PROFILES=none -> production against your own SAAS_DB_*. Production has no named
# task on purpose. Use `none`, not an empty value: Go template `default` treats ""
# as absent and would resolve back to dev.
dev:saas:
desc: "Start backend in SaaS flavor against Supabase"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`.
desc: "Start SaaS backend against the current PR's Supabase preview branch"
dotenv: ['app/.env.saas.local', 'app/.env.saas']
vars:
PROFILES: '{{.PROFILES | default "dev"}}'
cmds:
# Don't move this check into a `sh:` var: dotenv is visible in cmds but not
# during var evaluation, so the test would always see an empty value.
- cmd: |
if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then
echo ">> SAAS_DEV_PROJECT_REF is not set."
echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local."
echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead."
exit 1
fi
- task: _run:saas
vars:
PORT: '{{.PORT}}'
PROFILES: '{{.PROFILES}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
staging:saas:
desc: "Start SaaS backend against the shared v3 staging project"
cmds:
- task: _run:saas
vars:
PORT: '{{.PORT}}'
PROFILES: staging
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
_run:saas:
internal: true
dotenv: ['app/.env.saas.local', 'app/.env.saas']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
# Built here rather than inline in the cmds below: the Windows line is an
# unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X
# "none"}} needs escaped quotes that reach the Go template as literal
# backslashes and fail with `unexpected "\" in operand`.
PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
@@ -77,9 +118,11 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
# against SAAS_DB_* (production).
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}"
platforms: [windows]
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}}
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}}
platforms: [linux, darwin]
build:
+64 -10
View File
@@ -5,6 +5,14 @@ version: '3'
# mode flag) or use `--project editor/...` for tsc — so the editor lives
# under frontend/editor/ without each task needing a cd.
vars:
# Dev-only browser-tab label so concurrent worktrees are distinguishable. Only
# the worktree folder basename (e.g. "wt1") is exposed — never the full path,
# hostname, or user. Dropped from production builds.
DEV_LABEL:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
tasks:
install:
desc: "Install dependencies"
@@ -80,16 +88,52 @@ tasks:
OPEN: '{{.OPEN | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
# full path, hostname, or user. Consumed at dev-serve time by vite.config
# and dropped from production builds.
STIRLING_DEV_LABEL:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
# Separate from dev:_run rather than a flag on it: Task sets an `env:` key even
# when its value resolves to empty, and Vite treats an empty process.env VITE_* as
# authoritative over the committed editor/.env, so folding these in blanks Supabase
# config for the core, proprietary and desktop dev servers.
dev:_run:saas:
internal: true
ignore_error: true
# The backend's own env files, so both halves target one project. Paths are
# relative to this taskfile's dir, `frontend`.
dotenv: ['../app/.env.saas.local', '../app/.env.saas']
vars:
PORT: '{{.PORT | default "5173"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
OPEN: '{{.OPEN | default ""}}'
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
SAAS_ENV: '{{.SAAS_ENV}}'
# A real process.env VITE_* beats a committed .env in Vite (loadEnv applies
# process.env last), which is what lets this override editor/.env.
#
# These must stay `sh:`, not Go templates: dotenv values are visible to Task's
# embedded shell but not to templates, where {{.SAAS_DEV_PROJECT_REF}} is
# always empty.
VITE_SUPABASE_URL:
sh: |
case "${SAAS_ENV:-dev}" in
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
esac
echo "https://${ref}.supabase.co"
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
sh: |
case "${SAAS_ENV:-dev}" in
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
esac
cmds:
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
dev:
desc: "Start frontend dev server"
cmds:
@@ -111,13 +155,23 @@ tasks:
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:saas:
desc: "Start frontend dev server in SaaS mode"
desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)"
deps:
- task: prepare
vars: { MODE: saas }
vars:
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
# prod routes to the plain runner, which sets no VITE_SUPABASE_* and so leaves
# the committed editor/.env alone.
RUNNER: '{{if eq .SAAS_ENV "prod"}}dev:_run{{else}}dev:_run:saas{{end}}'
cmds:
- task: dev:_run
vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
- task: '{{.RUNNER}}'
vars:
MODE: saas
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
OPEN: '{{.OPEN}}'
SAAS_ENV: '{{.SAAS_ENV}}'
dev:desktop:
desc: "Start frontend dev server in desktop mode"
+18 -3
View File
@@ -99,11 +99,22 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
# Set SAAS_DEV_PROJECT_REF in app/.env.saas.local to pick the PR.
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
desc: "Start SaaS backend + frontend + engine against the current PR's preview branch"
cmds:
- task: dev:_all
vars: { FRONTEND: saas, BACKEND: saas }
vars: { FRONTEND: saas, BACKEND: saas, SAAS_ENV: dev }
staging:saas:
desc: "Start SaaS backend + frontend + engine against the shared v3 staging project"
cmds:
- task: dev:_all
vars:
FRONTEND: saas
BACKEND: saas
BACKEND_TASK: backend:staging:saas
SAAS_ENV: staging
dev:all:
desc: "Start backend + frontend + engine concurrently on free ports"
@@ -115,6 +126,9 @@ tasks:
vars:
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
BACKEND: '{{.BACKEND | default "proprietary"}}'
BACKEND_TASK: '{{.BACKEND_TASK | default (printf "backend:dev:%s" .BACKEND)}}'
# Only meaningful to the saas frontend; every other flavor ignores it.
SAAS_ENV: '{{.SAAS_ENV | default ""}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
@@ -124,7 +138,7 @@ tasks:
- task: engine:dev
vars:
PORT: '{{.ENGINE_PORT}}'
- task: 'backend:dev:{{.BACKEND}}'
- task: '{{.BACKEND_TASK}}'
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
@@ -134,6 +148,7 @@ tasks:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
SAAS_ENV: '{{.SAAS_ENV}}'
# ============================================================
# Build
+35 -17
View File
@@ -1,15 +1,16 @@
###############################################################################
# Stirling-PDF SaaS environment defaults.
# Stirling-PDF SaaS environment defaults. Committed, non-secret. Real values for secrets go in
# .env.saas.local, which is loaded first and wins. Do not commit that file.
#
# This file is committed and provides non-secret defaults loaded by
# `task backend:dev:saas`. Put real values for secrets (passwords, project
# refs, edge function secrets) in `.env.saas.local` - any variable set there
# takes precedence over what's defined here.
# Three environments, each deriving its Supabase URLs, JWT issuer and JWKS from one project ref:
#
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
###############################################################################
# prod PROFILES=none SAAS_DB_* the live project
# staging PROFILES=staging SAAS_STAGING_* pinned to v3, always there
# dev PROFILES=dev SAAS_DEV_* follows a SaaS PR's preview branch
#
# dev is the default for `task backend:dev:saas`. Use staging for somewhere stable; use dev when
# testing an open SaaS PR, since its preview branch is the only place those migrations are applied.
# ---------- Supabase project ----------
# ---------- Supabase project (prod / no-profile) ----------
# Project reference (the subdomain part of <ref>.supabase.co). Required.
# Set in .env.saas.local.
SAAS_DB_PROJECT_REF=
@@ -17,18 +18,35 @@ SAAS_DB_PROJECT_REF=
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
SUPABASE_EDGE_FUNCTION_SECRET=
# ---------- Database (saas profile) ----------
# Direct JDBC URL to the Supabase Postgres. Required when running the plain
# `saas` profile (i.e. without `--spring.profiles.include=dev`).
# ---------- Database (no profile) ----------
# Direct JDBC URL to the Supabase Postgres. Required when running without
# `--spring.profiles.include=...`.
# Example: jdbc:postgresql://db.<project-ref>.supabase.co:5432/postgres
SAAS_DB_URL=
SAAS_DB_USERNAME=postgres
SAAS_DB_PASSWORD=
# ---------- Database (dev profile overrides) ----------
# Used when `--spring.profiles.include=dev` is active. The dev profile
# defaults the URL/username to the shared dev Supabase project, but the
# password must still be provided in .env.saas.local.
SAAS_DEV_DB_URL=
# ---------- staging profile ----------
# The shared long-lived v3 project. application-staging.properties defaults the ref,
# URL, database host and meter endpoint, so staging needs only the password, in
# .env.saas.local. Set SAAS_STAGING_PROJECT_REF to repoint it; everything derives.
#
# The ref and publishable key are duplicated here because the task derives the
# frontend's VITE_SUPABASE_* from them and a shell cannot read a Spring default.
# Neither is secret: the ref is a public subdomain, the key ships in the bundle.
SAAS_STAGING_PROJECT_REF=qacaivhsjtftfwtgjvva
SAAS_STAGING_PUBLISHABLE_KEY=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
SAAS_STAGING_DB_USERNAME=postgres
SAAS_STAGING_DB_PASSWORD=
# ---------- dev profile ----------
# The SaaS PR's Supabase preview branch. Take the ref from that PR's "Supabase
# Preview" check; the profile derives URL, JWT issuer, JWKS, meter endpoint and
# database host from it, so this one value follows a different PR.
#
# A preview branch has its own password and keys; the parent project's will not
# authenticate. Both go in .env.saas.local, along with the ref.
SAAS_DEV_PROJECT_REF=
SAAS_DEV_PUBLISHABLE_KEY=
SAAS_DEV_DB_USERNAME=postgres
SAAS_DEV_DB_PASSWORD=
@@ -26,6 +26,11 @@ public class AdminPolicyManagementAuthority implements PolicyManagementAuthority
return userService.isCurrentUserAdmin();
}
@Override
public boolean canTriggerPolicies() {
return userService.isCurrentUserAdmin();
}
@Override
public Long currentUserTeamId() {
String username = userService.getCurrentUsername();
@@ -12,6 +12,17 @@ public interface PolicyManagementAuthority {
/** Whether the current user may create, edit, or delete policies (for their own team). */
boolean canEditPolicies();
/**
* Whether the current user may run a policy against its <em>configured sources</em> (the manual
* "run now" sweep). Kept separate from {@link #canEditPolicies()} because the two are distinct
* capabilities, even where a deployment grants both to the same people: a sweep operates on the
* team's configured sources using the server's stored connection credentials, which makes it a
* policy-management capability rather than ordinary use. Running a policy over the caller's
* <em>own</em> uploaded files is not covered by this and stays open to every team member — that
* is ordinary editor enforcement.
*/
boolean canTriggerPolicies();
/**
* The team that scopes the current user's policies — the team a new policy is stamped with and
* the only team whose policies the user may see/run/edit. {@code null} when it can't be
@@ -432,9 +432,10 @@ public class PolicyController {
* admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by
* {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume
* re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two
* covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login
* disabled) have no such role, so they trust the local operator. The path allowlist for folder
* sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
* covers them all; runs over the caller's own files ({@code /{id}/run}) stay open to the team,
* while source sweeps are gated by {@link #requirePolicySweepAllowed}. Single-user deployments
* (login disabled) have no such role, so they trust the local operator. The path allowlist for
* folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
*/
private void requirePolicyEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
@@ -447,6 +448,25 @@ public class PolicyController {
}
}
/**
* Sweeping a policy's configured sources requires the same role as managing policies: the sweep
* operates on the team's configured sources using the server's stored connection credentials,
* which makes it a policy-management capability rather than ordinary use, and team scoping on
* its own does not express that. Deliberately narrower than it looks: it gates only the sweep,
* not {@link #runStoredPolicy}, because running a policy over documents the caller supplied is
* ordinary editor enforcement that every member performs on upload and export.
*/
private void requirePolicySweepAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canTriggerPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Not permitted to run this policy against its configured sources");
}
}
@GetMapping
@Operation(
summary = "List policies",
@@ -571,8 +591,10 @@ public class PolicyController {
+ " the enabled flag (which only gates automatic triggering). Returns"
+ " the ids of the runs started (poll the run-status endpoint for each)"
+ " plus what the sweep skipped - already-processed, parked-by-failure,"
+ " and in-flight counts - so an empty result explains itself.")
+ " and in-flight counts - so an empty result explains itself. Requires"
+ " the policy-management role.")
public ResponseEntity<SweepOutcome> trigger(@PathVariable String policyId) {
requirePolicySweepAllowed();
Policy policy =
policyStore
.get(policyId)
@@ -39,6 +39,18 @@ class AdminPolicyManagementAuthorityTest {
assertFalse(authority().canEditPolicies());
}
@Test
void adminMayTriggerPolicies() {
when(userService.isCurrentUserAdmin()).thenReturn(true);
assertTrue(authority().canTriggerPolicies());
}
@Test
void nonAdminMayNotTriggerPolicies() {
when(userService.isCurrentUserAdmin()).thenReturn(false);
assertFalse(authority().canTriggerPolicies());
}
@Test
void currentUserTeamIdResolvesFromTheCurrentUsersTeam() {
Team team = new Team();
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -738,5 +739,78 @@ class PolicyControllerTest {
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
@Test
@DisplayName("trigger is forbidden for a team member who cannot manage policies")
void triggerForbiddenForMember() {
// Sweeping a policy's configured sources is a policy-management capability, so being
// in the policy's team is not on its own enough to perform it.
applicationProperties.getSecurity().setEnableLogin(true);
when(policyManagementAuthority.canTriggerPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.trigger("a"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
// Rejected before the policy is looked up, so no run starts.
verify(policyRunner, never()).run(any());
verify(policyStore, never()).get(any());
}
@Test
@DisplayName("trigger runs for a caller who may manage policies")
void triggerAllowedForLeader() {
applicationProperties.getSecurity().setEnableLogin(true);
when(policyManagementAuthority.canTriggerPolicies()).thenReturn(true);
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0);
when(policyRunner.run(p)).thenReturn(outcome);
ResponseEntity<SweepOutcome> response = controller.trigger("a");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).isEqualTo(outcome);
}
@Test
@DisplayName("trigger skips the role check when login is disabled")
void triggerTrustsTheLocalOperator() {
// Single-user deployments have no roles at all; the gate must not lock them out of
// their
// own sweeps.
applicationProperties.getSecurity().setEnableLogin(false);
Policy p = policy("a", null);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0);
when(policyRunner.run(p)).thenReturn(outcome);
assertThat(controller.trigger("a").getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
verify(policyManagementAuthority, never()).canTriggerPolicies();
}
@Test
@DisplayName("running a policy over the caller's own files stays open to any member")
void storedRunIsNotGatedByRole() {
// Editor enforcement: every member's upload/export runs the team's stored policies on
// their own documents. Gating this the way the sweep is gated would break the editor.
applicationProperties.getSecurity().setEnableLogin(true);
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP)))
.thenReturn(handle("run-9"));
ResponseEntity<JobResponse<Void>> response =
assertDoesNotThrow(() -> controller.runStoredPolicy("a", new PolicyRunFiles()));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
verify(policyManagementAuthority, never()).canTriggerPolicies();
verify(policyManagementAuthority, never()).canEditPolicies();
}
}
}
@@ -0,0 +1,53 @@
package stirling.software.saas.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
/** Logs which Supabase project this backend is talking to, and its schema policy. */
@Slf4j
@Component
@Profile({"dev", "staging"})
public class SaasProjectNotice {
private final Environment environment;
private final String projectRef;
private final String ddlAuto;
public SaasProjectNotice(
Environment environment,
@Value("${app.supabase.project-ref:unknown}") String projectRef,
@Value("${spring.jpa.hibernate.ddl-auto:none}") String ddlAuto) {
this.environment = environment;
this.projectRef = projectRef;
this.ddlAuto = ddlAuto;
}
@EventListener(ApplicationReadyEvent.class)
public void announceProject() {
boolean staging = environment.matchesProfiles("staging");
if (staging) {
log.info(
"""
SaaS staging profile: Supabase project {}, ddl-auto={}. This is the SHARED \
long-lived environment, so its data and schema are not yours alone. Testing an \
open SaaS PR? Use that PR's preview branch instead \
(SAAS_DEV_PROJECT_REF in app/.env.saas.local); staging will not have its \
migrations.\
""",
projectRef,
ddlAuto);
return;
}
log.info(
"SaaS dev profile: Supabase preview branch {}, ddl-auto={}. Disposable, so Hibernate"
+ " is allowed to add the inherited tables the migrations do not create.",
projectRef,
ddlAuto);
}
}
@@ -25,6 +25,11 @@ public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuth
return teamSecurity.isCurrentUserTeamLeader();
}
@Override
public boolean canTriggerPolicies() {
return teamSecurity.isCurrentUserTeamLeader();
}
@Override
public Long currentUserTeamId() {
return teamSecurity.currentUserTeamId();
@@ -1,32 +1,40 @@
# SaaS dev profile. Points at the dev Supabase project.
# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev
# SaaS dev profile: follows the Supabase preview branch of the SaaS PR under test.
# One variable switches PR, SAAS_DEV_PROJECT_REF; everything else derives from it.
# Want a stable shared environment instead? Use the staging profile.
spring.config.import=optional:classpath:application-dev-local.properties
app.supabase.project-ref=qacaivhsjtftfwtgjvva
# Let Hibernate reconcile the entity tables so a fresh preview branch heals itself. A branch is built
# from the Supabase migrations, which cover the SaaS-owned tables but not the ~28 inherited from the
# self-hosted app -- those have only ever been created by ddl-auto. Safe here because a preview branch
# is disposable and `update` only ever adds; staging pins `none`, so keep this profile-scoped.
spring.jpa.hibernate.ddl-auto=update
stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co
stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
# From the PR's "Supabase Preview" check. Required with no fallback: ddl-auto=update above must never
# be aimed at the shared project.
app.supabase.project-ref=${SAAS_DEV_PROJECT_REF}
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}}
stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co
# Per-branch, not derivable. Dashboard > Settings > API.
stirling.supabase.publishable-key=${SAAS_DEV_PUBLISHABLE_KEY}
# Override the whole URL if the branch needs the pooler host rather than the direct one.
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-dev-${user.name}}
spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres}
# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=...
# A preview branch has its own password; the parent project's will not authenticate.
spring.datasource.password=${SAAS_DEV_DB_PASSWORD:}
# Conservative dev pool sizing.
spring.datasource.hikari.maximum-pool-size=2
spring.datasource.hikari.minimum-idle=1
spring.datasource.hikari.idle-timeout=60000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.keepalive-time=300000
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name}
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-dev-${user.name}
logging.level.stirling.software.saas=DEBUG
logging.level.org.springframework.security.oauth2.jwt=WARN
logging.level.org.springframework.security.oauth2.server.resource=WARN
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
# shared secret the team-invitation flow uses — no service-role key in the Java env).
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
# The billing portal is NOT here — the FE calls create-customer-portal-session directly.
payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units
# Server-to-server meter call. Auth rides SUPABASE_EDGE_FUNCTION_SECRET; blank secret means the meter
# service no-ops with a WARN rather than failing the boot.
payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units
@@ -0,0 +1,39 @@
# SaaS staging profile: the long-lived shared v3 project, pinned so it is still there tomorrow.
# For work on an open SaaS PR use the dev profile, which follows that PR's preview branch.
spring.config.import=optional:classpath:application-staging-local.properties
# Stated rather than inherited: application-saas.properties defaults to `update`, and staging's
# schema is shared and RLS-dependent, so it must not be reconciled by Hibernate.
spring.jpa.hibernate.ddl-auto=none
# Committed as a default rather than a literal, so staging needs no setup but stays repointable.
# Neither the ref nor the publishable key is secret: the ref is a public subdomain, the key ships in
# the browser bundle. Everything below derives from the ref, so an override follows through.
app.supabase.project-ref=${SAAS_STAGING_PROJECT_REF:qacaivhsjtftfwtgjvva}
stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co
stirling.supabase.publishable-key=${SAAS_STAGING_PUBLISHABLE_KEY:sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY}
spring.datasource.url=${SAAS_STAGING_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-staging-${user.name}}
spring.datasource.username=${SAAS_STAGING_DB_USERNAME:postgres}
# Password not committed; export SAAS_STAGING_DB_PASSWORD or pass --spring.datasource.password=...
spring.datasource.password=${SAAS_STAGING_DB_PASSWORD:}
# Conservative pool sizing: this is a shared project, so don't hold connections others need.
spring.datasource.hikari.maximum-pool-size=2
spring.datasource.hikari.minimum-idle=1
spring.datasource.hikari.idle-timeout=60000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.keepalive-time=300000
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-staging-${user.name}
logging.level.stirling.software.saas=DEBUG
logging.level.org.springframework.security.oauth2.jwt=WARN
logging.level.org.springframework.security.oauth2.server.resource=WARN
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
# shared secret the team-invitation flow uses — no service-role key in the Java env).
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units
@@ -32,6 +32,18 @@ class TeamLeaderPolicyManagementAuthorityTest {
assertFalse(authority().canEditPolicies());
}
@Test
void teamLeaderMayTriggerPolicies() {
when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true);
assertTrue(authority().canTriggerPolicies());
}
@Test
void nonLeaderMayNotTriggerPolicies() {
when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false);
assertFalse(authority().canTriggerPolicies());
}
@Test
void currentUserTeamIdDelegatesToTeamSecurity() {
when(teamSecurity.currentUserTeamId()).thenReturn(9L);