mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(policies): lock policies to the SaaS build + profile (#6702)
## What & why
Policies (automation-backed enforcement) execute and bill through the
cloud backend, so the feature should only be available in the hosted
**SaaS** product — not in self-hosted proprietary or core builds. Today
it's enabled in the proprietary build (and the API is exposed in any
proprietary backend), so this locks it to SaaS on both layers.
## Frontend (build-flavor gate)
`POLICIES_ENABLED` is the single gate `usePoliciesEnabled` uses (rail +
auto-run controller).
- `proprietary` flag → **`false`** (self-hosted web no longer shows
policies)
- new `src/saas/constants/featureFlags.ts` → re-exports proprietary
flags, overrides `POLICIES_ENABLED = true`
- new `src/desktop/constants/featureFlags.ts` → same `true` override —
**required**: desktop's `@app` alias has no saas layer, and desktop
already gates policies on `POLICIES_ENABLED && useConfirmedSaaSMode()`,
so without a `true` here that runtime gate could never be satisfied.
Behaviour unchanged: desktop shows policies only when connected to SaaS.
- `PoliciesSidebar.test` mocks the flag on (it tests the component, not
the build gate — same pattern the existing `usePolicyAutoRun.retry.test`
uses).
## Backend (`@Profile("saas")` gate)
The saas backend runs under the `saas` Spring profile (as
`EntitlementGuard`, the AI controllers, etc. already do). The policy
beans are now `@Profile("saas")`, so `/api/v1/policies/*` and the
auto-run triggers exist **only** in the saas backend:
`PolicyController`, `PolicyEngine`, `PolicyRunner`, `PolicyRunRegistry`,
`PolicyValidator`, `JpaPolicyStore`, `FolderInputSource`,
`FolderOutputSink`, `InlineOutputSink`, `PolicyAccessGuard`,
`FolderAccessGuard`, `FolderWatchTrigger`, `ScheduleTrigger`,
`PolicyTriggerManager`.
**Deliberately *not* gated:** `PolicyExecutor` — `AiWorkflowService`
(always-on) injects it to run ad-hoc pipelines, so it stays
profile-free. It only depends on shared infra (`InternalApiClient`,
`ToolMetadataService`, `TempFileManager`, `ObjectMapper`), so leaving it
on is safe. Gating the engine/store/triggers as a set keeps wiring
consistent (nothing un-gated depends on a gated bean).
The saas `PolicyManagementAuthority` impl
(`TeamLeaderPolicyManagementAuthority`, `@Profile("saas")`) satisfies
`PolicyAccessGuard` in the saas context.
`AdminPolicyManagementAuthority` (`@Profile("!saas")`) becomes an unused
orphan in non-saas builds — harmless; left as-is rather than expanding
this PR's scope.
## Testing
- Frontend: full suite **869 pass**; typecheck clean on
proprietary/saas/core.
- Backend: `:proprietary` compiles, spotless clean, policy tests pass,
and the proprietary (non-saas) Spring context still boots with the
policy beans gated out (verified via the MCP `@SpringBootTest`
integration tests — no missing-bean failures).
Net: SaaS web build + desktop-in-SaaS-mode get policies (UI + API);
self-hosted proprietary and core get neither the UI nor the
`/api/v1/policies` endpoints.
This commit is contained in:
+2
@@ -5,6 +5,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -27,6 +28,7 @@ import stirling.software.proprietary.policy.model.Policy;
|
||||
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
|
||||
*/
|
||||
@Component
|
||||
@Profile("saas")
|
||||
public class FolderAccessGuard {
|
||||
|
||||
public static final String FOLDER_TYPE = "folder";
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.config;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.model.Policy;
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyAccessGuard {
|
||||
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -64,6 +65,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
|
||||
@Profile("saas")
|
||||
public class PolicyController {
|
||||
|
||||
private final PolicyRunner policyRunner;
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -53,6 +54,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyEngine {
|
||||
|
||||
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
@@ -28,6 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class PolicyRunRegistry {
|
||||
|
||||
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -26,6 +27,7 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyRunner {
|
||||
|
||||
private final PolicyEngine policyEngine;
|
||||
|
||||
+2
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyValidator {
|
||||
|
||||
private final List<PolicyTrigger> triggers;
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -33,6 +34,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class FolderInputSource implements InputSource {
|
||||
|
||||
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
@@ -30,6 +31,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class FolderOutputSink implements PolicyOutputSink {
|
||||
|
||||
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
|
||||
|
||||
+2
@@ -5,6 +5,7 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
@@ -22,6 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class InlineOutputSink implements PolicyOutputSink {
|
||||
|
||||
private static final String TYPE = "inline";
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -18,6 +19,7 @@ import tools.jackson.databind.ObjectMapper;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class JpaPolicyStore implements PolicyStore {
|
||||
|
||||
private final PolicyRepository repository;
|
||||
|
||||
+2
@@ -20,6 +20,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -45,6 +46,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class FolderWatchTrigger implements PolicyTrigger {
|
||||
|
||||
private static final String TYPE = "folder-watch";
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.trigger;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -12,6 +13,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class PolicyTriggerManager implements SmartLifecycle {
|
||||
|
||||
private final List<PolicyTrigger> triggers;
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -31,6 +32,7 @@ import tools.jackson.databind.ObjectMapper;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Profile("saas")
|
||||
public class ScheduleTrigger implements PolicyTrigger {
|
||||
|
||||
private static final String TYPE = "schedule";
|
||||
|
||||
@@ -233,7 +233,9 @@ export default function RightSidebar() {
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
|
||||
{policiesEnabled && (
|
||||
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
|
||||
)}
|
||||
<div className="tool-panel__collapsed-tools">
|
||||
{collapsedRailItems.map(({ id, tool }) => (
|
||||
<AppTooltip
|
||||
|
||||
@@ -27,6 +27,15 @@ async function openSecurityPolicy(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByText("Set up Security Policy")).toBeVisible();
|
||||
}
|
||||
|
||||
// Policies are a SaaS-only feature: POLICIES_ENABLED is off in the proprietary
|
||||
// and core builds this stubbed suite runs against, so the policy UI never
|
||||
// renders here. Skip unless the app under test is built with policies enabled
|
||||
// and the runner opts in via POLICIES_E2E=1.
|
||||
test.beforeEach(() => {
|
||||
const enabled = ["1", "true"].includes(process.env.POLICIES_E2E ?? "");
|
||||
test.skip(!enabled, "Policies are SaaS-only; set POLICIES_E2E=1 to run");
|
||||
});
|
||||
|
||||
test.describe("Policy editing gate — non-admin (login on)", () => {
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: false },
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Desktop-build feature gates. Shadows `proprietary/constants/featureFlags.ts`
|
||||
* (the desktop `@app/*` alias has no saas layer). Re-exports the proprietary
|
||||
* flags and re-enables Policies: the desktop Policies gate additionally requires
|
||||
* an active SaaS connection (see desktop PoliciesSidebar's `usePoliciesEnabled`),
|
||||
* so the flag must be on for that runtime check to ever apply.
|
||||
*/
|
||||
export * from "@proprietary/constants/featureFlags";
|
||||
|
||||
export const POLICIES_ENABLED: boolean = true;
|
||||
@@ -18,6 +18,10 @@ vi.mock("react-i18next", () => ({
|
||||
I18nextProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Policies ship gated SaaS-only via the build-flavor flag; these tests exercise
|
||||
// the component itself, so force the flag on regardless of the test build flavor.
|
||||
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
|
||||
|
||||
// usePolicies derives `canConfigure` from app-config; with no AppConfigProvider
|
||||
// here `config` is null, which (tri-state gate) hides the edit affordances. Mock
|
||||
// app-config as a single-user deployment (login off) so the local operator can
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
||||
|
||||
/**
|
||||
* Policies — proprietary, automation-backed policy enforcement. Enabled in the
|
||||
* proprietary build so it's reachable while in active development (frontend is
|
||||
* mock/stub-backed; no real server yet). Core stays `false`.
|
||||
* Policies — automation-backed policy enforcement. A SaaS-only feature: runs
|
||||
* execute and bill through the cloud backend, so it's enabled only in the saas
|
||||
* build (which overrides this to `true`) and on desktop when connected to SaaS.
|
||||
* The self-hosted proprietary build and the core build keep it `false`.
|
||||
*/
|
||||
export const POLICIES_ENABLED: boolean = true;
|
||||
export const POLICIES_ENABLED: boolean = false;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* SaaS-build feature gates. Shadows `proprietary/constants/featureFlags.ts` in
|
||||
* the saas build (via the `@app/*` alias). Re-exports the proprietary flags and
|
||||
* overrides only those that differ for the hosted SaaS product.
|
||||
*/
|
||||
export * from "@proprietary/constants/featureFlags";
|
||||
|
||||
/** Policies are a SaaS-only feature — enabled here, off in proprietary/core. */
|
||||
export const POLICIES_ENABLED: boolean = true;
|
||||
Reference in New Issue
Block a user