Remove policies feature flag (#7031)

# Description of Changes
Removes the feature flags for enabling policies on both the backend and
frontend. We shouldn't be releasing another self-hosted release that
doesn't include policies, so it makes sense to do this now. Builds that
don't have the Processor will just not run policies because they won't
have any. Beyond that, the API should always be available, but checks
whether the user actually has the entitlements to run policies (whether
they have credits/a payment method available)
This commit is contained in:
James Brunton
2026-07-15 14:25:25 +00:00
committed by GitHub
parent 4ee54243f3
commit ed58d90ab8
56 changed files with 378 additions and 156 deletions
-1
View File
@@ -297,7 +297,6 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
POLICIES_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
+2 -4
View File
@@ -26,7 +26,6 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
@@ -41,13 +40,12 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
-1
View File
@@ -90,7 +90,6 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:proprietary
vars:
PORT: '{{.EDITOR_PORT}}'
@@ -206,11 +206,6 @@ public class ApplicationProperties {
@Data
public static class Policies {
/**
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
*/
private boolean enabled = false;
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
@@ -35,6 +35,7 @@ import stirling.software.proprietary.billing.ContentHasher;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
@@ -84,7 +85,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
instanceof ApiKeyAuthenticationToken;
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
request.setAttribute(ATTR_CATEGORY, category);
decision = gate.evaluate(category != BillingCategory.BYPASSED);
// A policy run kicks off billable automation, so block it up front when unentitled
// rather than after its first tool. It carries no automation header itself (category
// BYPASSED), so it's gated here but metered only via its dispatched sub-steps - keeping
// the BYPASSED meter category avoids double-counting.
boolean billable =
category != BillingCategory.BYPASSED || PolicyRunRoutes.matches(request);
decision = gate.evaluate(billable);
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
@@ -6,7 +6,6 @@ import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@@ -30,7 +29,6 @@ import stirling.software.proprietary.policy.source.SourceStore;
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.config;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyAccessGuard {
private final UserServiceInterface userService;
@@ -7,7 +7,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -85,7 +84,6 @@ import stirling.software.proprietary.util.SecretMasker;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyController {
private final PolicyRunner policyRunner;
@@ -0,0 +1,85 @@
package stirling.software.proprietary.policy.controller;
import org.springframework.web.servlet.HandlerMapping;
import jakarta.servlet.http.HttpServletRequest;
/**
* Policy execute-route namespace under {@code /api/v1/policies} - the paths that actually run an
* automation ({@code /run}, {@code /run/stream}, {@code /{id}/run}, {@code /{id}/trigger}).
*
* <p>Single source of truth for both PAYG entitlement gates, so a caller without billing is blocked
* at the start of a run rather than partway through: the saas {@code EntitlementGuard} gates these
* on {@code FeatureGate.AUTOMATION}, and the self-hosted account-link {@code
* InstanceEntitlementInterceptor} treats them as billable. Read/list policy endpoints are
* deliberately excluded so the UI can still show policies and prompt on use.
*
* <p>This is the sole gate between an unentitled caller and a billable run, so the match is exact
* (not a loose suffix) and segment-anchored. {@code PolicyRunRoutesTest} asserts it against every
* mapping on {@code PolicyController}, so a new execute route that isn't classified here fails the
* build rather than silently running for free.
*/
public final class PolicyRunRoutes {
private static final String BASE = "/api/v1/policies";
private PolicyRunRoutes() {}
/**
* True when the request resolved to a policy execute endpoint. Prefers the matched route
* pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI.
*/
public static boolean matches(HttpServletRequest request) {
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String path = pattern instanceof String s ? s : request.getRequestURI();
String rel = relativeToBase(path);
return rel != null && isExecuteRoute(rel);
}
/**
* The path relative to {@code /api/v1/policies}, or null if the request isn't under that base.
* Segment-anchored (the char after the base must be {@code /} or end-of-string) so a sibling
* like {@code /api/v1/policies-x/...} never matches; tolerates a leading context path.
*/
private static String relativeToBase(String path) {
if (path == null) {
return null;
}
int base = path.indexOf(BASE);
if (base < 0) {
return null;
}
int end = base + BASE.length();
if (end < path.length() && path.charAt(end) != '/') {
return null;
}
return path.substring(end);
}
/**
* The execute routes only: {@code /run}, {@code /run/stream}, and the single-segment {@code
* /{id}/run} / {@code /{id}/trigger} (template or concrete id). Read/list/CRUD routes - {@code
* /run/{runId}}, {@code /runs}, {@code /overview}, {@code /triggers}, {@code /{id}}, {@code
* /order}, {@code /{id}/processed-history}, the base list/create - are all excluded.
*/
private static boolean isExecuteRoute(String rel) {
return rel.equals("/run")
|| rel.equals("/run/stream")
|| isSingleIdRoute(rel, "run")
|| isSingleIdRoute(rel, "trigger");
}
/**
* True for exactly {@code /{oneSegment}/<verb>} (the id being a template or a concrete value).
*/
private static boolean isSingleIdRoute(String rel, String verb) {
String suffix = "/" + verb;
if (!rel.endsWith(suffix)) {
return false;
}
String idSegment = rel.substring(0, rel.length() - suffix.length());
return idSegment.length() > 1
&& idSegment.charAt(0) == '/'
&& idSegment.indexOf('/', 1) < 0;
}
}
@@ -9,7 +9,6 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.slf4j.MDC;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -57,7 +56,6 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyEngine {
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
@@ -9,7 +9,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -29,7 +28,6 @@ import stirling.software.proprietary.policy.model.PolicyRun;
*/
@Slf4j
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -5,7 +5,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -35,7 +34,6 @@ import stirling.software.proprietary.policy.source.SourceStore;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunner {
private final PolicyEngine policyEngine;
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -25,7 +24,6 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
@@ -14,7 +14,6 @@ import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -42,7 +41,6 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -6,7 +6,6 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -49,7 +48,6 @@ import software.amazon.awssdk.services.s3.model.S3Object;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3InputSource implements InputSource {
private static final String TYPE = "s3";
@@ -7,7 +7,6 @@ import java.util.Map;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.dao.DataIntegrityViolationException;
@@ -24,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
*/
@Slf4j
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaProcessedLedger implements ProcessedLedger {
private static final int STAMP_CHUNK = 500;
@@ -15,7 +15,6 @@ import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -41,7 +40,6 @@ import stirling.software.proprietary.policy.model.OutputSpec;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderOutputSink implements PolicyOutputSink {
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -5,7 +5,6 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.model.OutputSpec;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
@@ -13,7 +13,6 @@ import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -54,7 +53,6 @@ import software.amazon.awssdk.services.s3.model.S3Exception;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3OutputSink implements PolicyOutputSink {
private static final String TYPE = "s3";
@@ -5,7 +5,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -29,7 +28,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyOverviewService {
private final PolicyStore policyStore;
@@ -4,7 +4,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@@ -44,7 +43,6 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class EmbeddedS3CredentialMigration {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@@ -4,7 +4,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
private final SourceStore sourceStore;
@@ -6,7 +6,6 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -32,7 +31,6 @@ import software.amazon.awssdk.services.s3.S3Configuration;
* users rather than the operator.
*/
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionPool {
private final ApplicationProperties applicationProperties;
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
@@ -39,7 +38,6 @@ import tools.jackson.databind.ObjectMapper;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionResolver {
static final String CONNECTION_ID_OPTION = "connectionId";
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.s3;
import java.net.URI;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -21,7 +20,6 @@ import stirling.software.proprietary.integration.service.IntegrationConfigValida
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3IntegrationValidator implements IntegrationConfigValidator {
private final ApplicationProperties applicationProperties;
@@ -10,7 +10,6 @@ import java.util.function.IntSupplier;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -24,7 +23,6 @@ import org.springframework.stereotype.Service;
* table stays bounded (~one row per source per active hour, for at most 30 days).
*/
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaSourceDocCounter implements SourceDocCounter {
private final SourceDocCountRepository countRepository;
@@ -4,7 +4,6 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -19,7 +18,6 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaSourceStore implements SourceStore {
private final SourceRepository repository;
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -20,7 +19,6 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceAccessGuard {
private final UserServiceInterface userService;
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -43,7 +42,6 @@ import stirling.software.proprietary.util.SecretMasker;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Sources", description = "Reusable policy input connections")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceController {
private final SourceStore sourceStore;
@@ -6,7 +6,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -24,7 +23,6 @@ import stirling.software.proprietary.util.SecretMasker;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceOverviewService {
private final SourceStore sourceStore;
@@ -5,7 +5,6 @@ import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -23,7 +22,6 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaPolicyStore implements PolicyStore {
private final PolicyRepository repository;
@@ -20,7 +20,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -50,7 +49,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.trigger;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Service;
@@ -13,7 +12,6 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyTriggerManager implements SmartLifecycle {
private final List<PolicyTrigger> triggers;
@@ -10,7 +10,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -32,7 +31,6 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ScheduleTrigger implements PolicyTrigger {
private static final String TYPE = "schedule";
@@ -155,6 +155,43 @@ class InstanceEntitlementInterceptorTest {
verifyNoInteractions(entitlementCache);
}
@Test
void gatesPolicyRunUpFrontEvenWithoutAutomationHeader() throws Exception {
// The policy /run call carries no automation header, but must be blocked up front (not
// after its first tool) when the instance is unlinked.
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED));
InstanceEntitlementInterceptor interceptor = interceptor();
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/policies/pol-1/run");
MockHttpServletResponse resp = new MockHttpServletResponse();
assertFalse(interceptor.preHandle(req, resp, new Object()));
assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), resp.getStatus());
assertTrue(resp.getContentAsString().contains("ACCOUNT_LINK_REQUIRED"));
verify(gate).evaluate(true); // gated as billable despite no automation header
}
@Test
void doesNotMeterThePolicyRunEndpointItself() throws Exception {
// Gated up front, but metered only via its dispatched tool sub-steps (category BYPASSED
// here), so the /run request itself never accrues usage.
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
UsageMeterService meter = mock(UsageMeterService.class);
when(meterProvider.getIfAvailable()).thenReturn(meter);
InstanceEntitlementInterceptor interceptor = interceptor();
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/policies/pol-1/run");
MockHttpServletResponse resp = new MockHttpServletResponse();
interceptor.preHandle(req, resp, new Object());
interceptor.afterCompletion(req, resp, new Object(), null);
verifyNoInteractions(meter);
}
private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) {
return new InstanceEntitlement(
true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1));
@@ -0,0 +1,127 @@
package stirling.software.proprietary.policy.controller;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.HandlerMapping;
class PolicyRunRoutesTest {
private static boolean matchesUri(String uri) {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI(uri);
return PolicyRunRoutes.matches(req);
}
private static boolean matchesPattern(String pattern) {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, pattern);
return PolicyRunRoutes.matches(req);
}
@Test
void matchesTheFourExecuteRoutes() {
assertThat(matchesUri("/api/v1/policies/run")).isTrue();
assertThat(matchesUri("/api/v1/policies/run/stream")).isTrue();
assertThat(matchesUri("/api/v1/policies/pol-123/run")).isTrue();
assertThat(matchesUri("/api/v1/policies/pol-123/trigger")).isTrue();
}
@Test
void excludesReadListAndCrudRoutes() {
assertThat(matchesUri("/api/v1/policies")).isFalse(); // list + create
assertThat(matchesUri("/api/v1/policies/runs")).isFalse();
assertThat(matchesUri("/api/v1/policies/run/abc-run-id")).isFalse(); // GET /run/{runId}
assertThat(matchesUri("/api/v1/policies/overview")).isFalse();
assertThat(matchesUri("/api/v1/policies/triggers")).isFalse(); // NB: not "/trigger"
assertThat(matchesUri("/api/v1/policies/order")).isFalse();
assertThat(matchesUri("/api/v1/policies/pol-123")).isFalse();
assertThat(matchesUri("/api/v1/policies/pol-123/processed-history")).isFalse();
}
@Test
void isSegmentAnchoredAndContextPathTolerant() {
assertThat(matchesUri("/stirling/api/v1/policies/pol-123/run")).isTrue();
assertThat(matchesUri("/api/v1/policies-x/pol-123/run"))
.isFalse(); // sibling, not the segment
assertThat(matchesUri("/api/v1/sources/pol/run")).isFalse();
assertThat(matchesUri("/api/v1/misc/compress-pdf")).isFalse();
}
/** Every request mapping on PolicyController, and whether it executes an automation. */
private static final Map<String, Boolean> EXPECTED =
Map.of(
"/api/v1/policies", false, // base: list (GET) + create (POST)
"/api/v1/policies/run", true,
"/api/v1/policies/run/stream", true,
"/api/v1/policies/run/{runId}", false,
"/api/v1/policies/runs", false,
"/api/v1/policies/order", false,
"/api/v1/policies/overview", false,
"/api/v1/policies/triggers", false,
"/api/v1/policies/{policyId}", false, // GET + DELETE
"/api/v1/policies/{policyId}/processed-history", false);
// Split out because Map.of caps at 10 entries; the execute {id} routes live here.
private static final Map<String, Boolean> EXPECTED_ID_EXECUTES =
Map.of(
"/api/v1/policies/{policyId}/run", true,
"/api/v1/policies/{policyId}/trigger", true);
/**
* Fail-safe: this matcher is the sole billing gate, so an unmatched execute route would run
* automations for free. Reconstruct every mapping on PolicyController and assert its
* classification is declared above - a new/renamed route lands as "unclassified" and fails the
* build until someone decides whether it executes an automation.
*/
@Test
void everyControllerMappingIsClassified() {
String base = classMapping();
Arrays.stream(PolicyController.class.getDeclaredMethods())
.filter(m -> AnnotatedElementUtils.hasAnnotation(m, RequestMapping.class))
.forEach(
m -> {
String pattern = base + methodMapping(m);
Boolean expected = expectedFor(pattern);
assertThat(expected)
.as(
"unclassified PolicyController route %s - add it to"
+ " PolicyRunRoutesTest.EXPECTED",
pattern)
.isNotNull();
assertThat(matchesPattern(pattern))
.as("PolicyRunRoutes classification of %s", pattern)
.isEqualTo(expected);
});
}
private static Boolean expectedFor(String pattern) {
if (EXPECTED.containsKey(pattern)) {
return EXPECTED.get(pattern);
}
return EXPECTED_ID_EXECUTES.get(pattern);
}
private static String classMapping() {
RequestMapping rm =
AnnotatedElementUtils.getMergedAnnotation(
PolicyController.class, RequestMapping.class);
return rm == null ? "" : firstOrEmpty(rm);
}
private static String methodMapping(java.lang.reflect.Method m) {
RequestMapping rm = AnnotatedElementUtils.getMergedAnnotation(m, RequestMapping.class);
return rm == null ? "" : firstOrEmpty(rm);
}
private static String firstOrEmpty(RequestMapping rm) {
String[] paths = rm.path().length > 0 ? rm.path() : rm.value();
return paths.length > 0 ? paths[0] : "";
}
}
@@ -31,6 +31,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
@@ -46,9 +47,11 @@ import stirling.software.saas.util.AuthenticationUtils;
*
* <p>Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping}
* (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated
* routes). Admin / info / config endpoints are excluded by the path-pattern in {@code
* PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non-
* billable infra never trips the guard.
* routes), plus two proprietary route families recognised by path since they can't carry the
* annotation: AI document tools ({@link AiToolRoutes} gated on AI_SUPPORT) and policy execute
* endpoints ({@link PolicyRunRoutes} gated on AUTOMATION). Admin / info / config endpoints are
* excluded by the path-pattern in {@code PaygWebMvcConfig} and are additionally skipped here when
* they carry no annotation and match no such family, so non-billable infra never trips the guard.
*
* <p>Decision matrix:
*
@@ -137,13 +140,20 @@ public class EntitlementGuard implements HandlerInterceptor {
// @RequiresFeature; recognise them by path so they're gated on AI_SUPPORT — see
// AiToolRoutes and PaygChargeInterceptor, which classify the same routes as AI.
boolean aiToolRoute = AiToolRoutes.matches(request);
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) {
// Policy execute routes (/api/v1/policies/**/run etc.) are proprietary and can't carry
// @RequiresFeature; recognise them by path and gate on AUTOMATION (mirrors aiToolRoute).
boolean policyRunRoute = PolicyRunRoutes.matches(request);
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute && !policyRunRoute) {
skippedNoAnnotationCounter.increment();
return true;
}
FeatureGate[] required =
aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm);
aiToolRoute
? new FeatureGate[] {FeatureGate.AI_SUPPORT}
: policyRunRoute
? new FeatureGate[] {FeatureGate.AUTOMATION}
: resolveRequiredGates(hm);
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean anonymous = isAnonymous(auth);
@@ -74,10 +74,6 @@ supabase.url=https://${app.supabase.project-ref}.supabase.co
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json
spring.security.oauth2.resourceserver.jwt.audiences=${app.supabase.expected-aud}
# ---------- Policies ----------
# Exposes the /api/v1/policies and /api/v1/sources controllers, engine, stores, and triggers.
policies.enabled=true
# ---------- Multi-tenant scoping ----------
# Restrict the signing user picker to the caller's team; SaaS must be 'team'
# or unrelated tenants leak emails to each other.
@@ -1,4 +1,4 @@
-- Policy engine schema (gated by policies.enabled): persisted policies and the reusable input
-- Policy engine schema: persisted policies and the reusable input
-- connections ("sources") they reference by id. The whole policy/source lives as JSON in the
-- *_json column (authoritative on read); the scalar columns are denormalized copies for querying,
-- notably team_id so a caller's team can be loaded without scanning every team's rows. owner and
@@ -9,8 +9,8 @@
-- instead of scanning a source's whole bucket history - and so the
-- hourly buckets can be pruned without losing it.
--
-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create
-- these, but the migration keeps the schema explicit for the Flyway-managed deployments.
-- Hibernate ddl-auto would also create these, but the migration keeps the schema explicit for the
-- Flyway-managed deployments.
CREATE TABLE IF NOT EXISTS policy_source_doc_counts (
source_id VARCHAR(255) NOT NULL,
@@ -1,4 +1,4 @@
-- Classification labels (gated by policies.enabled): the flat multi-label vocabulary the document
-- Classification labels: the flat multi-label vocabulary the document
-- classifier runs against. One admin-editable row per team. The whole label set lives as JSON in
-- labels_json (authoritative on read). team_id is a natural key and a plain value (not a foreign
-- key) to stay decoupled from the security entities, so classification can be enabled or disabled
@@ -10,8 +10,8 @@
-- the policy's sources, so the table stays near the set of files
-- currently present.
--
-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create
-- this, but the migration keeps the schema explicit for the Flyway-managed deployments.
-- Hibernate ddl-auto would also create this, but the migration keeps the schema explicit for the
-- Flyway-managed deployments.
CREATE TABLE IF NOT EXISTS policy_processed_files (
policy_id VARCHAR(255) NOT NULL,
@@ -198,6 +198,97 @@ class EntitlementGuardTest {
assertThat(body.get("category").asText()).isEqualTo("AI");
}
// ---------------------------------------------------------------------------------------
// Policy execute routes (proprietary; recognised by path, gated on AUTOMATION)
// ---------------------------------------------------------------------------------------
@Test
void policyRunRoute_noAnnotation_isInScopeAndGatedOnAutomation() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
HandlerMethod hm = handlerFor("plainEndpoint"); // no annotations
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/policies/pol-123/run");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(402);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AUTOMATION");
verify(entitlementService).getSnapshot(42L);
}
@Test
void policyRunRoute_anonymous_returns401WithAutomationCategory() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
HandlerMethod hm = handlerFor("plainEndpoint");
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/policies/pol-123/trigger");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(401);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED");
assertThat(body.get("category").asText()).isEqualTo("AUTOMATION");
}
@Test
void policyRunRoute_authenticatedFull_passesThrough() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot());
HandlerMethod hm = handlerFor("plainEndpoint");
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/policies/run");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
}
@Test
void policyReadRoute_notGated_passesThroughEvenDegraded() throws Exception {
// Listing policies must stay ungated so the UI can show them and prompt on use.
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
HandlerMethod hm = handlerFor("plainEndpoint");
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/policies");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
Mockito.verifyNoInteractions(entitlementService);
}
// ---------------------------------------------------------------------------------------
// Anonymous user
// ---------------------------------------------------------------------------------------
@@ -11,11 +11,3 @@
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
// as constant/unreachable conditions by the type checker and linter.
export const WATCHED_FOLDERS_ENABLED: boolean = false;
/**
* Policies — a proprietary, automation-backed feature (like Watched Folders but
* backend-driven, with non-folder triggers). The implementation lives under
* `proprietary/`; this core value stays `false` so the shared sidebar entry
* never appears in the open-source build.
*/
export const POLICIES_ENABLED: boolean = false;
@@ -1,19 +1,5 @@
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode";
/**
* Desktop shadow: policy runs execute + bill via the cloud (POST
* /api/v1/policies/.../run hits the SaaS backend), so the feature must stay
* off in local ("disconnected") and self-hosted modes — otherwise the
* auto-run controller would fire policy runs against a backend that doesn't
* serve them.
*
* Pessimistic SaaS-mode check (starts false): this gate controls whether
* PolicyAutoRunController mounts, and that fires GET /api/v1/policies on
* mount. useSaaSMode()'s optimistic-true default would leak that request
* against the local/self-hosted backend on cold start before the mode
* resolves.
*/
export function usePoliciesEnabled(): boolean {
return POLICIES_ENABLED && useConfirmedSaaSMode();
return useConfirmedSaaSMode();
}
@@ -1,10 +0,0 @@
/**
* 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 the desktop `usePoliciesEnabled` shadow), so
* the flag must be on for that runtime check to ever apply.
*/
export * from "@proprietary/constants/featureFlags";
export const POLICIES_ENABLED: boolean = true;
@@ -4,9 +4,10 @@ Automation-backed document-enforcement policies. The editor side is
**enforcement only**: policies are configured in the admin portal
(`src/portal/views/Policies.tsx`); the editor runs enabled policies on
uploaded files, blocks the file's exit points while a run is in flight, and
badges files a policy has produced. It ships behind the `POLICIES_ENABLED`
feature flag (SaaS build = on; proprietary and core builds = off; desktop
additionally requires an active SaaS connection).
badges files a policy has produced. Always on in the proprietary/SaaS builds;
the core (OSS) build has no implementation (`usePoliciesEnabled` stub = false),
and desktop additionally requires an active SaaS connection (runs bill through
the cloud). The single gate is `components/policies/usePoliciesEnabled.ts`.
## Layout
@@ -1,10 +1,3 @@
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
/**
* Whether policy enforcement is active for this build. Gates mounting the
* headless PolicyAutoRunController. Shadows the core stub; the desktop build
* shadows this again to additionally require an active SaaS connection.
*/
export function usePoliciesEnabled(): boolean {
return POLICIES_ENABLED;
return true;
}
@@ -47,7 +47,6 @@ const mocks = vi.hoisted(() => ({
consumeFiles: vi.fn(),
}));
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
vi.mock("@app/contexts/FileContext", () => ({
useAllFiles: () => ({ fileStubs: mocks.workspace }),
useFileManagement: () => ({
@@ -4,7 +4,6 @@ import { renderHook, act } from "@testing-library/react";
// Two active upload policies, so the auto-run should CHAIN them: fire the first on
// the upload, then the second on the first's output. Stub the contexts + network so
// we can drive the dispatch against the REAL run store.
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
const fileStubs: { id: string; name: string; derivedFromTool?: boolean }[] = [];
vi.mock("@app/contexts/FileContext", () => ({
useAllFiles: () => ({ fileStubs }),
@@ -21,7 +21,6 @@ const mocks = vi.hoisted(() => ({
createStirlingFilesAndStubs: vi.fn(),
}));
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
vi.mock("@app/contexts/FileContext", () => ({
useAllFiles: () => ({ fileStubs: mocks.fileStubs }),
useFileManagement: () => ({
@@ -3,7 +3,6 @@ import { renderHook, act } from "@testing-library/react";
// The auto-run hook reaches into several contexts + the network; stub those so we can drive just
// the queue-rejection retry path against the REAL run store.
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
vi.mock("@app/contexts/FileContext", () => ({
useAllFiles: () => ({ fileStubs: [] }),
useFileManagement: () => ({ addFiles: vi.fn() }),
@@ -23,7 +23,6 @@ import {
} from "@app/contexts/FileContext";
import { fileStorage } from "@app/services/fileStorage";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import i18n from "@app/i18n";
import {
runStoredPolicy,
@@ -247,7 +246,6 @@ export function usePolicyAutoRun(): void {
// of the chain is dispatched by the chaining effect below, each on the previous
// policy's output, so the policies apply cumulatively in order.
useEffect(() => {
if (!POLICIES_ENABLED) return;
const firstCategory = orderedUploadCategories[0];
if (!firstCategory) return;
const backendId = policies[firstCategory]?.backendId;
@@ -281,7 +279,6 @@ export function usePolicyAutoRun(): void {
// next upload policy on that output. Only chains on success (a failed run has no
// output), and only once per run. isDispatched guards re-dispatch across reloads.
useEffect(() => {
if (!POLICIES_ENABLED) return;
for (const run of runs) {
if (run.status !== "COMPLETED" || !run.imported) continue;
if (chained.current.has(run.runId)) continue;
@@ -315,7 +312,6 @@ export function usePolicyAutoRun(): void {
// Poll each in-flight run to a terminal state.
useEffect(() => {
if (!POLICIES_ENABLED) return;
for (const run of runs) {
if (isTerminal(run.status) || polling.current.has(run.runId)) continue;
polling.current.add(run.runId);
@@ -328,7 +324,6 @@ export function usePolicyAutoRun(): void {
// Import each completed run's outputs into the workspace (each output once),
// so the enforced file appears in the app rather than only on the backend.
useEffect(() => {
if (!POLICIES_ENABLED) return;
for (const run of runs) {
if (
run.status !== "COMPLETED" ||
@@ -377,7 +372,7 @@ export function usePolicyAutoRun(): void {
// than leaving them orphaned. Waits until policies are known so server runs can be
// attributed to their category.
useEffect(() => {
if (!POLICIES_ENABLED || reconciled.current) return;
if (reconciled.current) return;
if (Object.keys(policies).length === 0) return;
reconciled.current = true;
void reconcileServerRuns(policies);
@@ -13,11 +13,3 @@
* Watched Folders implementation to navigate to).
*/
export const WATCHED_FOLDERS_ENABLED: boolean = 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 = false;
@@ -29,7 +29,6 @@ import {
} from "@app/components/policies/enforcementQueue";
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
import { alert, updateToast, dismissToast } from "@app/components/toast";
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import i18n from "@app/i18n";
/** Poll cadence + cap for a single export run (≈2.5 min worst case). */
@@ -60,7 +59,6 @@ interface PolicyRunResult {
/** Configured, active policies set to enforce on export (read from the cache). */
function activeExportPolicies(): ExportPolicy[] {
if (!POLICIES_ENABLED) return [];
const labels = new Map(
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
);
@@ -1,9 +0,0 @@
/**
* 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;