From 29002d0b8281d3af7e27b0ae972f2adee113e258 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 23 Jul 2026 11:19:24 +0000 Subject: [PATCH] Improve UX around source folders in Processor (#7101) # Description of Changes Adds implicitly defined folders to the list of locations that folder sources can look in, including the legacy watchedFolder folders, and the server storage location (if enabled). Also adds a settings UI for defining the list of allowed folders instead of having to manually edit `settings.yml` (please excuse the styling, that's the standard styling of the Processor, hoping it gets fixed by one of the styling PRs). image image --- .../common/model/ApplicationProperties.java | 7 +- .../src/main/resources/settings.yml.template | 8 +- .../config/FolderAccessDeniedException.java | 14 + .../policy/config/FolderAccessGuard.java | 57 ++- .../FolderAccessSettingsController.java | 42 +++ .../policy/source/SourceController.java | 30 ++ .../api/AdminSettingsController.java | 4 +- .../policy/config/FolderAccessGuardTest.java | 90 ++++- .../policy/input/FolderInputSourceTest.java | 6 +- .../policy/output/FolderOutputSinkTest.java | 11 +- .../public/locales/en-US/translation.toml | 25 ++ frontend/editor/public/og-metadata.json | 6 + frontend/editor/public/og-metadata.saas.json | 6 + .../core/components/shared/config/types.ts | 1 + frontend/editor/src/portal/api/sources.ts | 24 +- .../src/portal/views/SourceBuilder.test.tsx | 63 +++- .../editor/src/portal/views/SourceBuilder.tsx | 37 +- .../shared/config/configNavSections.tsx | 9 + .../AdminFolderAccessSection.tsx | 328 ++++++++++++++++++ .../AdminStorageSharingSection.tsx | 2 +- 20 files changed, 746 insertions(+), 24 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessDeniedException.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx 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 b20dc298f7..43f27eec2d 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 @@ -208,9 +208,10 @@ public class ApplicationProperties { public static class Policies { /** * 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 - * pointed at an arbitrary server path. Stirling's own config directory is always - * off-limits, and folder access is always disabled in SaaS mode regardless of this list. + * write to. Empty (the default) disables folder access except to implicitly defined + * folders, such as server storage folders (if enabled) and the pipeline watched folders. + * Stirling's own config directory is always off-limits, and folder access is always + * disabled in SaaS mode regardless of this list. */ private List allowedFolderRoots = new java.util.ArrayList<>(); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 2d227c34a2..fd9cc13c13 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -398,9 +398,11 @@ aiEngine: policies: # Folder automations can read from and write to the directories you allow here, so treat this as a - # security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs - # entirely; list absolute directories to permit folder access only within them. Stirling's own - # config directory is always off-limits, and folder access is always disabled in SaaS mode. + # security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs, + # other than from directories that are always permitted like server file-storage and watched folders. + # List absolute directories to permit folder access within them. + # Stirling's own config directory is always off-limits, and folder access is always + # disabled in SaaS mode. allowedFolderRoots: [] # e.g. ["/data/inbox", "/data/outbox"] scheduleSweepSeconds: 60 # How often (seconds) scheduled policies are checked for being due watchReconcileSeconds: 300 # How often (seconds) folder-watch re-syncs watches and re-runs as a safety net for missed events diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessDeniedException.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessDeniedException.java new file mode 100644 index 0000000000..12b4ab0855 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessDeniedException.java @@ -0,0 +1,14 @@ +package stirling.software.proprietary.policy.config; + +/** + * A folder path was rejected only because it falls outside the configured/implied allowed roots - a + * condition an admin can resolve by adding the root under the Folder Access settings. Distinct from + * the guard's other rejections (SaaS mode, the protected config dir), which editing the allowlist + * cannot fix, so callers can offer a "go to settings" affordance for this case alone. + */ +public class FolderAccessDeniedException extends IllegalArgumentException { + + public FolderAccessDeniedException(String message) { + super(message); + } +} 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 5c83e0cde3..92ab91e99c 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 @@ -10,6 +10,7 @@ import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.source.SourceStore; @@ -22,6 +23,9 @@ import stirling.software.proprietary.policy.source.SourceStore; *
  • denied entirely under the {@code saas} profile; *
  • Stirling's own config dir always rejected, even if an allowed root were misconfigured to * contain it; + *
  • Stirling-owned "implied" roots are always permitted (even with none configured): the local + * server file-storage directory when that storage provider is enabled, and the pipeline + * watched-folder directories, so automations use them without the admin listing them; *
  • must resolve within {@code policies.allowedFolderRoots}; none configured means all denied. * * @@ -33,18 +37,29 @@ public class FolderAccessGuard { public static final String FOLDER_TYPE = "folder"; + /** Reason keys for an implied root, surfaced to the admin UI so it can label each one. */ + public static final String IMPLIED_SERVER_STORAGE = "serverStorage"; + + public static final String IMPLIED_WATCHED_FOLDER = "watchedFolder"; + + /** A directory implicitly permitted regardless of {@code allowedFolderRoots}, and why. */ + public record ImpliedRoot(Path path, String reason) {} + private final boolean saasActive; private final List allowedRoots; + private final List impliedRoots; private final List protectedRoots; private final SourceStore sourceStore; public FolderAccessGuard( ApplicationProperties applicationProperties, + RuntimePathConfig runtimePathConfig, Environment environment, SourceStore sourceStore) { this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas"); this.allowedRoots = normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots()); + this.impliedRoots = impliedRoots(applicationProperties.getStorage(), runtimePathConfig); this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath()))); this.sourceStore = sourceStore; } @@ -62,18 +77,28 @@ public class FolderAccessGuard { "folder may not point inside a protected Stirling directory"); } } + // Stirling-owned implied roots are always permitted, even with no configured roots, so + // automations work against them out of the box. + if (impliedRoots.stream().anyMatch(root -> normalized.startsWith(root.path()))) { + return normalized; + } if (allowedRoots.isEmpty()) { - throw new IllegalArgumentException( + throw new FolderAccessDeniedException( "folder access is disabled; set policies.allowedFolderRoots to permit it"); } boolean within = allowedRoots.stream().anyMatch(normalized::startsWith); if (!within) { - throw new IllegalArgumentException( + throw new FolderAccessDeniedException( "folder '" + normalized + "' is outside the allowed folder roots"); } return normalized; } + /** The Stirling-owned directories always permitted, with a reason key for each (read-only). */ + public List impliedRoots() { + return impliedRoots; + } + /** Whether this policy touches a folder source/sink, and so is subject to these rules. */ public boolean usesFolderAccess(Policy policy) { boolean readsFolder = @@ -86,6 +111,34 @@ public class FolderAccessGuard { return readsFolder || writesFolder; } + /** + * Stirling-owned directories always permitted regardless of {@code allowedFolderRoots}, so + * folder automations work against them out of the box. + */ + private static List impliedRoots( + ApplicationProperties.Storage storage, RuntimePathConfig runtimePathConfig) { + List roots = new ArrayList<>(); + for (Path path : serverStorageRoots(storage)) { + roots.add(new ImpliedRoot(path, IMPLIED_SERVER_STORAGE)); + } + for (Path path : normalizeAll(runtimePathConfig.getPipelineWatchedFoldersPaths())) { + roots.add(new ImpliedRoot(path, IMPLIED_WATCHED_FOLDER)); + } + return List.copyOf(roots); + } + + /** The local server file-storage directory, when that storage provider is enabled. */ + private static List serverStorageRoots(ApplicationProperties.Storage storage) { + if (!storage.isEnabled() || !"local".equalsIgnoreCase(storage.getProvider())) { + return List.of(); + } + String basePath = storage.getLocal().getBasePath(); + if (basePath == null || basePath.isBlank()) { + return List.of(); + } + return List.of(normalize(Path.of(basePath))); + } + private static List normalizeAll(List roots) { List result = new ArrayList<>(); for (String root : roots) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java new file mode 100644 index 0000000000..1b39aee106 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java @@ -0,0 +1,42 @@ +package stirling.software.proprietary.policy.controller; + +import java.util.List; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.annotations.api.AdminApi; +import stirling.software.proprietary.policy.config.FolderAccessGuard; + +/** + * Read-only admin view of the folder roots that are always permitted for folder automations, + * regardless of {@code policies.allowedFolderRoots} (server storage, pipeline watched folders). The + * Folder Access settings section renders these so an admin can see what is implicitly allowed and + * why, without them being editable. The editable roots themselves live under the {@code policies} + * settings section. + */ +@AdminApi +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +public class FolderAccessSettingsController { + + private final FolderAccessGuard folderAccessGuard; + + @GetMapping("/policies/implied-folder-roots") + @Operation( + summary = "Implied folder roots", + description = + "Stirling-managed directories always permitted for folder automations" + + " regardless of policies.allowedFolderRoots. Read-only.") + public List impliedFolderRoots() { + return folderAccessGuard.impliedRoots().stream() + .map(root -> new ImpliedFolderRoot(root.path().toString(), root.reason())) + .toList(); + } + + public record ImpliedFolderRoot(String path, String reason) {} +} 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 4ad611f42e..6539d9a34d 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 @@ -6,8 +6,10 @@ import java.util.Optional; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -23,6 +25,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.FolderAccessDeniedException; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.input.InputSource; @@ -45,6 +48,13 @@ import stirling.software.proprietary.util.SecretMasker; @Tag(name = "Sources", description = "Reusable policy input connections") public class SourceController { + /** + * Machine-readable marker on the error body when a folder source is rejected for pointing + * outside the allowed roots. The admin portal keys off this to offer a link straight to the + * Folder Access settings rather than only showing the message. + */ + public static final String FOLDER_ACCESS_DENIED_CODE = "folderAccessDenied"; + private static final String WEBHOOK_TYPE = "webhook"; private final SourceStore sourceStore; @@ -114,6 +124,10 @@ public class SourceController { Source owned = withPreparedOptions(withStoredSecrets(resolveOwnership(source)), isCreate); try { validateConfig(owned); + } catch (FolderAccessDeniedException e) { + // Surfaced with a machine-readable code by handleFolderAccessDenied so the portal can + // link to the Folder Access settings; don't flatten it into a plain 400 here. + throw e; } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } @@ -150,6 +164,22 @@ public class SourceController { return ResponseEntity.noContent().build(); } + /** + * A folder source was rejected for pointing outside the allowed roots. Return a 400 carrying + * {@link #FOLDER_ACCESS_DENIED_CODE} so the portal can offer a link to the Folder Access + * settings, while other guard rejections (SaaS mode, the protected config dir) fall through to + * the global handler as plain 400s the admin can't fix by editing the allowlist. + */ + @ExceptionHandler(FolderAccessDeniedException.class) + public ResponseEntity handleFolderAccessDenied(FolderAccessDeniedException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + problem.setProperty("code", FOLDER_ACCESS_DENIED_CODE); + return ResponseEntity.badRequest() + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problem); + } + /** * Stamp owner + team server-side. Create stamps the current user and their team; update * preserves the existing owner and team after verifying the source belongs to the caller's diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 286c0f1d01..650acd968b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -673,6 +673,7 @@ public class AdminSettingsController { case "telegram" -> applicationProperties.getTelegram(); case "aiengine", "aiEngine" -> applicationProperties.getAiEngine(); case "mcp" -> applicationProperties.getMcp(); + case "policies" -> applicationProperties.getPolicies(); default -> null; }; } @@ -699,7 +700,8 @@ public class AdminSettingsController { "telegram", "aiEngine", "aiengine", - "mcp"); + "mcp", + "policies"); // Pattern to validate safe property paths - only alphanumeric, dots, and underscores private static final Pattern SAFE_KEY_PATTERN = diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java index 4859954e71..351dcd1d2f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.io.TempDir; import org.springframework.core.env.StandardEnvironment; import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -36,7 +37,37 @@ class FolderAccessGuardTest { properties.getPolicies().setAllowedFolderRoots(allowedRoots); StandardEnvironment environment = new StandardEnvironment(); environment.setActiveProfiles(activeProfiles); - return new FolderAccessGuard(properties, environment, sourceStore); + return new FolderAccessGuard( + properties, new RuntimePathConfig(properties), environment, sourceStore); + } + + private FolderAccessGuard guardWithStorage( + List allowedRoots, boolean storageEnabled, String provider, String basePath) { + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowedFolderRoots(allowedRoots); + ApplicationProperties.Storage storage = properties.getStorage(); + storage.setEnabled(storageEnabled); + storage.setProvider(provider); + storage.getLocal().setBasePath(basePath); + return new FolderAccessGuard( + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + sourceStore); + } + + private FolderAccessGuard guardWithWatchedFolder(String watchedDir) { + ApplicationProperties properties = new ApplicationProperties(); + properties + .getSystem() + .getCustomPaths() + .getPipeline() + .setWatchedFoldersDirs(List.of(watchedDir)); + return new FolderAccessGuard( + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + sourceStore); } @Test @@ -50,8 +81,9 @@ class FolderAccessGuardTest { @Test void rejectsADirectoryOutsideEveryAllowedRoot() { FolderAccessGuard guard = guard(List.of(tempDir.toString())); + // FolderAccessDeniedException (not the base type): the admin can fix this in settings. assertThrows( - IllegalArgumentException.class, + FolderAccessDeniedException.class, () -> guard.requirePermitted(tempDir.resolveSibling("elsewhere"))); } @@ -59,14 +91,50 @@ class FolderAccessGuardTest { void rejectsTraversalThatWalksOutOfAnAllowedRoot() { FolderAccessGuard guard = guard(List.of(tempDir.toString())); assertThrows( - IllegalArgumentException.class, + FolderAccessDeniedException.class, () -> guard.requirePermitted(tempDir.resolve("..").resolve("escaped"))); } @Test void rejectsEverythingWhenNoRootsAreConfigured() { FolderAccessGuard guard = guard(List.of()); - assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(tempDir)); + assertThrows(FolderAccessDeniedException.class, () -> guard.requirePermitted(tempDir)); + } + + @Test + void permitsTheLocalServerStorageDirectoryEvenWithNoConfiguredRoots() { + Path storageBase = tempDir.resolve("storage"); + FolderAccessGuard guard = + guardWithStorage(List.of(), true, "local", storageBase.toString()); + Path within = storageBase.resolve("inbox"); + + assertEquals(within.toAbsolutePath().normalize(), guard.requirePermitted(within)); + } + + @Test + void ignoresServerStorageWhenTheStorageFeatureIsDisabled() { + Path storageBase = tempDir.resolve("storage"); + FolderAccessGuard guard = + guardWithStorage(List.of(), false, "local", storageBase.toString()); + + assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(storageBase)); + } + + @Test + void ignoresServerStorageWhenTheProviderIsNotLocal() { + Path storageBase = tempDir.resolve("storage"); + FolderAccessGuard guard = guardWithStorage(List.of(), true, "s3", storageBase.toString()); + + assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(storageBase)); + } + + @Test + void permitsPipelineWatchedFoldersEvenWithNoConfiguredRoots() { + Path watched = tempDir.resolve("watched"); + FolderAccessGuard guard = guardWithWatchedFolder(watched.toString()); + Path within = watched.resolve("inbox"); + + assertEquals(within.toAbsolutePath().normalize(), guard.requirePermitted(within)); } @Test @@ -76,15 +144,21 @@ class FolderAccessGuardTest { // Allow the config dir's parent, so only the protected-path rule can reject it. FolderAccessGuard guard = guard(List.of(configDir.getParent().toString())); - assertThrows( - IllegalArgumentException.class, - () -> guard.requirePermitted(configDir.resolve("settings.yml"))); + // Not a FolderAccessDeniedException: editing the allowlist can't unprotect the config dir. + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> guard.requirePermitted(configDir.resolve("settings.yml"))); + assertFalse(ex instanceof FolderAccessDeniedException); } @Test void refusesAllFolderAccessUnderTheSaasProfile() { FolderAccessGuard guard = guard(List.of(tempDir.toString()), "saas"); - assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(tempDir)); + // Not a FolderAccessDeniedException: SaaS has no folder allowlist to point the admin at. + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(tempDir)); + assertFalse(ex instanceof FolderAccessDeniedException); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java index 64178ce9bb..bcb968ca8d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java @@ -27,6 +27,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.env.StandardEnvironment; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; @@ -57,7 +58,10 @@ class FolderInputSourceTest { properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); FolderAccessGuard guard = new FolderAccessGuard( - properties, new StandardEnvironment(), new InProcessSourceStore()); + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + new InProcessSourceStore()); source = new FolderInputSource(readinessChecker, guard); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java index f9e1c92403..7f9acf4fae 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java @@ -19,6 +19,7 @@ import org.springframework.core.env.StandardEnvironment; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.ResultFile; import stirling.software.proprietary.policy.config.FolderAccessGuard; @@ -49,7 +50,10 @@ class FolderOutputSinkTest { sink = new FolderOutputSink( new FolderAccessGuard( - properties, new StandardEnvironment(), new InProcessSourceStore()), + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + new InProcessSourceStore()), ledger); } @@ -113,7 +117,10 @@ class FolderOutputSinkTest { FolderOutputSink orderedSink = new FolderOutputSink( new FolderAccessGuard( - properties, new StandardEnvironment(), new InProcessSourceStore()), + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + new InProcessSourceStore()), orderedLedger); orderedSink.deliver( diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index e3c636b49e..4c99d2a479 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -1099,6 +1099,25 @@ label = "Regenerate on Startup" description = "Number of days the certificate will be valid" label = "Certificate Validity (days)" +[admin.settings.folderAccess] +description = "Directories that folder sources and folder outputs are allowed to read from and write to. This is a security boundary: automations can never be pointed at a server path outside this list." +securityNote = "Leave this empty to disable folder sources and outputs entirely. Stirling's own configuration directory is always off-limits." +title = "Folder Access" + +[admin.settings.folderAccess.implied] +description = "These Stirling-managed directories are always permitted and can't be changed here." +serverStorage = "Server file storage" +title = "Always allowed" +watchedFolder = "Pipeline watched folder" + +[admin.settings.folderAccess.roots] +add = "Add" +empty = "No folders allowed. Folder sources and outputs are currently disabled." +hint = "Enter absolute paths, for example /data/inbox." +label = "Allowed folder roots" +placeholder = "/data/inbox" +remove = "Remove folder root" + [admin.settings.general] description = "Configure system-wide application settings including branding and default behavior." system = "System" @@ -8506,6 +8525,11 @@ editTitle = "Edit source" enabled = "Enabled" save = "Save changes" +[portal.sources.builder.folderAccess] +description = "Folder automations can only use folders an administrator has allowed. Add it under Folder Access settings, then try again." +openSettings = "Folder Access settings" +title = "This folder isn't allowed" + [portal.sources.delete] body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated." cancel = "Cancel" @@ -9437,6 +9461,7 @@ advanced = "Advanced" database = "Database" endpoints = "Endpoints" features = "Features" +folderAccess = "Folder Access" mcp = "MCP Server" storageSharing = "File Storage & Sharing" systemSettings = "System Settings" diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index 63461920ce..3053b4fd9e 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -470,6 +470,11 @@ "title": "Admin Storage Sharing Settings - Stirling PDF", "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" }, + "/settings/adminFolderAccess": { + "image": "/og_images/home.png", + "title": "Admin Folder Access Settings - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, "/settings/adminMcp": { "image": "/og_images/home.png", "title": "Admin Mcp Settings - Stirling PDF", @@ -673,6 +678,7 @@ "/settings/adminUsage": "/settings/adminUsage", "/settings/adminEndpoints": "/settings/adminEndpoints", "/settings/adminStorageSharing": "/settings/adminStorageSharing", + "/settings/adminFolderAccess": "/settings/adminFolderAccess", "/settings/adminMcp": "/settings/adminMcp", "/settings/adminAiGeneral": "/settings/adminAiGeneral", "/settings/adminAiModels": "/settings/adminAiModels", diff --git a/frontend/editor/public/og-metadata.saas.json b/frontend/editor/public/og-metadata.saas.json index 8f581f2125..39f6bf414e 100644 --- a/frontend/editor/public/og-metadata.saas.json +++ b/frontend/editor/public/og-metadata.saas.json @@ -471,6 +471,11 @@ "title": "Admin Storage Sharing Settings - Stirling PDF", "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" }, + "/settings/adminFolderAccess": { + "image": "/og_images/home.png", + "title": "Admin Folder Access Settings - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, "/settings/adminMcp": { "image": "/og_images/home.png", "title": "Admin Mcp Settings - Stirling PDF", @@ -686,6 +691,7 @@ "/settings/adminUsage": "/settings/adminUsage", "/settings/adminEndpoints": "/settings/adminEndpoints", "/settings/adminStorageSharing": "/settings/adminStorageSharing", + "/settings/adminFolderAccess": "/settings/adminFolderAccess", "/settings/adminMcp": "/settings/adminMcp", "/settings/adminAiGeneral": "/settings/adminAiGeneral", "/settings/adminAiModels": "/settings/adminAiModels", diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index 1182933d61..6b7d62192d 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [ "adminUsage", "adminEndpoints", "adminStorageSharing", + "adminFolderAccess", "adminMcp", "adminAiGeneral", "adminAiModels", diff --git a/frontend/editor/src/portal/api/sources.ts b/frontend/editor/src/portal/api/sources.ts index 47e456d094..4262a47a97 100644 --- a/frontend/editor/src/portal/api/sources.ts +++ b/frontend/editor/src/portal/api/sources.ts @@ -1,9 +1,31 @@ -import { apiClient } from "@portal/api/http"; +import { apiClient, HttpError } from "@portal/api/http"; /** * Sources service layer: the backend contract. */ +/** + * Backend marker (on the error body) for a folder source rejected because its + * directory falls outside the allowed roots - the one folder-access failure an + * admin can fix in the Folder Access settings. Mirrors + * SourceController.FOLDER_ACCESS_DENIED_CODE. + */ +export const FOLDER_ACCESS_DENIED_CODE = "folderAccessDenied"; + +/** + * True when a source save failed specifically because the folder is outside the + * allowed roots (so the caller can point the admin at settings), as opposed to + * any other 400 (blank directory, SaaS mode, the protected config dir). + */ +export function isFolderAccessDeniedError(error: unknown): boolean { + return ( + error instanceof HttpError && + typeof error.body === "object" && + error.body !== null && + (error.body as { code?: unknown }).code === FOLDER_ACCESS_DENIED_CODE + ); +} + /** Overview row status: referenced and enabled, enabled-but-orphaned, or disabled. */ export type SourceStatus = "active" | "unused" | "disabled"; diff --git a/frontend/editor/src/portal/views/SourceBuilder.test.tsx b/frontend/editor/src/portal/views/SourceBuilder.test.tsx index 60a9f13b93..77186de7d8 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.test.tsx +++ b/frontend/editor/src/portal/views/SourceBuilder.test.tsx @@ -7,10 +7,19 @@ import { } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { ReactNode } from "react"; import { SourceBuilder } from "@portal/views/SourceBuilder"; +import { UIProvider } from "@portal/contexts/UIContext"; + +// SourceBuilder reads useUI() to open settings, so wrap in its provider. +const Providers = ({ children }: { children: ReactNode }) => ( + + {children} + +); const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: MantineProvider }); + baseRender(ui, { wrapper: Providers }); vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -22,10 +31,12 @@ vi.mock("react-i18next", () => ({ const createSource = vi.fn(); const fetchSource = vi.fn(); const deleteSource = vi.fn(); +const isFolderAccessDeniedError = vi.fn(); vi.mock("@portal/api/sources", () => ({ createSource: (s: unknown) => createSource(s), fetchSource: (id: string) => fetchSource(id), deleteSource: (id: string) => deleteSource(id), + isFolderAccessDeniedError: (e: unknown) => isFolderAccessDeniedError(e), })); const fetchS3Connections = vi.fn(); @@ -55,6 +66,8 @@ describe("SourceBuilder", () => { fetchSource.mockReset(); deleteSource.mockReset(); deleteSource.mockResolvedValue(undefined); + isFolderAccessDeniedError.mockReset(); + isFolderAccessDeniedError.mockReturnValue(false); fetchS3Connections.mockReset(); fetchS3Connections.mockResolvedValue([]); }); @@ -86,6 +99,54 @@ describe("SourceBuilder", () => { expect(await screen.findByText("sources list")).toBeInTheDocument(); }); + it("offers a Folder Access settings link when the folder is outside allowed roots", async () => { + createSource.mockRejectedValue( + new Error("outside the allowed folder roots"), + ); + isFolderAccessDeniedError.mockReturnValue(true); + renderBuilder("/processor/sources/new"); + + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Claims intake" }, + }); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.folder\.fields\.directory\.label/, + ), + { target: { value: "/etc" } }, + ); + fireEvent.click(screen.getByText("portal.sources.builder.create")); + + expect( + await screen.findByText("portal.sources.builder.folderAccess.title"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.sources.builder.folderAccess.openSettings"), + ).toBeInTheDocument(); + }); + + it("shows a plain error banner (no settings link) for other save failures", async () => { + createSource.mockRejectedValue(new Error("boom")); + isFolderAccessDeniedError.mockReturnValue(false); + renderBuilder("/processor/sources/new"); + + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Claims intake" }, + }); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.folder\.fields\.directory\.label/, + ), + { target: { value: "/data/incoming" } }, + ); + fireEvent.click(screen.getByText("portal.sources.builder.create")); + + expect(await screen.findByText("boom")).toBeInTheDocument(); + expect( + screen.queryByText("portal.sources.builder.folderAccess.openSettings"), + ).not.toBeInTheDocument(); + }); + it("gates the s3 type on a chosen connection", async () => { renderBuilder("/processor/sources/new"); fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { diff --git a/frontend/editor/src/portal/views/SourceBuilder.tsx b/frontend/editor/src/portal/views/SourceBuilder.tsx index 4da5a96460..4d6ff1af14 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.tsx +++ b/frontend/editor/src/portal/views/SourceBuilder.tsx @@ -18,8 +18,10 @@ import { createSource, deleteSource, fetchSource, + isFolderAccessDeniedError, type Source, } from "@portal/api/sources"; +import { useUI } from "@portal/contexts/UIContext"; import { useAsync } from "@portal/hooks/useAsync"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes"; @@ -69,6 +71,7 @@ function optionsFor( export function SourceBuilder() { const { t } = useTranslation(); const navigate = useNavigate(); + const { openSettings } = useUI(); const { id } = useParams(); const isEdit = Boolean(id); const listPath = toPortalPath(VIEW_PATHS.sources); @@ -87,6 +90,9 @@ export function SourceBuilder() { const [seeded, setSeeded] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // A folder-outside-allowed-roots failure: the error banner offers a link to + // the Folder Access settings instead of leaving the admin at a dead end. + const [folderAccessDenied, setFolderAccessDenied] = useState(false); const [pendingDelete, setPendingDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [reveal, setReveal] = useState<{ @@ -137,6 +143,7 @@ export function SourceBuilder() { if (!canSave) return; setSubmitting(true); setError(null); + setFolderAccessDenied(false); try { const saved = await createSource({ id: isEdit ? id : undefined, @@ -156,6 +163,7 @@ export function SourceBuilder() { navigate(listPath); } catch (e) { setError(errorMessage(e)); + setFolderAccessDenied(isFolderAccessDeniedError(e)); setSubmitting(false); } } @@ -357,7 +365,34 @@ export function SourceBuilder() { )} - {error && } + {error && + (folderAccessDenied ? ( + openSettings("adminFolderAccess")} + > + {t( + "portal.sources.builder.folderAccess.openSettings", + "Folder Access settings", + )} + + } + /> + ) : ( + + ))} , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? enableLoginTooltip : undefined, + }, { key: "adminEndpoints", label: t("settings.configuration.endpoints", "Endpoints"), diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx new file mode 100644 index 0000000000..fad2782162 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.tsx @@ -0,0 +1,328 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Code, + Group, + Loader, + Paper, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { alert } from "@app/components/toast"; +import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal"; +import { useRestartServer } from "@app/components/shared/config/useRestartServer"; +import { useAdminSettings } from "@app/hooks/useAdminSettings"; +import PendingBadge from "@app/components/shared/config/PendingBadge"; +import { useLoginRequired } from "@app/hooks/useLoginRequired"; +import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; +import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter"; +import { useSettingsDirty } from "@app/hooks/useSettingsDirty"; +import apiClient from "@app/services/apiClient"; + +interface FolderAccessSettingsData { + allowedFolderRoots?: string[]; +} + +interface ImpliedFolderRoot { + path: string; + reason: string; +} + +export default function AdminFolderAccessSection() { + const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled, getDisabledStyles } = + useLoginRequired(); + const { + restartModalOpened, + showRestartModal, + closeRestartModal, + restartServer, + } = useRestartServer(); + + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ sectionName: "policies" }); + + const [newRoot, setNewRoot] = useState(""); + const [impliedRoots, setImpliedRoots] = useState([]); + + useEffect(() => { + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled]); + + useEffect(() => { + if (!loginEnabled) return; + apiClient + .get( + "/api/v1/admin/settings/policies/implied-folder-roots", + ) + .then((res) => setImpliedRoots(res.data ?? [])) + .catch(() => setImpliedRoots([])); + }, [loginEnabled]); + + const roots = settings.allowedFolderRoots ?? []; + + const reasonLabel = (reason: string) => { + switch (reason) { + case "serverStorage": + return t( + "admin.settings.folderAccess.implied.serverStorage", + "Server file storage", + ); + case "watchedFolder": + return t( + "admin.settings.folderAccess.implied.watchedFolder", + "Pipeline watched folder", + ); + default: + return reason; + } + }; + + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty( + settings, + loading, + ); + + const setRoots = useCallback( + (next: string[]) => { + setSettings({ ...settings, allowedFolderRoots: next }); + }, + [settings, setSettings], + ); + + const addRoot = useCallback(() => { + const value = newRoot.trim(); + if (!value) return; + if (roots.includes(value)) { + setNewRoot(""); + return; + } + setRoots([...roots, value]); + setNewRoot(""); + }, [newRoot, roots, setRoots]); + + const removeRoot = useCallback( + (value: string) => { + setRoots(roots.filter((root) => root !== value)); + }, + [roots, setRoots], + ); + + const handleDiscard = useCallback(() => { + setSettings(resetToSnapshot()); + setNewRoot(""); + }, [resetToSnapshot, setSettings]); + + const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } + try { + await saveSettings(); + markSaved(); + showRestartModal(); + } catch (_error) { + alert({ + alertType: "error", + title: t("admin.error", "Error"), + body: t("admin.settings.saveError", "Failed to save settings"), + }); + } + }; + + if (loginEnabled && loading) { + return ( + + + + ); + } + + return ( +
    +
    + + +
    + + + {t("admin.settings.folderAccess.title", "Folder Access")} + + {isFieldPending("allowedFolderRoots") && ( + + )} + + + {t( + "admin.settings.folderAccess.description", + "Directories that folder sources and folder outputs are allowed to read from and write to. This is a security boundary: automations can never be pointed at a server path outside this list.", + )} + +
    + + + + {t( + "admin.settings.folderAccess.securityNote", + "Leave this empty to disable folder sources and outputs entirely. Stirling's own configuration directory is always off-limits, and folder access is always disabled in hosted (SaaS) mode.", + )} + + + + + +
    + + {t( + "admin.settings.folderAccess.roots.label", + "Allowed folder roots", + )} + + + {t( + "admin.settings.folderAccess.roots.hint", + "Enter absolute paths, for example /data/inbox.", + )} + +
    + + {roots.length === 0 ? ( + + {t( + "admin.settings.folderAccess.roots.empty", + "No folders allowed. Folder sources and outputs are currently disabled.", + )} + + ) : ( + + {roots.map((root) => ( + + {root} + + + +
    + + {impliedRoots.length > 0 && ( + + +
    + + {t( + "admin.settings.folderAccess.implied.title", + "Always allowed", + )} + + + {t( + "admin.settings.folderAccess.implied.description", + "These Stirling-managed directories are always permitted and can't be changed here.", + )} + +
    + + {impliedRoots.map((root) => ( + + + {root.path} + + + + {reasonLabel(root.reason)} + + + + + ))} + +
    +
    + )} + + +
    +
    + +
    + ); +} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx index 49f5064eb7..6ab2a0fef8 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx @@ -121,8 +121,8 @@ export default function AdminStorageSharingSection() { return; } try { - markSaved(); await saveSettings(); + markSaved(); showRestartModal(); } catch (_error) { alert({