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:
Reece Browne
2026-06-18 11:05:55 +00:00
committed by GitHub
parent 9a3bc6b47f
commit 8f46ca0d92
20 changed files with 68 additions and 5 deletions
@@ -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";
@@ -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;
@@ -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;
@@ -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
@@ -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<>();
@@ -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,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;
@@ -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;
@@ -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;
@@ -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";
@@ -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;
@@ -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";
@@ -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;
@@ -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;