diff --git a/README.md b/README.md
index a0803ec90..20099c580 100644
--- a/README.md
+++ b/README.md
@@ -342,6 +342,15 @@ the client subscribed with no events ever reaching it.
+
+Configuration Files Configurations
+
+| Setting | Default | Required | Description |
+|-----------------------------------------------|:-------:|:--------:|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| config.allowUserResourceDependencies | false | No | Whether user-authored applications may declare `resource_dependencies` (scoped access to the calling user's resources outside the appdata sandbox). Admin-authored applications are not gated. **Note**: static settings — changing it requires a Core restart. Other `config.*` settings: see [sample](sample/aidial.settings.json). |
+
+
+
Applications Configurations
diff --git a/sample/aidial.settings.json b/sample/aidial.settings.json
index ff0a54653..db43f736e 100644
--- a/sample/aidial.settings.json
+++ b/sample/aidial.settings.json
@@ -1,7 +1,8 @@
{
"config": {
"files": ["/app/config/aidial.config.json"],
- "reload": 60000
+ "reload": 60000,
+ "allowUserResourceDependencies": false
},
"redis": {
"singleServerConfig": {
diff --git a/server/src/main/java/com/epam/aidial/core/server/AiDial.java b/server/src/main/java/com/epam/aidial/core/server/AiDial.java
index 5ff1b0277..afb8fe190 100644
--- a/server/src/main/java/com/epam/aidial/core/server/AiDial.java
+++ b/server/src/main/java/com/epam/aidial/core/server/AiDial.java
@@ -64,6 +64,7 @@
import com.epam.aidial.core.server.service.PerRequestPermissionService;
import com.epam.aidial.core.server.service.PublicationService;
import com.epam.aidial.core.server.service.PublicationUtil;
+import com.epam.aidial.core.server.service.ResourceDependencyValidator;
import com.epam.aidial.core.server.service.ResourceOperationService;
import com.epam.aidial.core.server.service.ResponseMappingService;
import com.epam.aidial.core.server.service.ResponsesApiClient;
@@ -328,6 +329,10 @@ vertx, settings("config"), null,
WellKnownResourceMetadataService wellKnownResourceMetadataService = new WellKnownResourceMetadataService(settings("toolsets"));
WellKnownResourceMetadataController resourceMetadataController = new WellKnownResourceMetadataController(wellKnownResourceMetadataService);
PerRequestPermissionService perRequestPermissionService = new PerRequestPermissionService(apiKeyStore, accessService, encryptionService);
+ // Static settings, not hot-reloaded — restarting Core is required to change it. Default off:
+ // user-authored apps may not declare resource dependencies until an operator opts in.
+ ResourceDependencyValidator resourceDependencyValidator = new ResourceDependencyValidator(
+ settings("config").getBoolean("allowUserResourceDependencies", false));
ApiKeyValidation apiKeyValidation = Json.decodeValue(settings("apiKeyValidation").toBuffer(), ApiKeyValidation.class);
boolean printAuthorizationHeader = settings.getBoolean("printAuthorizationHeader", false);
@@ -364,7 +369,8 @@ vertx, settings("config"), null,
toolSetService, securedResourceService, mcpHttpClientBuilder, toolSetRepairService, applicationSchemaService,
catalogSchemaService, authorizationHeaderProvider,
resourceAuthSettingsService, resourceCredentialsService,
- perRequestPermissionService, resourceAuthSettingsEncryptionService, authSettingsResolver, clientChannelService, taskExecutor, version(),
+ perRequestPermissionService, resourceDependencyValidator, resourceAuthSettingsEncryptionService,
+ authSettingsResolver, clientChannelService, taskExecutor, version(),
printAuthorizationHeader,
responseMappingService, complexResourceService, backgroundJobService, responsesApiClient, generator,
configAuthService, configApplyService, configValidationService);
diff --git a/server/src/main/java/com/epam/aidial/core/server/Proxy.java b/server/src/main/java/com/epam/aidial/core/server/Proxy.java
index f53ddccfe..76a4b7dda 100644
--- a/server/src/main/java/com/epam/aidial/core/server/Proxy.java
+++ b/server/src/main/java/com/epam/aidial/core/server/Proxy.java
@@ -34,6 +34,7 @@
import com.epam.aidial.core.server.service.NotificationService;
import com.epam.aidial.core.server.service.PerRequestPermissionService;
import com.epam.aidial.core.server.service.PublicationService;
+import com.epam.aidial.core.server.service.ResourceDependencyValidator;
import com.epam.aidial.core.server.service.ResourceOperationService;
import com.epam.aidial.core.server.service.ResponseMappingService;
import com.epam.aidial.core.server.service.ResponsesApiClient;
@@ -177,6 +178,7 @@ public class Proxy implements Handler {
private final ResourceAuthSettingsService resourceAuthSettingsService;
private final ResourceCredentialsService resourceCredentialsService;
private final PerRequestPermissionService perRequestPermissionService;
+ private final ResourceDependencyValidator resourceDependencyValidator;
private final ResourceAuthSettingsEncryptionService resourceAuthSettingsEncryptionService;
private final AuthSettingsResolver authSettingsResolver;
private final ClientChannelService clientChannelService;
diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java
index 376d5abda..2fdd2f581 100644
--- a/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java
+++ b/server/src/main/java/com/epam/aidial/core/server/controller/ConfigResourceController.java
@@ -1381,6 +1381,13 @@ private Future> handleAppOrToolSetPut() {
if (!requestNode.isObject()) {
throw new HttpException(HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
}
+ // Shape validation is pure CPU over the decoded body — run it before the bucket locks,
+ // not inside the cluster-wide critical section below.
+ Application application = type == ResourceTypes.APPLICATION
+ ? ConfigEntityCodec.treeToEntity(requestNode, Application.class) : null;
+ if (application != null) {
+ context.getProxy().getResourceDependencyValidator().validateShape(application);
+ }
return taskExecutor.submit(() -> lockService.underBucketLocks(MergedConfigStore.ADMIN_BUCKET_LOCATIONS, () -> {
rejectDuplicateDeploymentId(type, path);
// The platform bucket requires explicit admin access for every operation (see
@@ -1389,7 +1396,6 @@ private Future> handleAppOrToolSetPut() {
// this path is always admin context and may preserve forwardAuthToken.
Object decrypted = switch (type) {
case APPLICATION -> {
- Application application = ConfigEntityCodec.treeToEntity(requestNode, Application.class);
applicationService.putApplication(descriptor, etag, author, application, true, AdminManagedFieldsWriteMode.AUTHORITATIVE);
yield applicationService.getApplicationWithDecryptedSecrets(descriptor).getValue();
}
diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java
index 76c5f5171..1b8364908 100644
--- a/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java
+++ b/server/src/main/java/com/epam/aidial/core/server/controller/ResourceController.java
@@ -27,6 +27,7 @@
import com.epam.aidial.core.server.service.ExternalServiceStatusEnricher;
import com.epam.aidial.core.server.service.ExternalServicesWriteMode;
import com.epam.aidial.core.server.service.PermissionDeniedException;
+import com.epam.aidial.core.server.service.ResourceDependencyValidator;
import com.epam.aidial.core.server.service.ToolSetService;
import com.epam.aidial.core.server.util.ApplicationTypeSchemaProcessingException;
import com.epam.aidial.core.server.util.CredentialsLocatorFactory;
@@ -80,6 +81,7 @@ public class ResourceController extends AccessControlBaseController {
private final ToolSetService toolSetService;
private final DeploymentService deploymentService;
+ private final ResourceDependencyValidator resourceDependencyValidator;
public ResourceController(Proxy proxy, ProxyContext context, boolean metadata) {
// PUT and DELETE require write access, GET - read
@@ -91,6 +93,7 @@ public ResourceController(Proxy proxy, ProxyContext context, boolean metadata) {
this.resourceService = proxy.getResourceService();
this.applicationSchemaService = proxy.getApplicationSchemaService();
this.deploymentService = proxy.getDeploymentService();
+ this.resourceDependencyValidator = proxy.getResourceDependencyValidator();
this.metadata = metadata;
}
@@ -683,6 +686,12 @@ private void validateCustomApplication(Application application) {
try {
checkCreateCodeApp(application);
validateSchemaBasedApplication(application);
+ resourceDependencyValidator.validateShape(application);
+ if (!accessService.hasAdminAccess(context)) {
+ // Governance ceiling, keyed on the author rather than the destination bucket: an admin
+ // prototyping in their own bucket authors an admin app, not a user-authored one.
+ resourceDependencyValidator.validateUserAuthored(application);
+ }
if (!application.getInterceptors().isEmpty()) {
if (!accessService.hasAdminAccess(context)) {
throw new HttpException(FORBIDDEN, "Only admins are allowed to set interceptors");
diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java b/server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java
new file mode 100644
index 000000000..2183e1434
--- /dev/null
+++ b/server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java
@@ -0,0 +1,196 @@
+package com.epam.aidial.core.server.service;
+
+import com.epam.aidial.core.config.Application;
+import com.epam.aidial.core.config.ResourceAccessType;
+import com.epam.aidial.core.config.ResourceDependency;
+import com.epam.aidial.core.storage.http.HttpException;
+import com.epam.aidial.core.storage.util.UrlUtil;
+import lombok.RequiredArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static com.epam.aidial.core.storage.http.HttpStatus.BAD_REQUEST;
+import static com.epam.aidial.core.storage.http.HttpStatus.FORBIDDEN;
+
+/**
+ * Write-time validation of the {@code resourceDependencies} declaration section. This is the
+ * pointer rule, not an access decision: creating a dependency requires no permission on the
+ * target — whether the originating user can reach the target is a runtime question, verified
+ * fresh per request at resolution time. Only the shape of the ask and the authoring governance
+ * ceiling are checked here.
+ */
+@RequiredArgsConstructor
+public class ResourceDependencyValidator {
+
+ /** A declaration larger than this is wrong-shaped; it should be folder-scoped links, not a file inventory. */
+ public static final int MAX_DECLARED_DEPENDENCIES = 100;
+
+ public static final String CURRENT_USER_PLACEHOLDER = "current-user";
+
+ /** Global-view roots a concrete path may address. The personal root is reachable only via the placeholder. */
+ private static final Set GLOBAL_VIEW_ROOTS =
+ Set.of("files", "public", "prompts", "conversations", "applications", "toolsets", "skills");
+
+ /**
+ * Resource-type folders a {@code current-user/…} path must be rooted in for user-authored apps —
+ * a root-level {@code current-user/} declaration ("write everything personal") is not declarable.
+ */
+ private static final Set PERSONAL_TYPED_ROOTS =
+ Set.of("files", "prompts", "conversations", "applications", "toolsets", "skills");
+
+ private final boolean allowUserResourceDependencies;
+
+ /** Throws on the first shape violation. Applied on every writer surface regardless of author. */
+ public void validateShape(Application application) {
+ List issues = shapeIssues(application);
+ if (!issues.isEmpty()) {
+ throw new HttpException(BAD_REQUEST, "Invalid resource dependencies: " + String.join("; ", issues));
+ }
+ }
+
+ /**
+ * Governance ceiling for user-authored apps: with the flag off (the default) they may not declare
+ * dependencies at all; with it on, personal targets must be typed — never the personal root.
+ * Admin-authored writes (public bucket by an admin, the platform bucket) are not gated here.
+ */
+ public void validateUserAuthored(Application application) {
+ List section = application.getResourceDependencies();
+ if (section == null || section.isEmpty()) {
+ return;
+ }
+ if (!allowUserResourceDependencies) {
+ throw new HttpException(FORBIDDEN,
+ "User-authored applications may not declare resource dependencies (allowUserResourceDependencies is disabled)");
+ }
+ for (ResourceDependency dependency : section) {
+ String path = pathOf(dependency);
+ if (path == null) {
+ continue;
+ }
+ String[] segments = decodedSegments(path);
+ if (segments.length > 0 && CURRENT_USER_PLACEHOLDER.equals(segments[0]) && !isTypedPersonalPath(segments)) {
+ throw new HttpException(FORBIDDEN, "Root-level current-user dependency is not declarable: "
+ + "personal targets must be rooted in a resource-type folder: " + path);
+ }
+ }
+ }
+
+ /** Non-throwing form of {@link #validateShape}: the same rules, usable from any write surface. */
+ public static List shapeIssues(Application application) {
+ List issues = new ArrayList<>();
+ List section = application.getResourceDependencies();
+ if (section == null || section.isEmpty()) {
+ return issues;
+ }
+ boolean overCap = section.size() > MAX_DECLARED_DEPENDENCIES;
+ if (overCap) {
+ issues.add("resourceDependencies: the section exceeds " + MAX_DECLARED_DEPENDENCIES + " entries");
+ }
+ // Once over the cap the section is rejected anyway — inspect only the first MAX entries so a
+ // huge body cannot turn validation itself into unbounded allocation.
+ int inspected = Math.min(section.size(), MAX_DECLARED_DEPENDENCIES);
+ Set seenLinkIds = new HashSet<>();
+ for (int i = 0; i < inspected; i++) {
+ ResourceDependency dependency = section.get(i);
+ String at = "resourceDependencies[" + i + "]";
+ if (dependency == null) {
+ issues.add(at + ": entry is null");
+ continue;
+ }
+ if (!ResourceDependency.KIND.equals(dependency.getKind())) {
+ issues.add(at + ": kind must be " + ResourceDependency.KIND);
+ }
+ String linkId = dependency.getLinkId();
+ if (linkId == null || linkId.isBlank()) {
+ issues.add(at + ": linkId is required");
+ } else if (!seenLinkIds.add(linkId)) {
+ issues.add(at + ": duplicate linkId '" + linkId + "'");
+ }
+ issues.addAll(pathIssues(at, dependency));
+ // An explicit JSON null defeats the field default, so guard for null alongside empty.
+ if (dependency.getAccess() == null || dependency.getAccess().isEmpty()) {
+ issues.add(at + ": access must not be empty");
+ } else if (dependency.getAccess().contains(ResourceAccessType.SHARE)) {
+ issues.add(at + ": SHARE is not a dependency right");
+ }
+ }
+ return issues;
+ }
+
+ private static List pathIssues(String at, ResourceDependency dependency) {
+ List issues = new ArrayList<>();
+ String path = pathOf(dependency);
+ if (path == null) {
+ issues.add(at + ": target.path is required");
+ return issues;
+ }
+ // Token rules run on decoded segments, mirroring ResourceDescriptorFactory's single tryDecodePath
+ // pass — the platform canonicalizes declared paths through that decode, so validating the raw
+ // string would let %2e%2e / %2a / %63urrent-user smuggle banned tokens past the bans.
+ String[] segments = decodedSegments(path);
+ // A path of slashes only splits to zero segments; treat it as a missing path, not a crash.
+ if (segments.length == 0) {
+ issues.add(at + ": target.path is required");
+ return issues;
+ }
+ String root = segments[0];
+ // Token rules on every segment after the root; the root itself is governed by the form checks below.
+ for (int i = 1; i < segments.length; i++) {
+ String segment = segments[i];
+ if (CURRENT_USER_PLACEHOLDER.equals(segment)) {
+ issues.add(at + ": the current-user placeholder is valid only as the root segment: " + path);
+ }
+ if (segment.isEmpty()) {
+ issues.add(at + ": path must not contain empty segments: " + path);
+ }
+ if (segment.contains("*")) {
+ issues.add(at + ": wildcards are not allowed: " + path);
+ }
+ if (".".equals(segment) || "..".equals(segment)) {
+ issues.add(at + ": relative path segments are not allowed: " + path);
+ }
+ }
+ if (CURRENT_USER_PLACEHOLDER.equals(root)) {
+ // Placeholder-rooted form; the typed-root restriction is the governance ceiling's, not shape's.
+ return issues;
+ }
+ if ("users".equals(root)) {
+ // Personal targets are declared only via the placeholder — a concrete users/… path resolves for
+ // no one but that user and is rejected at write time as a shape error.
+ issues.add(at + ": personal targets must use the current-user placeholder, not a concrete users/… path: " + path);
+ } else if (!GLOBAL_VIEW_ROOTS.contains(root)) {
+ issues.add(at + ": target must be a global-view path or current-user rooted: " + path);
+ } else if (segments.length < 2) {
+ // A bare type root addresses the whole global view of that type — as over-broad as the
+ // personal root the governance ceiling bans. Declarations must be folder- or file-scoped.
+ issues.add(at + ": target must address a folder or resource within " + root + "/, not the type root: " + path);
+ }
+ return issues;
+ }
+
+ private static boolean isTypedPersonalPath(String[] segments) {
+ return segments.length > 1 && PERSONAL_TYPED_ROOTS.contains(segments[1]);
+ }
+
+ private static String pathOf(ResourceDependency dependency) {
+ if (dependency.getTarget() == null || dependency.getTarget().getPath() == null) {
+ return null;
+ }
+ String path = dependency.getTarget().getPath().trim();
+ return path.isEmpty() ? null : path;
+ }
+
+ /** Splits off a single trailing slash (folder targets end with one) before splitting into segments. */
+ private static String[] splitPath(String path) {
+ String trimmed = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
+ return trimmed.split("/");
+ }
+
+ private static String[] decodedSegments(String path) {
+ return Arrays.stream(splitPath(path)).map(UrlUtil::tryDecodePath).toArray(String[]::new);
+ }
+}
diff --git a/server/src/main/java/com/epam/aidial/core/server/service/config/ConfigApplyService.java b/server/src/main/java/com/epam/aidial/core/server/service/config/ConfigApplyService.java
index 2eefc35a4..f9d2394a2 100644
--- a/server/src/main/java/com/epam/aidial/core/server/service/config/ConfigApplyService.java
+++ b/server/src/main/java/com/epam/aidial/core/server/service/config/ConfigApplyService.java
@@ -20,6 +20,7 @@
import com.epam.aidial.core.server.security.ApiKeyStore;
import com.epam.aidial.core.server.service.AdminManagedFieldsWriteMode;
import com.epam.aidial.core.server.service.ApplicationService;
+import com.epam.aidial.core.server.service.ResourceDependencyValidator;
import com.epam.aidial.core.server.service.ToolSetService;
import com.epam.aidial.core.server.service.config.ConfigManifestSupport.ParsedName;
import com.epam.aidial.core.server.util.ResourceDescriptorFactory;
@@ -273,6 +274,13 @@ private EntityResult applyModel(AdminManifest entry, String id, ParsedName parse
private EntityResult applyApplication(AdminManifest entry, String id, ParsedName parsed, Config scratch, List pending) {
Application application = ConfigEntityCodec.treeToEntity(entry.spec(), Application.class);
+ // Same shape rules as every other application write surface; always admin context here, so the
+ // user-authored governance ceiling does not apply.
+ List dependencyIssues = ResourceDependencyValidator.shapeIssues(application);
+ if (!dependencyIssues.isEmpty()) {
+ return new EntityResult(id, AdminApplyStatus.FAILED,
+ "Invalid resource dependencies: " + String.join("; ", dependencyIssues));
+ }
ResourceDescriptor descriptor = ResourceDescriptorFactory.fromDecoded(
ResourceTypes.APPLICATION, parsed.bucket(), parsed.location(), parsed.name());
// Only the platform bucket is materialized into MergedConfigStore (see EntityLocationStrategy) —
diff --git a/server/src/main/resources/aidial.settings.json b/server/src/main/resources/aidial.settings.json
index f65f4bb57..9db8d7165 100644
--- a/server/src/main/resources/aidial.settings.json
+++ b/server/src/main/resources/aidial.settings.json
@@ -41,6 +41,7 @@
"files": [],
"reload": 60000,
"onInvalidEntity": "abort",
+ "allowUserResourceDependencies": false,
"write": {
"softValidation": false
}
diff --git a/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java b/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java
index 6558b806a..1c3e3cf5a 100644
--- a/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java
@@ -1774,6 +1774,104 @@ void testMcpCall() {
}
+ @Test
+ void testResourceDependenciesSectionWriteTimeValidation() {
+ // Admin-authored (public bucket): a valid section is accepted, root-level personal target included —
+ // the typed-root restriction belongs to the user-authored ceiling, not to shape.
+ Response response = send(HttpMethod.PUT, "/v1/applications/public/resource-dependency-app", null,
+ dependencyAppBody("""
+ {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"], "required": true},
+ {"kind": "dial.resourceLink", "link_id": "lnk_policies", "target": {"path": "files/public/policies/"}, "access": ["read"]}"""),
+ "authorization", "admin");
+ verify(response, 200);
+
+ // User-authored (own bucket) with the flag off — the default: rejected wholesale.
+ response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/resource-dependency-app", null,
+ dependencyAppBody("""
+ {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"]}"""));
+ verify(response, 403);
+
+ // The ceiling is keyed on the author, not the destination bucket: an admin prototyping in
+ // their own bucket authors an admin app — the section is accepted even with the flag off.
+ response = send(HttpMethod.PUT, "/v1/applications/" + adminBucket() + "/resource-dependency-app", null,
+ dependencyAppBody("""
+ {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"]}"""),
+ "authorization", "admin");
+ verify(response, 200);
+
+ // Shape violations are rejected for every author; admin + public bucket isolates shape from the ceiling.
+ String[][] invalidSections = {
+ {"wrong kind", "{\"kind\": \"dial.resource\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/f/\"}, \"access\": [\"read\"]}"},
+ {"concrete personal path", "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"users/bob/files/f/\"}, \"access\": [\"read\"]}"},
+ {"wildcard", "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/*/\"}, \"access\": [\"read\"]}"},
+ {"placeholder off the root",
+ "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/current-user/f/\"}, \"access\": [\"read\"]}"},
+ {"unknown root", "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"buckets/public/f/\"}, \"access\": [\"read\"]}"},
+ {"share is not a dependency right",
+ "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/f/\"}, \"access\": [\"share\"]}"},
+ {"empty access", "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/f/\"}, \"access\": []}"},
+ };
+ for (String[] casePair : invalidSections) {
+ response = send(HttpMethod.PUT, "/v1/applications/public/resource-dependency-app", null,
+ dependencyAppBody(casePair[1]), "authorization", "admin");
+ assertEquals(400, response.status(), casePair[0] + " — body: " + response.body());
+ }
+
+ // Apps without the section are unaffected.
+ response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/plain-application", null, """
+ {
+ "endpoint": "http://application1/v1/completions",
+ "display_name": "Plain Application"
+ }
+ """);
+ verify(response, 200);
+ }
+
+ private static String dependencyAppBody(String dependenciesJson) {
+ return """
+ {
+ "endpoint": "http://application1/v1/completions",
+ "display_name": "Resource Dependency App",
+ "resource_dependencies": [%s]
+ }
+ """.formatted(dependenciesJson);
+ }
+
+ private String adminBucket() {
+ Response response = send(HttpMethod.GET, "/v1/bucket", null, "", "authorization", "admin");
+ return new JsonObject(response.body()).getString("bucket");
+ }
+
+ /**
+ * The governance flag on: user-authored apps may declare dependencies, but personal targets
+ * must be typed — a root-level {@code current-user/} declaration is not declarable.
+ */
+ public static class AllowUserResourceDependenciesOn extends ResourceBaseTest {
+
+ @Override
+ protected JsonObject additionalSettingsOverrides() {
+ return new JsonObject().put("config", new JsonObject().put("allowUserResourceDependencies", true));
+ }
+
+ @Test
+ void testUserAuthoredDependenciesWhileFlagOn() {
+ Response response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/resource-dependency-app", null,
+ dependencyAppBody("""
+ {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"], "required": true}"""));
+ verify(response, 200);
+
+ response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/resource-dependency-app", null,
+ dependencyAppBody("""
+ {"kind": "dial.resourceLink", "link_id": "lnk_root", "target": {"path": "current-user/"}, "access": ["write"]}"""));
+ verify(response, 403);
+
+ response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/resource-dependency-app", null,
+ dependencyAppBody("""
+ {"kind": "dial.resourceLink", "link_id": "lnk_untyped", "target": {"path": "current-user/rootstuff/"}, "access": ["write"]}"""));
+ verify(response, 403);
+ }
+ }
+
private HttpUriRequest createHttpUriRequest(int port, String deployment, String apiKey) {
String uri = "http://127.0.0.1:" + port + "/openai/deployments/" + deployment + "/chat/completions";
String requestBody = """
diff --git a/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java b/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java
index 16a7078a2..f7881edbc 100644
--- a/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java
+++ b/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java
@@ -420,4 +420,36 @@ void testFunctionTypeApplicationRejectedOnPlatform() {
"authorization", "admin", "If-None-Match", "*");
verify(put, 400);
}
+
+ @Test
+ void testMalformedResourceDependenciesSectionRejected() {
+ // The platform bucket is always admin context, so only shape validation applies — but it
+ // must apply here too: this surface bypasses ResourceController.validateCustomApplication.
+ String body = """
+ {
+ "endpoint": "http://application1/v1/completions",
+ "display_name": "Platform App",
+ "resource_dependencies": [
+ {"kind": "dial.resourceLink", "link_id": "lnk_1", "target": {"path": "users/bob/files/f/"}, "access": ["read"]}
+ ]
+ }
+ """;
+ Response put = send(HttpMethod.PUT, "/v1/applications/platform/bad-dependency-app", null, body,
+ "authorization", "admin", "If-None-Match", "*");
+ verify(put, 400);
+
+ // A valid section is accepted on the same surface.
+ String validBody = """
+ {
+ "endpoint": "http://application1/v1/completions",
+ "display_name": "Platform App",
+ "resource_dependencies": [
+ {"kind": "dial.resourceLink", "link_id": "lnk_1", "target": {"path": "current-user/skills/"}, "access": ["write"]}
+ ]
+ }
+ """;
+ put = send(HttpMethod.PUT, "/v1/applications/platform/good-dependency-app", null, validBody,
+ "authorization", "admin", "If-None-Match", "*");
+ verify(put, 200);
+ }
}
diff --git a/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java b/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java
new file mode 100644
index 000000000..0cfdfb4c3
--- /dev/null
+++ b/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java
@@ -0,0 +1,278 @@
+package com.epam.aidial.core.server.service;
+
+import com.epam.aidial.core.config.Application;
+import com.epam.aidial.core.config.ResourceAccessType;
+import com.epam.aidial.core.config.ResourceDependency;
+import com.epam.aidial.core.storage.http.HttpException;
+import com.epam.aidial.core.storage.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Every branch of the write-time rules: the shape table (400s) and the user-authored
+ * governance ceiling (403s). These are pointer rules — no target permission is involved.
+ */
+public class ResourceDependencyValidatorTest {
+
+ private static ResourceDependency dependency(String path) {
+ return new ResourceDependency()
+ .setKind(ResourceDependency.KIND)
+ .setLinkId("lnk_1")
+ .setTarget(new ResourceDependency.Target().setPath(path))
+ .setAccess(Set.of(ResourceAccessType.READ));
+ }
+
+ private static Application appWith(ResourceDependency... dependencies) {
+ return new Application().setResourceDependencies(List.of(dependencies));
+ }
+
+ // ---- shape: the two valid forms ----
+
+ @Test
+ void acceptsConcreteGlobalViewFolderTarget() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertDoesNotThrow(() -> validator.validateShape(
+ appWith(dependency("files/public/policies/").setAccess(Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE)))));
+ }
+
+ @Test
+ void acceptsCurrentUserPlaceholderTarget() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertDoesNotThrow(() -> validator.validateShape(appWith(dependency("current-user/skills/"))));
+ }
+
+ @Test
+ void acceptsFileTargetWithoutTrailingSlash() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertDoesNotThrow(() -> validator.validateShape(appWith(dependency("prompts/public/my-prompt"))));
+ }
+
+ // ---- shape: record fields ----
+
+ @Test
+ void rejectsWrongKind() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application application = appWith(dependency("files/public/folder/").setKind("dial.resource"));
+
+ HttpException error = assertThrows(HttpException.class, () -> validator.validateShape(application));
+ assertEquals(HttpStatus.BAD_REQUEST, error.getStatus());
+ assertTrue(error.getMessage().contains("kind"));
+ }
+
+ @Test
+ void rejectsMissingAndDuplicateLinkIds() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application missing = appWith(dependency("files/public/folder/").setLinkId(" "));
+ Application duplicate = appWith(dependency("files/public/folder/"), dependency("files/public/other/"));
+
+ assertTrue(validatorShapeMessage(validator, missing).contains("linkId is required"));
+ assertTrue(validatorShapeMessage(validator, duplicate).contains("duplicate linkId"));
+ }
+
+ @Test
+ void rejectsMissingTargetPath() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application application = appWith(dependency(null));
+
+ assertTrue(validatorShapeMessage(validator, application).contains("target.path is required"));
+ }
+
+ @Test
+ void rejectsEmptyAccessAndShareAccess() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application empty = appWith(dependency("files/public/folder/").setAccess(Set.of()));
+ Application share = appWith(dependency("files/public/folder/").setAccess(Set.of(ResourceAccessType.SHARE)));
+
+ assertTrue(validatorShapeMessage(validator, empty).contains("access must not be empty"));
+ assertTrue(validatorShapeMessage(validator, share).contains("SHARE"));
+ }
+
+ @Test
+ void rejectsExplicitNullAccessAsValidationError() {
+ // An explicit JSON null defeats the field default; it must surface as a 400, not a 500.
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application application = appWith(dependency("files/public/folder/").setAccess(null));
+
+ assertTrue(validatorShapeMessage(validator, application).contains("access must not be empty"));
+ }
+
+ @Test
+ void rejectsNullEntry() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application application = new Application()
+ .setResourceDependencies(Arrays.asList(dependency("files/public/folder/"), null));
+
+ assertTrue(validatorShapeMessage(validator, application).contains("entry is null"));
+ }
+
+ // ---- shape: the target language ----
+
+ @Test
+ void rejectsConcreteUsersPathAsShapeError() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application application = appWith(dependency("users/someone/files/folder/"));
+
+ assertTrue(validatorShapeMessage(validator, application).contains("current-user placeholder"));
+ }
+
+ @Test
+ void rejectsUnknownRootSegment() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application application = appWith(dependency("buckets/public/folder/"));
+
+ assertTrue(validatorShapeMessage(validator, application).contains("global-view path"));
+ }
+
+ @Test
+ void rejectsPlaceholderOutsideTheRoot() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ Application application = appWith(dependency("files/public/current-user/folder/"));
+
+ assertTrue(validatorShapeMessage(validator, application).contains("only as the root segment"));
+ }
+
+ @Test
+ void rejectsWildcardsAndRelativeSegments() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/*/folder/"))).contains("wildcards"));
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/../users/x/"))).contains("relative path segments"));
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public//folder/"))).contains("empty segments"));
+ }
+
+ @Test
+ void rejectsPercentEncodedBannedTokens() {
+ // The platform percent-decodes declared paths when building descriptors (the same single
+ // tryDecodePath pass), so the token bans must run on decoded segments — raw-string checks
+ // would let these smuggles through.
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/%2e%2e/x/"))).contains("relative path segments"));
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/%2a/"))).contains("wildcards"));
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/%63urrent-user/f/"))).contains("only as the root segment"));
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("%75sers/bob/files/"))).contains("current-user placeholder"));
+ }
+
+ @Test
+ void acceptsLegitimatelyEncodedSegments() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertDoesNotThrow(() -> validator.validateShape(appWith(dependency("files/public/my%20folder/"))));
+ }
+
+ @Test
+ void rejectsSectionOverTheCap() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ List section = IntStream.rangeClosed(1, ResourceDependencyValidator.MAX_DECLARED_DEPENDENCIES + 1)
+ .mapToObj(i -> dependency("files/public/folder" + i + "/").setLinkId("lnk_" + i))
+ .toList();
+ Application application = new Application().setResourceDependencies(section);
+
+ // Over the cap the section is rejected on the cap alone — only the first MAX entries are
+ // inspected, so a huge body cannot turn validation into unbounded allocation.
+ List issues = ResourceDependencyValidator.shapeIssues(application);
+ assertTrue(issues.contains("resourceDependencies: the section exceeds "
+ + ResourceDependencyValidator.MAX_DECLARED_DEPENDENCIES + " entries"));
+ assertTrue(issues.size() <= ResourceDependencyValidator.MAX_DECLARED_DEPENDENCIES * 5);
+ }
+
+ @Test
+ void rejectsBareTypeRootAsTooBroad() {
+ // "files" alone addresses the whole global view of that type — as over-broad as the personal
+ // root the governance ceiling bans. Declarations must be folder- or file-scoped.
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("files"))).contains("not the type root"));
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("public/"))).contains("not the type root"));
+ }
+
+ @Test
+ void rejectsSlashOnlyTargetPath() {
+ // A path of slashes only splits to zero segments — a shape error, not a crash.
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+ ResourceDependencyValidator flagOnValidator = new ResourceDependencyValidator(true);
+
+ assertTrue(validatorShapeMessage(validator, appWith(dependency("//"))).contains("target.path is required"));
+ assertDoesNotThrow(() -> flagOnValidator.validateUserAuthored(appWith(dependency("//"))));
+ }
+
+ @Test
+ void emptyAndAbsentSectionsPassShape() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertDoesNotThrow(() -> validator.validateShape(new Application()));
+ assertDoesNotThrow(() -> validator.validateShape(new Application().setResourceDependencies(List.of())));
+ }
+
+ // ---- the governance ceiling (user-authored) ----
+
+ @Test
+ void rejectsUserAuthoredSectionWhileFlagOff() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ HttpException error = assertThrows(HttpException.class,
+ () -> validator.validateUserAuthored(appWith(dependency("files/public/folder/"))));
+ assertEquals(HttpStatus.FORBIDDEN, error.getStatus());
+ assertTrue(error.getMessage().contains("may not declare"));
+ }
+
+ @Test
+ void acceptsUserAuthoredSectionWhileFlagOn() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(true);
+
+ assertDoesNotThrow(() -> validator.validateUserAuthored(appWith(dependency("current-user/skills/"))));
+ }
+
+ @Test
+ void rejectsRootLevelCurrentUserWhileFlagOn() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(true);
+
+ HttpException root = assertThrows(HttpException.class,
+ () -> validator.validateUserAuthored(appWith(dependency("current-user/"))));
+ HttpException untyped = assertThrows(HttpException.class,
+ () -> validator.validateUserAuthored(appWith(dependency("current-user/rootstuff/"))));
+
+ assertEquals(403, root.getStatus().getCode());
+ assertEquals(403, untyped.getStatus().getCode());
+ assertTrue(untyped.getMessage().contains("resource-type folder"));
+ }
+
+ @Test
+ void ceilingIgnoresAppsWithoutSection() {
+ ResourceDependencyValidator validator = new ResourceDependencyValidator(false);
+
+ assertDoesNotThrow(() -> validator.validateUserAuthored(new Application()));
+ assertDoesNotThrow(() -> validator.validateUserAuthored(new Application().setResourceDependencies(List.of())));
+ }
+
+ // ---- the non-throwing form used by the lazy validator ----
+
+ @Test
+ void shapeIssuesListsAllProblemsWithoutThrowing() {
+ Application application = appWith(
+ dependency("files/public/*/").setKind("wrong").setAccess(Set.of()),
+ dependency("users/x/y/"));
+
+ List issues = ResourceDependencyValidator.shapeIssues(application);
+
+ assertTrue(issues.size() >= 5); // kind, access, wildcard, personal-target, and the second entry's issue
+ }
+
+ private static String validatorShapeMessage(ResourceDependencyValidator validator, Application application) {
+ HttpException error = assertThrows(HttpException.class, () -> validator.validateShape(application));
+ assertEquals(HttpStatus.BAD_REQUEST, error.getStatus());
+ return error.getMessage();
+ }
+}