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}/} (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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index d54caedd14..2ceabae656 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java index f0a0a9d6b0..edc906461e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java @@ -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 runs = new ConcurrentHashMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 8575e95e61..866fe0910d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index 92c4cf95d9..cac05765a0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -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 triggers; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java index 9270cc0c1b..4f02d13c82 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java index fbbcfc4549..34576b47b9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java @@ -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"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java index 43f0e6795d..02fce08763 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index 52da21208a..c06f18a846 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java index 904eb20523..78531eaa9c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java @@ -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"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java index fb590b3c2e..b0bd210e10 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java @@ -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"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index 5ba7856606..117d78de40 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java index 398b2bd709..e324cda525 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java @@ -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(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java index 00d1b28e6c..276ca1b002 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java index 4118557dbd..145295d462 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java index d15839aebe..f8b39b6e36 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java @@ -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"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java index ee36ba5693..026c3f2377 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java index af67953d77..11a82187c9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java index 69249fb700..259159a9db 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java index f5a14282c9..72ff59e914 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java index 524b8c369c..b9a19e9d2f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index f202a30e23..1f5eb18174 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 08b2942b14..41da23c734 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -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; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index dee78e8488..a4470a418b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -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"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java index 7971e6b490..cf63853be9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java @@ -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 triggers; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index 32ba4ec77d..2018ffd126 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -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"; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java index 13a2606105..082da85fd7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java @@ -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)); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java new file mode 100644 index 0000000000..f1be12ae1d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java @@ -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 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 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] : ""; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index 54ebd24c04..ae8474fa42 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -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; * *

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;