diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index a3573a49df..8f1aab7977 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -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 }}" diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 77c9645476..dde75433a8 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -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: diff --git a/Taskfile.yml b/Taskfile.yml index 705ad4a1db..75183bd4f9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -90,7 +90,6 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" - POLICIES_ENABLED: "true" - task: frontend:dev:proprietary vars: PORT: '{{.EDITOR_PORT}}' diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index d2f0472510..9714f7a1dd 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 285943ee49..0cd71c9bda 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -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. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java index d61a006cd9..5c83e0cde3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java @@ -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"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java index 061c788052..4fa90cc04b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 95fde9304a..71e5b93c4e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java new file mode 100644 index 0000000000..4c8b5f40a5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java @@ -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}). + * + *
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. + * + *
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}/ 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.
*
* 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);
diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties
index 49798d64cd..a6b594dd96 100644
--- a/app/saas/src/main/resources/application-saas.properties
+++ b/app/saas/src/main/resources/application-saas.properties
@@ -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.
diff --git a/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql b/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql
index babdaef562..b07ad1cebf 100644
--- a/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql
+++ b/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql
@@ -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
diff --git a/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql b/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql
index 7dcbaf1c0e..bf50191893 100644
--- a/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql
+++ b/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql
@@ -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,
diff --git a/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql b/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql
index 0719d73fb1..6ad5ce7cc5 100644
--- a/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql
+++ b/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql
@@ -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
diff --git a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql
index 4be3fa07d8..3a3e136437 100644
--- a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql
+++ b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql
@@ -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,
diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java
index d66e4b17fe..0a77de3e7e 100644
--- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java
+++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java
@@ -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
// ---------------------------------------------------------------------------------------
diff --git a/frontend/editor/src/core/constants/featureFlags.ts b/frontend/editor/src/core/constants/featureFlags.ts
index 0ada1cc673..a60770ac3a 100644
--- a/frontend/editor/src/core/constants/featureFlags.ts
+++ b/frontend/editor/src/core/constants/featureFlags.ts
@@ -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;
diff --git a/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts
index efc8f33fa7..e04859a45e 100644
--- a/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts
+++ b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts
@@ -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();
}
diff --git a/frontend/editor/src/desktop/constants/featureFlags.ts b/frontend/editor/src/desktop/constants/featureFlags.ts
deleted file mode 100644
index d003b3e642..0000000000
--- a/frontend/editor/src/desktop/constants/featureFlags.ts
+++ /dev/null
@@ -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;
diff --git a/frontend/editor/src/proprietary/components/policies/README.md b/frontend/editor/src/proprietary/components/policies/README.md
index a08fc01452..19e2b8567e 100644
--- a/frontend/editor/src/proprietary/components/policies/README.md
+++ b/frontend/editor/src/proprietary/components/policies/README.md
@@ -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
diff --git a/frontend/editor/src/proprietary/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/proprietary/components/policies/usePoliciesEnabled.ts
index 48cb41e99b..957913c2af 100644
--- a/frontend/editor/src/proprietary/components/policies/usePoliciesEnabled.ts
+++ b/frontend/editor/src/proprietary/components/policies/usePoliciesEnabled.ts
@@ -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;
}
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx
index 1d1bec13e6..e936d97d00 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx
@@ -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: () => ({
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx
index 638c821669..b2c2d6f5dc 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx
@@ -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 }),
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx
index b6e91186fd..b2193cda55 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx
@@ -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: () => ({
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx
index 7acc1ae2bb..c25f80ce65 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx
@@ -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() }),
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
index 78e170d0d6..a876a785ff 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
@@ -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);
diff --git a/frontend/editor/src/proprietary/constants/featureFlags.ts b/frontend/editor/src/proprietary/constants/featureFlags.ts
index 599ee4ebce..e432b34599 100644
--- a/frontend/editor/src/proprietary/constants/featureFlags.ts
+++ b/frontend/editor/src/proprietary/constants/featureFlags.ts
@@ -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;
diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts
index 6294c914ff..78b747182b 100644
--- a/frontend/editor/src/proprietary/services/policyExport.ts
+++ b/frontend/editor/src/proprietary/services/policyExport.ts
@@ -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]),
);
diff --git a/frontend/editor/src/saas/constants/featureFlags.ts b/frontend/editor/src/saas/constants/featureFlags.ts
deleted file mode 100644
index 4627295798..0000000000
--- a/frontend/editor/src/saas/constants/featureFlags.ts
+++ /dev/null
@@ -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;