Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,15 @@ the client subscribed with no events ever reaching it.

</details>

<details>
<summary><b>Configuration Files Configurations</b></summary>

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

</details>

<details>
<summary><b>Applications Configurations</b></summary>

Expand Down
3 changes: 2 additions & 1 deletion sample/aidial.settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"config": {
"files": ["/app/config/aidial.config.json"],
"reload": 60000
"reload": 60000,
"allowUserResourceDependencies": false
},
"redis": {
"singleServerConfig": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions server/src/main/java/com/epam/aidial/core/server/Proxy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -177,6 +178,7 @@ public class Proxy implements Handler<HttpServerRequest> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<String> 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<ResourceDependency> 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<String> shapeIssues(Application application) {
List<String> issues = new ArrayList<>();
List<ResourceDependency> 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to call the min function?
We already know the size is less or equal to MAX cap

Set<String> 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<String> pathIssues(String at, ResourceDependency dependency) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the logic should rely on ResourceDescriptorFactory

List<String> 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);
}
}
Loading