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({