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(); + } +}