From 585293c99c92bf2b04af89ddf029c7b1619cb56d Mon Sep 17 00:00:00 2001 From: Serguei Gorokhov Date: Wed, 2 Sep 2026 23:11:12 +0300 Subject: [PATCH 1/3] feat: validate resourceDependencies at write time, gated by allowUserResourceDependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new ResourceDependencyValidator enforces the two-form target language (concrete global-view path, or current-user placeholder rooted), the allowed-targets rule, the ~100-entry cap, and record shape — a pointer rule only, no permission on the target is checked or required. It runs on both application writer surfaces: ResourceController (user/public bucket PUTs) and ConfigResourceController (platform bucket), which previously bypassed application validation entirely. The governance ceiling: user-authored apps may not declare dependencies while the new settings flag allowUserResourceDependencies is off (the default); with it on, personal targets must be typed — a root-level current-user/ declaration is not declarable. Static settings are not hot-reloaded. BlobEntityValidator gains the read-side warning form of the same rules, reusing one implementation. Note: CustomApplicationApiTest.testApplicationListing fails on Windows on pristine development alike (a pre-existing gpt-backslash folder issue, green on CI's Linux runners) — unrelated to this change. Spec: documentation repo, offline-access-delegation/implementation-specs/pr2-write-time-validation.md Co-Authored-By: Claude Code --- sample/aidial.settings.json | 3 +- .../com/epam/aidial/core/server/AiDial.java | 8 +- .../com/epam/aidial/core/server/Proxy.java | 2 + .../server/config/BlobEntityValidator.java | 14 ++ .../controller/ConfigResourceController.java | 3 + .../server/controller/ResourceController.java | 13 +- .../service/ResourceDependencyValidator.java | 174 +++++++++++++ .../src/main/resources/aidial.settings.json | 1 + .../core/server/CustomApplicationApiTest.java | 85 +++++++ .../server/PlatformAppToolsetApiTest.java | 32 +++ .../config/BlobEntityValidatorTest.java | 40 +++ .../ResourceDependencyValidatorTest.java | 233 ++++++++++++++++++ 12 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java create mode 100644 server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java 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/config/BlobEntityValidator.java b/server/src/main/java/com/epam/aidial/core/server/config/BlobEntityValidator.java index 80193d9c0..48bd5ef7f 100644 --- a/server/src/main/java/com/epam/aidial/core/server/config/BlobEntityValidator.java +++ b/server/src/main/java/com/epam/aidial/core/server/config/BlobEntityValidator.java @@ -2,6 +2,7 @@ import com.epam.aidial.core.config.Application; import com.epam.aidial.core.config.Config; +import com.epam.aidial.core.server.service.ResourceDependencyValidator; import java.net.URI; import java.util.ArrayList; @@ -28,9 +29,22 @@ public static List validate(Application application, Config c appendInterceptorWarnings(application.getInterceptors(), config, warnings); appendSchemaWarning(application.getApplicationTypeSchemaId(), config, warnings); appendDependencyWarnings(application.getDependencies(), config, warnings); + appendResourceDependencyWarnings(application, warnings); return warnings; } + /** + * Read-side backstop for the {@code resourceDependencies} section: the same rules the + * write-time validator enforces, as warnings — blob entities written before a rule existed, + * or arriving by copy/publication, surface here instead of failing a listing. The exact + * location travels inside the message ("resourceDependencies[i]: …"). + */ + private static void appendResourceDependencyWarnings(Application application, List warnings) { + for (String issue : ResourceDependencyValidator.shapeIssues(application)) { + warnings.add(new ValidationWarning("resourceDependencies", issue)); + } + } + private static void appendInterceptorWarnings(List refs, Config config, List warnings) { if (refs == null || refs.isEmpty()) { return; 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..061e97d00 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 @@ -1390,6 +1390,9 @@ private Future handleAppOrToolSetPut() { Object decrypted = switch (type) { case APPLICATION -> { Application application = ConfigEntityCodec.treeToEntity(requestNode, Application.class); + // Always an admin context on this surface (see AdminRoleAuthorizationService), so the + // user-authored governance ceiling does not apply — shape only. + context.getProxy().getResourceDependencyValidator().validateShape(application); 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..ffaf08ebf 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; } @@ -679,10 +682,16 @@ private Future> getToolsetData(ResourceDescri }); } - private void validateCustomApplication(Application application) { + private void validateCustomApplication(Application application, boolean adminPublicWrite) { try { checkCreateCodeApp(application); validateSchemaBasedApplication(application); + resourceDependencyValidator.validateShape(application); + if (!adminPublicWrite) { + // Governance ceiling: user-authored apps may not declare dependencies while the flag is off, + // and personal targets must be typed even when it is on. + resourceDependencyValidator.validateUserAuthored(application); + } if (!application.getInterceptors().isEmpty()) { if (!accessService.hasAdminAccess(context)) { throw new HttpException(FORBIDDEN, "Only admins are allowed to set interceptors"); @@ -791,7 +800,7 @@ private Future putResource(ResourceDescriptor descriptor) { AdminManagedFieldsWriteMode adminManagedFieldsWriteMode = AdminManagedFieldsWriteMode.of(adminPublicWrite, bodyJson); return taskExecutor.submit(() -> { - validateCustomApplication(application); + validateCustomApplication(application, adminPublicWrite); return applicationService.putApplication(descriptor, etag, author, application, adminPublicWrite, adminManagedFieldsWriteMode, externalServicesWriteMode).getKey(); }); 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..cbdbdfadd --- /dev/null +++ b/server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java @@ -0,0 +1,174 @@ +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 lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +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 = splitPath(path); + if (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}, so the lazy read-side validator can reuse the same rules. */ + public static List shapeIssues(Application application) { + List issues = new ArrayList<>(); + List section = application.getResourceDependencies(); + if (section == null || section.isEmpty()) { + return issues; + } + if (section.size() > MAX_DECLARED_DEPENDENCIES) { + issues.add("resourceDependencies: the section exceeds " + MAX_DECLARED_DEPENDENCIES + " entries"); + } + Set seenLinkIds = new HashSet<>(); + for (int i = 0; i < section.size(); 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; + } + String[] segments = splitPath(path); + 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); + } + 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("/"); + } +} 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..9f444bcbb 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,91 @@ 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); + + // 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); + } + + /** + * 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/config/BlobEntityValidatorTest.java b/server/src/test/java/com/epam/aidial/core/server/config/BlobEntityValidatorTest.java index aa8ca17df..48d2201e8 100644 --- a/server/src/test/java/com/epam/aidial/core/server/config/BlobEntityValidatorTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/config/BlobEntityValidatorTest.java @@ -4,11 +4,14 @@ import com.epam.aidial.core.config.Config; import com.epam.aidial.core.config.Interceptor; import com.epam.aidial.core.config.Model; +import com.epam.aidial.core.config.ResourceAccessType; +import com.epam.aidial.core.config.ResourceDependency; import org.junit.jupiter.api.Test; import java.net.URI; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -101,4 +104,41 @@ void validateApplication_returnsNoWarnings_whenFieldsAreNullOrEmpty() { assertTrue(warnings.isEmpty()); } + @Test + void validateApplication_warnsOnMalformedResourceDependenciesSection() { + // The read-side backstop reuses the write-time shape rules: a concrete personal path + // (rejected at PUT) surfaces here as a warning for entities that arrived by other means. + Config config = new Config(); + + Application app = new Application(); + app.setResourceDependencies(List.of(new ResourceDependency() + .setKind(ResourceDependency.KIND) + .setLinkId("lnk_1") + .setTarget(new ResourceDependency.Target().setPath("users/bob/files/f/")) + .setAccess(Set.of(ResourceAccessType.READ)))); + + List warnings = BlobEntityValidator.validate(app, config); + + assertEquals(1, warnings.size()); + assertEquals("resourceDependencies", warnings.get(0).getField()); + assertTrue(warnings.get(0).getMessage().contains("current-user placeholder"), + () -> "Unexpected message: " + warnings.get(0).getMessage()); + } + + @Test + void validateApplication_returnsNoWarnings_forValidResourceDependenciesSection() { + Config config = new Config(); + + Application app = new Application(); + app.setResourceDependencies(List.of(new ResourceDependency() + .setKind(ResourceDependency.KIND) + .setLinkId("lnk_1") + .setTarget(new ResourceDependency.Target().setPath("current-user/skills/")) + .setAccess(Set.of(ResourceAccessType.WRITE)))); + + List warnings = BlobEntityValidator.validate(app, config); + + assertTrue(warnings.isEmpty()); + } + } 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..f86c1f33f --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java @@ -0,0 +1,233 @@ +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 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); + + assertTrue(validatorShapeMessage(validator, application).contains("exceeds")); + } + + @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(); + } +} From d59c25b5db3043256123e92fc87f8282c61bdd23 Mon Sep 17 00:00:00 2001 From: Serguei Gorokhov Date: Wed, 2 Sep 2026 23:32:26 +0300 Subject: [PATCH 2/3] fix: run target-language token bans on decoded path segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security-review follow-up to the write-time validation commit: the token bans (.., *, current-user off the root) ran on raw string segments, while the platform canonicalizes declared paths through ResourceDescriptorFactory's single tryDecodePath pass when building descriptors. Percent-encoded smuggles (%2e%2e, %2a, %63urrent-user, %75sers) therefore passed shape validation and would decode to exactly the banned tokens at resolution time — a validate-then-normalize mismatch inside the control this series introduces. The validator now decodes each segment the same way the factory does (one tryDecodePath pass, mirroring fromEntityPath) before applying the token and root-form rules; legitimately encoded segments such as my%20folder remain declarable. Co-Authored-By: Claude Code --- .../service/ResourceDependencyValidator.java | 13 ++++++++++-- .../ResourceDependencyValidatorTest.java | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) 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 index cbdbdfadd..a9d9033bd 100644 --- 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 @@ -4,9 +4,11 @@ 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; @@ -69,7 +71,7 @@ public void validateUserAuthored(Application application) { if (path == null) { continue; } - String[] segments = splitPath(path); + String[] segments = decodedSegments(path); if (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); @@ -122,7 +124,10 @@ private static List pathIssues(String at, ResourceDependency dependency) issues.add(at + ": target.path is required"); return issues; } - String[] segments = splitPath(path); + // 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); 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++) { @@ -171,4 +176,8 @@ 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/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java b/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java index f86c1f33f..0332c001d 100644 --- 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 @@ -152,6 +152,26 @@ void rejectsWildcardsAndRelativeSegments() { 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); From 2feee1d064f6583e64799d6a7c1c0e1caea094d9 Mon Sep 17 00:00:00 2001 From: Serguei Gorokhov Date: Wed, 2 Sep 2026 23:52:14 +0300 Subject: [PATCH 3/3] fix: harden resourceDependencies write-time validation from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-driven fixes to the validation commit: - Bounded work: once a section exceeds the 100-entry cap only the first 100 entries are inspected, so a huge body cannot turn validation into unbounded allocation and a giant 400 message. - Slash-only target paths (zero segments after split) are a 400 shape error instead of an ArrayIndexOutOfBounds 500. - A bare type root ("files", "public/") is rejected — it addresses a whole global view, as over-broad as the personal root the ceiling bans. - The governance ceiling is keyed on the author (accessService.hasAdminAccess) rather than the destination bucket: an admin prototyping in their own bucket authors an admin app. - The bulk admin-apply surface (ConfigApplyService.applyApplication) now applies the same shape rules, returning a FAILED entity result — validate identically on every write path. - Platform-bucket PUT: shape validation runs before the underBucketLocks critical section — pure CPU work does not belong inside a cluster-wide lock. - Removed the BlobEntityValidator warning backstop added in the previous commit: BlobEntityValidator.validate has zero main-code callers (pre-existing dead code), so the claimed read-side mitigation was unreachable. The residual surface is config-file apps only — admin-authored by definition, and treated as unresolvable at resolution. - README settings table documents allowUserResourceDependencies. Co-Authored-By: Claude Code --- README.md | 9 +++++ .../server/config/BlobEntityValidator.java | 14 ------- .../controller/ConfigResourceController.java | 11 +++-- .../server/controller/ResourceController.java | 10 ++--- .../service/ResourceDependencyValidator.java | 21 ++++++++-- .../service/config/ConfigApplyService.java | 8 ++++ .../core/server/CustomApplicationApiTest.java | 13 ++++++ .../config/BlobEntityValidatorTest.java | 40 ------------------- .../ResourceDependencyValidatorTest.java | 27 ++++++++++++- 9 files changed, 85 insertions(+), 68 deletions(-) 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/server/src/main/java/com/epam/aidial/core/server/config/BlobEntityValidator.java b/server/src/main/java/com/epam/aidial/core/server/config/BlobEntityValidator.java index 48bd5ef7f..80193d9c0 100644 --- a/server/src/main/java/com/epam/aidial/core/server/config/BlobEntityValidator.java +++ b/server/src/main/java/com/epam/aidial/core/server/config/BlobEntityValidator.java @@ -2,7 +2,6 @@ import com.epam.aidial.core.config.Application; import com.epam.aidial.core.config.Config; -import com.epam.aidial.core.server.service.ResourceDependencyValidator; import java.net.URI; import java.util.ArrayList; @@ -29,22 +28,9 @@ public static List validate(Application application, Config c appendInterceptorWarnings(application.getInterceptors(), config, warnings); appendSchemaWarning(application.getApplicationTypeSchemaId(), config, warnings); appendDependencyWarnings(application.getDependencies(), config, warnings); - appendResourceDependencyWarnings(application, warnings); return warnings; } - /** - * Read-side backstop for the {@code resourceDependencies} section: the same rules the - * write-time validator enforces, as warnings — blob entities written before a rule existed, - * or arriving by copy/publication, surface here instead of failing a listing. The exact - * location travels inside the message ("resourceDependencies[i]: …"). - */ - private static void appendResourceDependencyWarnings(Application application, List warnings) { - for (String issue : ResourceDependencyValidator.shapeIssues(application)) { - warnings.add(new ValidationWarning("resourceDependencies", issue)); - } - } - private static void appendInterceptorWarnings(List refs, Config config, List warnings) { if (refs == null || refs.isEmpty()) { return; 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 061e97d00..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,10 +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); - // Always an admin context on this surface (see AdminRoleAuthorizationService), so the - // user-authored governance ceiling does not apply — shape only. - context.getProxy().getResourceDependencyValidator().validateShape(application); 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 ffaf08ebf..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 @@ -682,14 +682,14 @@ private Future> getToolsetData(ResourceDescri }); } - private void validateCustomApplication(Application application, boolean adminPublicWrite) { + private void validateCustomApplication(Application application) { try { checkCreateCodeApp(application); validateSchemaBasedApplication(application); resourceDependencyValidator.validateShape(application); - if (!adminPublicWrite) { - // Governance ceiling: user-authored apps may not declare dependencies while the flag is off, - // and personal targets must be typed even when it is on. + 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()) { @@ -800,7 +800,7 @@ private Future putResource(ResourceDescriptor descriptor) { AdminManagedFieldsWriteMode adminManagedFieldsWriteMode = AdminManagedFieldsWriteMode.of(adminPublicWrite, bodyJson); return taskExecutor.submit(() -> { - validateCustomApplication(application, adminPublicWrite); + validateCustomApplication(application); return applicationService.putApplication(descriptor, etag, author, application, adminPublicWrite, adminManagedFieldsWriteMode, externalServicesWriteMode).getKey(); }); 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 index a9d9033bd..2183e1434 100644 --- 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 @@ -72,25 +72,29 @@ public void validateUserAuthored(Application application) { continue; } String[] segments = decodedSegments(path); - if (CURRENT_USER_PLACEHOLDER.equals(segments[0]) && !isTypedPersonalPath(segments)) { + 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}, so the lazy read-side validator can reuse the same rules. */ + /** 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; } - if (section.size() > MAX_DECLARED_DEPENDENCIES) { + 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 < section.size(); i++) { + for (int i = 0; i < inspected; i++) { ResourceDependency dependency = section.get(i); String at = "resourceDependencies[" + i + "]"; if (dependency == null) { @@ -128,6 +132,11 @@ private static List pathIssues(String at, ResourceDependency dependency) // 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++) { @@ -155,6 +164,10 @@ private static List pathIssues(String at, ResourceDependency dependency) 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; } 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/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java b/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java index 9f444bcbb..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 @@ -1791,6 +1791,14 @@ void testResourceDependenciesSectionWriteTimeValidation() { {"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\"]}"}, @@ -1829,6 +1837,11 @@ private static String dependencyAppBody(String dependenciesJson) { """.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. diff --git a/server/src/test/java/com/epam/aidial/core/server/config/BlobEntityValidatorTest.java b/server/src/test/java/com/epam/aidial/core/server/config/BlobEntityValidatorTest.java index 48d2201e8..aa8ca17df 100644 --- a/server/src/test/java/com/epam/aidial/core/server/config/BlobEntityValidatorTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/config/BlobEntityValidatorTest.java @@ -4,14 +4,11 @@ import com.epam.aidial.core.config.Config; import com.epam.aidial.core.config.Interceptor; import com.epam.aidial.core.config.Model; -import com.epam.aidial.core.config.ResourceAccessType; -import com.epam.aidial.core.config.ResourceDependency; import org.junit.jupiter.api.Test; import java.net.URI; import java.util.List; import java.util.Map; -import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -104,41 +101,4 @@ void validateApplication_returnsNoWarnings_whenFieldsAreNullOrEmpty() { assertTrue(warnings.isEmpty()); } - @Test - void validateApplication_warnsOnMalformedResourceDependenciesSection() { - // The read-side backstop reuses the write-time shape rules: a concrete personal path - // (rejected at PUT) surfaces here as a warning for entities that arrived by other means. - Config config = new Config(); - - Application app = new Application(); - app.setResourceDependencies(List.of(new ResourceDependency() - .setKind(ResourceDependency.KIND) - .setLinkId("lnk_1") - .setTarget(new ResourceDependency.Target().setPath("users/bob/files/f/")) - .setAccess(Set.of(ResourceAccessType.READ)))); - - List warnings = BlobEntityValidator.validate(app, config); - - assertEquals(1, warnings.size()); - assertEquals("resourceDependencies", warnings.get(0).getField()); - assertTrue(warnings.get(0).getMessage().contains("current-user placeholder"), - () -> "Unexpected message: " + warnings.get(0).getMessage()); - } - - @Test - void validateApplication_returnsNoWarnings_forValidResourceDependenciesSection() { - Config config = new Config(); - - Application app = new Application(); - app.setResourceDependencies(List.of(new ResourceDependency() - .setKind(ResourceDependency.KIND) - .setLinkId("lnk_1") - .setTarget(new ResourceDependency.Target().setPath("current-user/skills/")) - .setAccess(Set.of(ResourceAccessType.WRITE)))); - - List warnings = BlobEntityValidator.validate(app, config); - - assertTrue(warnings.isEmpty()); - } - } 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 index 0332c001d..0cfdfb4c3 100644 --- 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 @@ -180,7 +180,32 @@ void rejectsSectionOverTheCap() { .toList(); Application application = new Application().setResourceDependencies(section); - assertTrue(validatorShapeMessage(validator, application).contains("exceeds")); + // 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