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).

<img width="888" height="786" alt="image"
src="https://github.com/user-attachments/assets/cf6d0705-adcf-463c-8e80-6901a652068b"
/>

<img width="1103" height="713" alt="image"
src="https://github.com/user-attachments/assets/b7244592-249a-4149-994f-3a2b750f25f9"
/>
This commit is contained in:
James Brunton
2026-07-23 11:19:24 +00:00
committed by GitHub
parent 1e2895a79f
commit 29002d0b82
20 changed files with 746 additions and 24 deletions
@@ -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<String> allowedFolderRoots = new java.util.ArrayList<>();
@@ -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
@@ -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);
}
}
@@ -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;
* <li>denied entirely under the {@code saas} profile;
* <li>Stirling's own config dir always rejected, even if an allowed root were misconfigured to
* contain it;
* <li>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;
* <li>must resolve within {@code policies.allowedFolderRoots}; none configured means all denied.
* </ol>
*
@@ -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<Path> allowedRoots;
private final List<ImpliedRoot> impliedRoots;
private final List<Path> 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<ImpliedRoot> 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<ImpliedRoot> impliedRoots(
ApplicationProperties.Storage storage, RuntimePathConfig runtimePathConfig) {
List<ImpliedRoot> 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<Path> 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<Path> normalizeAll(List<String> roots) {
List<Path> result = new ArrayList<>();
for (String root : roots) {
@@ -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<ImpliedFolderRoot> impliedFolderRoots() {
return folderAccessGuard.impliedRoots().stream()
.map(root -> new ImpliedFolderRoot(root.path().toString(), root.reason()))
.toList();
}
public record ImpliedFolderRoot(String path, String reason) {}
}
@@ -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<ProblemDetail> 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
@@ -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 =
@@ -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<String> 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
@@ -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();
@@ -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(
@@ -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"
+6
View File
@@ -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",
@@ -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",
@@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [
"adminUsage",
"adminEndpoints",
"adminStorageSharing",
"adminFolderAccess",
"adminMcp",
"adminAiGeneral",
"adminAiModels",
+23 -1
View File
@@ -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";
@@ -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 }) => (
<MantineProvider>
<UIProvider>{children}</UIProvider>
</MantineProvider>
);
const render = (ui: Parameters<typeof baseRender>[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/), {
@@ -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<string | null>(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() {
</FormField>
)}
{error && <Banner tone="danger" description={error} />}
{error &&
(folderAccessDenied ? (
<Banner
tone="danger"
title={t(
"portal.sources.builder.folderAccess.title",
"This folder isn't allowed",
)}
description={t(
"portal.sources.builder.folderAccess.description",
"Folder automations can only use folders an administrator has allowed. Add it under Folder Access settings, then try again.",
)}
action={
<Button
variant="secondary"
size="sm"
onClick={() => openSettings("adminFolderAccess")}
>
{t(
"portal.sources.builder.folderAccess.openSettings",
"Folder Access settings",
)}
</Button>
}
/>
) : (
<Banner tone="danger" description={error} />
))}
</div>
<Modal
@@ -24,6 +24,7 @@ import AdminAiLimitsSection from "@app/components/shared/config/configSections/A
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
import AdminFolderAccessSection from "@app/components/shared/config/configSections/AdminFolderAccessSection";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import AccountSection from "@app/components/shared/config/configSections/AccountSection";
import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding";
@@ -135,6 +136,14 @@ export const useConfigNavSections = (
badge: t("toolPanel.alpha", "Alpha"),
badgeColor: "orange",
},
{
key: "adminFolderAccess",
label: t("settings.configuration.folderAccess", "Folder Access"),
icon: "folder-rounded",
component: <AdminFolderAccessSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminEndpoints",
label: t("settings.configuration.endpoints", "Endpoints"),
@@ -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<FolderAccessSettingsData>({ sectionName: "policies" });
const [newRoot, setNewRoot] = useState("");
const [impliedRoots, setImpliedRoots] = useState<ImpliedFolderRoot[]>([]);
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
useEffect(() => {
if (!loginEnabled) return;
apiClient
.get<ImpliedFolderRoot[]>(
"/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 (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<div className="settings-section-content">
<Stack gap="sm">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group gap="xs" align="center">
<Text fw={600} size="lg">
{t("admin.settings.folderAccess.title", "Folder Access")}
</Text>
{isFieldPending("allowedFolderRoots") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="sm" c="dimmed">
{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.",
)}
</Text>
</div>
<Alert variant="light" color="blue">
<Text size="xs">
{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.",
)}
</Text>
</Alert>
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<div>
<Text fw={600} size="sm">
{t(
"admin.settings.folderAccess.roots.label",
"Allowed folder roots",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"admin.settings.folderAccess.roots.hint",
"Enter absolute paths, for example /data/inbox.",
)}
</Text>
</div>
{roots.length === 0 ? (
<Text size="sm" c="dimmed" fs="italic">
{t(
"admin.settings.folderAccess.roots.empty",
"No folders allowed. Folder sources and outputs are currently disabled.",
)}
</Text>
) : (
<Stack gap="xs">
{roots.map((root) => (
<Group
key={root}
justify="space-between"
wrap="nowrap"
gap="xs"
>
<Code style={{ wordBreak: "break-all" }}>{root}</Code>
<Button
variant="tertiary"
aria-label={t(
"admin.settings.folderAccess.roots.remove",
"Remove folder root",
)}
leftSection={
<LocalIcon
icon="close-rounded"
width="1.1rem"
height="1.1rem"
/>
}
onClick={() => removeRoot(root)}
disabled={!loginEnabled}
style={{ flexShrink: 0 }}
/>
</Group>
))}
</Stack>
)}
<Group gap="xs" align="flex-end" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
value={newRoot}
onChange={(e) => setNewRoot(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addRoot();
}
}}
placeholder={t(
"admin.settings.folderAccess.roots.placeholder",
"/data/inbox",
)}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<Button
variant="secondary"
onClick={addRoot}
disabled={!loginEnabled || newRoot.trim().length === 0}
>
{t("admin.settings.folderAccess.roots.add", "Add")}
</Button>
</Group>
</Stack>
</Paper>
{impliedRoots.length > 0 && (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<div>
<Text fw={600} size="sm">
{t(
"admin.settings.folderAccess.implied.title",
"Always allowed",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"admin.settings.folderAccess.implied.description",
"These Stirling-managed directories are always permitted and can't be changed here.",
)}
</Text>
</div>
<Stack gap="xs">
{impliedRoots.map((root) => (
<Group
key={root.path}
justify="space-between"
wrap="nowrap"
gap="xs"
align="center"
>
<Code style={{ wordBreak: "break-all" }}>
{root.path}
</Code>
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<Text size="xs" c="dimmed">
{reasonLabel(root.reason)}
</Text>
<LocalIcon icon="lock" width="1rem" height="1rem" />
</Group>
</Group>
))}
</Stack>
</Stack>
</Paper>
)}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
</div>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -121,8 +121,8 @@ export default function AdminStorageSharingSection() {
return;
}
try {
markSaved();
await saveSettings();
markSaved();
showRestartModal();
} catch (_error) {
alert({