diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ResponsesController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ResponsesController.java index 5166ff2e1..866ac8ca0 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ResponsesController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ResponsesController.java @@ -24,6 +24,7 @@ import com.epam.aidial.core.server.function.CollectResponsesApiOutputAttachmentsFn; import com.epam.aidial.core.server.function.ExtractTerminalResponseFn; import com.epam.aidial.core.server.function.ReplaceResponseIdFn; +import com.epam.aidial.core.server.function.ResolveResourceDependenciesFn; import com.epam.aidial.core.server.function.enhancement.ApplyDefaultDeploymentSettingsFn; import com.epam.aidial.core.server.function.enhancement.EnhanceDeploymentRequestFn; import com.epam.aidial.core.server.function.request.RequestObject; @@ -72,7 +73,8 @@ public ResponsesController(Proxy proxy, ProxyContext context) { new EnhanceDeploymentRequestFn(proxy, context), new CollectRequestApplicationFilesFn(proxy, context), new BuildUpstreamCacheFn(proxy, context, InterfaceType.OPENAI_RESPONSES), - new CollectDeploymentsFn(proxy, context)); + new CollectDeploymentsFn(proxy, context), + new ResolveResourceDependenciesFn(proxy, context)); } @Override diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/anthropic/MessagesBaseController.java b/server/src/main/java/com/epam/aidial/core/server/controller/anthropic/MessagesBaseController.java index 7e0622700..4bc676833 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/anthropic/MessagesBaseController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/anthropic/MessagesBaseController.java @@ -13,6 +13,7 @@ import com.epam.aidial.core.server.function.CollectDeploymentsFn; import com.epam.aidial.core.server.function.CollectRequestApplicationFilesFn; import com.epam.aidial.core.server.function.CollectRequestStandardAttachmentsFn; +import com.epam.aidial.core.server.function.ResolveResourceDependenciesFn; import com.epam.aidial.core.server.function.enhancement.ApplyDefaultDeploymentSettingsFn; import com.epam.aidial.core.server.function.enhancement.EnhanceDeploymentRequestFn; import com.epam.aidial.core.server.function.request.MessagesApiRequest; @@ -72,7 +73,8 @@ protected List> buildEnhancementFunctions() { new ApplyDefaultDeploymentSettingsFn(proxy, context, InterfaceType.ANTHROPIC_MESSAGES), new EnhanceDeploymentRequestFn(proxy, context), new CollectRequestApplicationFilesFn(proxy, context), - new CollectDeploymentsFn(proxy, context)); + new CollectDeploymentsFn(proxy, context), + new ResolveResourceDependenciesFn(proxy, context)); } public Future handle() { diff --git a/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java b/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java index d5fd0cfc8..d4f007872 100644 --- a/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java +++ b/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java @@ -24,19 +24,28 @@ import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import javax.annotation.Nullable; /** - * Request-start resolution of the application's declared resource dependencies (design §7.1): - * resolve each declared target, verify it fresh against the originating user's reach, intersect - * with the admin-consented set, and bake the passing grants into the per-request key the - * application will hold — the app never asks for a credential; the key it already holds gets - * richer. A record is a request, not a grant: nothing here widens anything the user cannot - * already reach. + * Request-start resolution of the called application's declared resource dependencies (design + * §7.1): resolve each declared target, verify it fresh against the originating user's reach, + * intersect with the content-bound admin-consent record, and bake the passing grants into the + * per-request key the application will hold — the app never asks for a credential; the key it + * already holds gets richer. A record is a request, not a grant: nothing here widens anything + * the user cannot already reach. + * + *

Root-call only. Resolution runs when the context still carries the originating user + * (no per-request key) — that is the only state in which the user's reach is directly + * evaluable; under a per-request key the same checks would silently evaluate a deployment's own + * key instead. Hops that arrive with a per-request key present — an interceptor's final call + * back to the app, or a chained app-to-app call — are skipped: their declarations do not + * resolve and their grants do not propagate in v1 (documented limitation; chained composition + * needs originating-user evaluation under key contexts, which is phase-2 machinery). */ public class ResolveResourceDependenciesFn extends BaseRequestFunction { @@ -55,13 +64,10 @@ public Boolean apply(RequestObject request) { if (declaration == null || declaration.isEmpty()) { return false; } - // Load-bearing timing: this function runs in the enhancement chain BEFORE the per-request key - // is assigned, so the reach checks below evaluate the originating user's permissions. After - // assignment the same calls would silently evaluate the app's own key instead — make that - // drift loud instead of silent. if (context.getApiKeyData().getPerRequestKey() != null) { - throw new IllegalStateException( - "Resource dependencies must be resolved before the per-request key is assigned"); + // Not the root user call — see the class javadoc. Skip, never throw: a declaring app + // behind an interceptor or in a chain must stay callable, just without grants. + return false; } resolve(application, declaration); return false; @@ -70,48 +76,47 @@ public Boolean apply(RequestObject request) { private void resolve(Application application, List declaration) { String applicationId = application.getName(); // The consent record is content-bound to the whole declaration: any change since the grant - // re-requires it, and until then nothing resolves. + // re-requires it, and until then nothing resolves. Checked before any resolution work. boolean consented = proxy.getConsentService().isAdminConsented(applicationId, declaration); - AccessService accessService = proxy.getAccessService(); - ApiKeyData proxyApiKeyData = context.getProxyApiKeyData(); AuthBucket userBucket = BucketBuilder.buildBucket(context); - List granted = new ArrayList<>(); - List unresolved = new ArrayList<>(); + List resolvedTargets = new ArrayList<>(); + List unresolvedDeps = new ArrayList<>(); List requiredFailures = new ArrayList<>(); for (ResourceDependency dependency : declaration) { - Set requestedAccess = requestedAccessOf(dependency); + Set requestedAccess = consented ? requestedAccessOf(dependency) : null; ResourceDescriptor target = requestedAccess == null ? null : resolveTarget(dependency, userBucket); - if (!consented || target == null) { - // Fail closed per record: unconsented, malformed (config-file apps bypass write-time - // validation) — no grant, no failure, unless required. - trackUnresolved(dependency, target, unresolved, requiredFailures); - continue; - } - Set userAccess = - accessService.lookupPermissions(Set.of(target), context).getOrDefault(target, Set.of()); - if (userAccess.containsAll(requestedAccess)) { - // Both halves of the delivery: perRequestSharedResources serves the application's own - // direct calls with this key (the access rule reads the presented key's shared map); - // perRequestReceivers[app] carries the grants to every descendant mint down a chained - // call — ApiKeyData.initFromContext always shares the initial deployment's receiver - // entry into each child key. - proxyApiKeyData.getPerRequestSharedResources() - .put(target.getUrl(), new PerRequestSharedData(requestedAccess)); - proxyApiKeyData.getPerRequestReceivers() - .computeIfAbsent(applicationId, key -> new HashMap<>()) - .put(target.getUrl(), new PerRequestSharedData(requestedAccess)); - granted.add(entryOf(dependency)); + if (target == null) { + // Fail closed per record: malformed (config-file apps bypass write-time validation) + // or unconsented — no grant, no failure, unless required. + trackUnresolved(dependency, unresolvedDeps, requiredFailures); } else { - // The user cannot reach the target with the declared rights — the record simply does - // not grant; it does not fail the call unless required. - trackUnresolved(dependency, target, unresolved, requiredFailures); + resolvedTargets.add(new Resolved(dependency, target, requestedAccess)); + } + } + + List granted = new ArrayList<>(); + if (!resolvedTargets.isEmpty()) { + // One batched walk of the permission chain for all resolved targets. + Set targets = resolvedTargets.stream().map(Resolved::target).collect(Collectors.toSet()); + Map> userAccessByTarget = + proxy.getAccessService().lookupPermissions(targets, context); + for (Resolved resolved : resolvedTargets) { + Set userAccess = userAccessByTarget.getOrDefault(resolved.target(), Set.of()); + if (userAccess.containsAll(resolved.access())) { + bakeGrant(resolved.target(), resolved.access()); + granted.add(entryOf(resolved.dependency())); + } else { + // The user cannot reach the target with the declared rights — the record simply + // does not grant; it does not fail the call unless required. + trackUnresolved(resolved.dependency(), unresolvedDeps, requiredFailures); + } } } ResourceDependencyAuditLog.grant(context, applicationId, granted); - ResourceDependencyAuditLog.denial(context, applicationId, unresolved); + ResourceDependencyAuditLog.denial(context, applicationId, entriesOf(unresolvedDeps)); if (!requiredFailures.isEmpty()) { // A required dependency is unresolvable — the application never half-works silently. ResourceDependencyAuditLog.runtimeFail(context, applicationId, requiredFailures); @@ -120,21 +125,44 @@ private void resolve(Application application, List declarati } } - private static void trackUnresolved(ResourceDependency dependency, @Nullable ResourceDescriptor target, - List unresolved, List requiredFailures) { - String path = dependency == null || dependency.getTarget() == null ? null : dependency.getTarget().getPath(); - if (dependency != null && dependency.isRequired()) { + private record Resolved(ResourceDependency dependency, ResourceDescriptor target, Set access) { + } + + private void bakeGrant(ResourceDescriptor target, Set requestedAccess) { + // Union semantics, like every other grant writer: two records targeting the same URL + // with different rights must combine, not overwrite each other. + ApiKeyData proxyApiKeyData = context.getProxyApiKeyData(); + proxyApiKeyData.getPerRequestSharedResources() + .computeIfAbsent(target.getUrl(), key -> new PerRequestSharedData(new HashSet<>())) + .permissions().addAll(requestedAccess); + } + + private static void trackUnresolved(@Nullable ResourceDependency dependency, + List unresolvedDeps, List requiredFailures) { + if (dependency == null) { + return; + } + String path = dependency.getTarget() == null ? null : dependency.getTarget().getPath(); + if (dependency.isRequired()) { requiredFailures.add(path == null ? "" : path); } - unresolved.add(entryOf(dependency)); + unresolvedDeps.add(dependency); + } + + private static List entriesOf(List dependencies) { + List entries = new ArrayList<>(dependencies.size()); + for (ResourceDependency dependency : dependencies) { + entries.add(entryOf(dependency)); + } + return entries; } - private static Consent.ResourceEntry entryOf(@Nullable ResourceDependency dependency) { + private static Consent.ResourceEntry entryOf(ResourceDependency dependency) { Consent.ResourceEntry entry = new Consent.ResourceEntry(); - if (dependency != null && dependency.getTarget() != null) { + if (dependency.getTarget() != null) { entry.setUrl(dependency.getTarget().getPath()); } - if (dependency != null && dependency.getAccess() != null) { + if (dependency.getAccess() != null) { entry.setAccess(new HashSet<>(dependency.getAccess())); } return entry; @@ -162,9 +190,9 @@ private static Set requestedAccessOf(@Nullable ResourceDepen } /** - * Resolves a declared target path to a descriptor: a {@code current-user/…} path against the - * originating user's own bucket, a concrete global-view path as-is. Null when the record is - * malformed — an unresolvable record, never a crash. + * Resolves a declared target path to a descriptor: a {@code current-user//} + * path against the originating user's own bucket, a concrete global-view path as-is. Null + * when the record is malformed — an unresolvable record, never a crash. */ @Nullable private ResourceDescriptor resolveTarget(ResourceDependency dependency, AuthBucket userBucket) { @@ -182,10 +210,12 @@ private ResourceDescriptor resolveTarget(ResourceDependency dependency, AuthBuck return null; } if (ResourceDependencyValidator.CURRENT_USER_PLACEHOLDER.equals(segments[0])) { - // current-user// — the type segment names the target's resource - // type, the rest is the path inside the user's bucket. Two segments (e.g. - // current-user/skills/) target the type's root folder in the user's bucket. - if (segments.length < 2) { + // current-user//. Two segments (current-user/skills/) target the + // type's root folder in the user's bucket — the skill-creator shape. The type segment + // is restricted to the personal typed-root vocabulary: ResourceTypes.of() also maps + // internal engine types (credentials, keys, models, …), and a declaration like + // current-user/credentials/ must never resolve into the user's secret-bearing blobs. + if (segments.length < 2 || !ResourceDependencyValidator.PERSONAL_TYPED_ROOTS.contains(segments[1])) { return null; } ResourceType type = ResourceTypes.of(segments[1]); @@ -198,7 +228,9 @@ private ResourceDescriptor resolveTarget(ResourceDependency dependency, AuthBuck type, userBucket.getUserBucket(), userBucket.getUserBucketLocation(), relativePath); } return ResourceDescriptorFactory.fromAnyUrl(path, proxy.getEncryptionService()); - } catch (IllegalArgumentException e) { + } catch (RuntimeException e) { + // fromAnyUrl wraps URISyntaxException in a plain RuntimeException — malformed means + // unresolvable, whatever the exception shape. return null; } } diff --git a/server/src/main/java/com/epam/aidial/core/server/log/ResourceDependencyAuditLog.java b/server/src/main/java/com/epam/aidial/core/server/log/ResourceDependencyAuditLog.java index 8f2840420..3812244f1 100644 --- a/server/src/main/java/com/epam/aidial/core/server/log/ResourceDependencyAuditLog.java +++ b/server/src/main/java/com/epam/aidial/core/server/log/ResourceDependencyAuditLog.java @@ -87,6 +87,7 @@ private static String targetsOf(List entries) { private static String accessTypesOf(List entries) { return entries.stream() .flatMap(entry -> entry.getAccess().stream()) + .filter(java.util.Objects::nonNull) .map(Enum::name) .distinct() .collect(Collectors.joining(",")); 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 2183e1434..59a24d085 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 @@ -38,8 +38,10 @@ public class ResourceDependencyValidator { /** * 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. + * Also enforced by the resolver on the read side: ResourceTypes.of() maps internal engine types + * (credentials, keys, models, …) that must never be declarable as personal targets. */ - private static final Set PERSONAL_TYPED_ROOTS = + public static final Set PERSONAL_TYPED_ROOTS = Set.of("files", "prompts", "conversations", "applications", "toolsets", "skills"); private final boolean allowUserResourceDependencies; diff --git a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyApiTest.java new file mode 100644 index 000000000..2a04df3a0 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyApiTest.java @@ -0,0 +1,252 @@ +package com.epam.aidial.core.server; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.epam.aidial.core.server.data.ApiKeyData; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The v1 checklist as test cases (design §12, resolution PR): the consented flow, the two + * failure modes distinguished (required vs optional without consent), content binding on + * declaration change, and the audit fields. Scenarios run on the chat-completions path; the + * Anthropic-Messages and Responses sites share the identical chain position (see + * {@code ResourceDependencyResolutionApiTest} for the full happy-path walk). + */ +public class ResourceDependencyApiTest extends ResourceBaseTest { + + private static final String PROMPT_BODY = + "{\"id\":\"prompt-by-app\",\"folderId\":\"dep-smoke/\",\"name\":\"prompt-by-app\",\"content\":\"app content\"}"; + + private String userBucket() { + Response response = send(HttpMethod.GET, "/v1/bucket", null, "", "authorization", "user"); + return new JsonObject(response.body()).getString("bucket"); + } + + private Response putDeclaringApp(String dependenciesJson) { + return send(HttpMethod.PUT, "/v1/applications/public/dep-e2e-app", null, """ + { + "endpoint": "http://localhost:4849/chat/completions", + "display_name": "Dependency E2E App", + "resource_dependencies": [%s] + } + """.formatted(dependenciesJson), "authorization", "admin"); + } + + private Response grantConsent() { + return send(HttpMethod.POST, "/v1/consent/applications/public/dep-e2e-app/admin-consent", null, "", + "authorization", "admin"); + } + + /** A chat completion against the declaring app; the upstream exercises the declared target with the app's key. */ + private Response chat(AtomicReference targetStatus, String targetPath) { + try (TestWebServer server = new TestWebServer(4849)) { + server.map(HttpMethod.POST, "/chat/completions", request -> { + String perRequestKey = request.getHeader("Api-Key"); + targetStatus.set(send(HttpMethod.PUT, targetPath, null, PROMPT_BODY, + "api-key", perRequestKey).status()); + return TestWebServer.createResponse(200, + "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion\",\"choices\":[]}", + "Content-Type", "application/json"); + }); + return send(HttpMethod.POST, "/openai/deployments/applications/public/dep-e2e-app/chat/completions", + null, """ + {"messages":[{"role":"user","content":"how are you?"}]} + """, "authorization", "user", "Content-Type", "application/json"); + } + } + + @Test + void testRequiredDependencyWithoutConsentFailsTheCall() { + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + """), 200); + String bucket = userBucket(); + + AtomicReference targetStatus = new AtomicReference<>(); + Response completion = chat(targetStatus, "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket)); + + assertEquals(403, completion.status(), () -> "Body: " + completion.body()); + assertEquals(null, targetStatus.get(), "the upstream must never be reached when a required dependency fails"); + } + + @Test + void testOptionalDependencyWithoutConsentDegradesSilently() { + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": false} + """), 200); + String bucket = userBucket(); + + AtomicReference targetStatus = new AtomicReference<>(); + Response completion = chat(targetStatus, "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket)); + + assertEquals(200, completion.status(), () -> "Body: " + completion.body()); + assertEquals(403, targetStatus.get(), "the call succeeds, the undeclared-consent target stays off-limits"); + } + + @Test + void testConsentedRequiredDependencyGrantsAndFailsAfterWithdrawal() { + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + """), 200); + verify(grantConsent(), 200); + String bucket = userBucket(); + + AtomicReference grantedStatus = new AtomicReference<>(); + Response completion = chat(grantedStatus, "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket)); + assertEquals(200, completion.status()); + assertEquals(200, grantedStatus.get(), "the consented target must be writable by the app's key"); + + verify(send(HttpMethod.DELETE, "/v1/consent/applications/public/dep-e2e-app/admin-consent", null, "", + "authorization", "admin"), 200); + + AtomicReference withdrawnStatus = new AtomicReference<>(); + Response afterWithdrawal = chat(withdrawnStatus, "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket)); + assertEquals(403, afterWithdrawal.status(), "withdrawal stops the run immediately for a required dependency"); + } + + @Test + void testDeclarationChangeInvalidatesTheGrant() { + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + """), 200); + verify(grantConsent(), 200); + // Any declaration change re-requires the grant — content binding. + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true}, + {"kind": "dial.resourceLink", "link_id": "lnk_extra", "target": {"path": "current-user/prompts/other/"}, "access": ["read"]} + """), 200); + String bucket = userBucket(); + + AtomicReference targetStatus = new AtomicReference<>(); + Response completion = chat(targetStatus, "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket)); + + assertEquals(403, completion.status(), "the stale grant must not cover the changed declaration"); + } + + @Test + void testPublicTargetGrantFollowsTheUsersOwnReach() { + // Pointer semantics, end to end (D-04): the declaration adds visibility and consent, never + // access — the app's key reaches a public target exactly when the originating user does. + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "files/public/dep-public/"}, "access": ["read"], "required": false} + """), 200); + verify(grantConsent(), 200); + + int userDirect = send(HttpMethod.GET, "/v1/files/public/dep-public/some.txt", null, "", + "authorization", "user").status(); + + AtomicReference viaApp = new AtomicReference<>(); + try (TestWebServer server = new TestWebServer(4849)) { + server.map(HttpMethod.POST, "/chat/completions", request -> { + viaApp.set(send(HttpMethod.GET, "/v1/files/public/dep-public/some.txt", null, "", + "api-key", request.getHeader("Api-Key")).status()); + return TestWebServer.createResponse(200, + "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion\",\"choices\":[]}", + "Content-Type", "application/json"); + }); + Response completion = send(HttpMethod.POST, + "/openai/deployments/applications/public/dep-e2e-app/chat/completions", null, """ + {"messages":[{"role":"user","content":"how are you?"}]} + """, "authorization", "user", "Content-Type", "application/json"); + assertEquals(200, completion.status(), () -> "Body: " + completion.body()); + } + + assertEquals(userDirect, viaApp.get(), + "the app's key must see exactly what the originating user sees — no more, no less"); + } + + @Test + void testChainedCallIntoDeclaringAppStaysCallableWithoutGrants() { + // Root-call-only resolution (design §7.1's chained composition is phase-2 machinery): a + // chained hop presenting a per-request key must stay callable — never the hard failure the + // first revision threw — and must not bake grants it cannot evaluate the originating + // user's reach for. + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + """), 200); + verify(grantConsent(), 200); + String bucket = userBucket(); + + // The key an orchestrator would hold when delegating to the declaring app. + ApiKeyData appKey = createAppKey("user", java.util.Map.of()); + appKey.setExecutionPath(List.of("orchestrator")); + apiKeyStore.assignPerRequestApiKey(appKey); + + AtomicReference targetStatus = new AtomicReference<>(); + try (TestWebServer server = new TestWebServer(4849)) { + server.map(HttpMethod.POST, "/chat/completions", request -> { + targetStatus.set(send(HttpMethod.PUT, + "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket), null, PROMPT_BODY, + "api-key", request.getHeader("Api-Key")).status()); + return TestWebServer.createResponse(200, + "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion\",\"choices\":[]}", + "Content-Type", "application/json"); + }); + Response completion = send(HttpMethod.POST, + "/openai/deployments/applications/public/dep-e2e-app/chat/completions", null, """ + {"messages":[{"role":"user","content":"how are you?"}]} + """, "api-key", appKey.getPerRequestKey(), "Content-Type", "application/json"); + assertEquals(200, completion.status(), () -> "Body: " + completion.body()); + } + + // Callable, but the dependency did not resolve on this hop: no grant, target off-limits. + assertEquals(403, targetStatus.get(), + "a chained hop must not receive grants — the originating user's reach is not evaluable there"); + } + + @Test + void testAuditEventsCarryTheDesignFields() { + Logger auditLogger = (Logger) LoggerFactory.getLogger("DIAL_RESOURCE_DEPS_AUDIT"); + ListAppender appender = new ListAppender<>(); + appender.start(); + auditLogger.addAppender(appender); + try { + String bucket = userBucket(); + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": false} + """), 200); + + // optional, unconsented: the call succeeds and the denial is audited + AtomicReference targetStatus = new AtomicReference<>(); + assertEquals(200, chat(targetStatus, "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket)).status()); + + // required, unconsented: the call fails and the runtime failure is audited + verify(putDeclaringApp(""" + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + """), 200); + AtomicReference failedStatus = new AtomicReference<>(); + assertEquals(403, chat(failedStatus, "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket)).status()); + + List events = appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith("event=resource_dependency_")) + .collect(Collectors.toList()); + + String denial = events.stream().filter(e -> e.contains("event=resource_dependency_denial")).findFirst().orElse(null); + assertNotNull(denial, () -> "Events: " + events); + assertTrue(denial.contains("application_id=applications/public/dep-e2e-app"), denial); + assertTrue(denial.contains("targets=current-user/prompts/dep-smoke/"), denial); + assertTrue(denial.contains("user_id=user"), denial); + assertTrue(denial.contains("trace_id=") && !denial.contains("trace_id=,") && !denial.contains("trace_id=null"), denial); + + String runtimeFail = events.stream() + .filter(e -> e.contains("event=resource_dependency_runtime_fail")).findFirst().orElse(null); + assertNotNull(runtimeFail, () -> "Events: " + events); + assertTrue(runtimeFail.contains("outcome=RUNTIME_FAIL"), runtimeFail); + } finally { + auditLogger.detachAppender(appender); + } + } +} diff --git a/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java b/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java index cc4878f66..b2bd47ffd 100644 --- a/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java @@ -7,7 +7,6 @@ import com.epam.aidial.core.server.Proxy; import com.epam.aidial.core.server.ProxyContext; import com.epam.aidial.core.server.data.ApiKeyData; -import com.epam.aidial.core.server.data.permission.PerRequestSharedData; import com.epam.aidial.core.server.function.request.ChatCompletionRequest; import com.epam.aidial.core.server.function.request.RequestObject; import com.epam.aidial.core.server.security.AccessService; @@ -120,16 +119,20 @@ void apply_isNoOpWithoutDeclaration() { } @Test - void apply_throwsWhenRunAfterKeyAssignment() { - // Load-bearing timing: after assignment the reach checks would silently evaluate the app's - // own key instead of the user — that drift must be loud, not silent. + void apply_skipsWhenNotTheRootUserCall() { + // Load-bearing timing: under a per-request key the reach checks would evaluate a + // deployment's own key instead of the originating user — so hops that arrive with one + // (an interceptor's final call back, a chained app-to-app call) are skipped, never run + // and never thrown: a declaring app behind an interceptor must stay callable. when(context.getDeployment()).thenReturn(application); application.setResourceDependencies(List.of(dependency("current-user/skills/", false))); ApiKeyData assigned = new ApiKeyData(); assigned.setPerRequestKey("prk"); when(context.getApiKeyData()).thenReturn(assigned); - assertThrows(IllegalStateException.class, () -> fn.apply(request)); + assertFalse(fn.apply(request)); + verify(consentService, never()).isAdminConsented(anyString(), any()); + assertTrue(proxyApiKeyData.getPerRequestSharedResources().isEmpty()); } @Test @@ -142,14 +145,50 @@ void apply_bakesGrantForConsentedReachablePlaceholderTarget() { assertFalse(fn.apply(request)); - // The target lands in the app's own shared map (its direct calls with this key) and in the - // receivers entry (descendant mints down a chained call); the target is the user's skills - // ROOT folder, so the grant prefix-matches everything under it (findFolderPermissions). + // The grant lands in the key's own shared map — the app's direct calls with this key are + // served by it; the target is the user's skills ROOT folder, so the grant prefix-matches + // everything under it (findFolderPermissions). assertEquals(Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE), proxyApiKeyData.getPerRequestSharedResources().get(userSkillsFolder().getUrl()).permissions()); - Map receiverGrants = proxyApiKeyData.getPerRequestReceivers().get("app"); + } + + @Test + void apply_combinesGrantsForRecordsWithTheSameTarget() { + // Two records targeting the same URL with different rights combine, never overwrite. + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of( + new ResourceDependency().setKind(ResourceDependency.KIND).setLinkId("lnk_w") + .setTarget(new ResourceDependency.Target().setPath("files/public/p/")) + .setAccess(Set.of(ResourceAccessType.WRITE)), + new ResourceDependency().setKind(ResourceDependency.KIND).setLinkId("lnk_r") + .setTarget(new ResourceDependency.Target().setPath("files/public/p/")) + .setAccess(Set.of(ResourceAccessType.READ)))); + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); + ResourceDescriptor target = com.epam.aidial.core.server.util.ResourceDescriptorFactory + .fromAnyUrl("files/public/p/", encryptionService); + when(accessService.lookupPermissions(any(), eq(context))) + .thenReturn(Map.of(target, Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE))); + + assertFalse(fn.apply(request)); + assertEquals(Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE), - receiverGrants.get(userSkillsFolder().getUrl()).permissions()); + proxyApiKeyData.getPerRequestSharedResources().get("files/public/p/").permissions()); + } + + @Test + void apply_neverResolvesInternalEngineTypesAsPersonalTargets() { + // ResourceTypes.of() maps internal engine types (credentials, keys, models…) — a + // current-user/credentials/ declaration must never reach the user's secret-bearing blobs, + // whoever authored the app (config-file apps bypass the write-time ceiling). + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of( + dependency("current-user/credentials/", false), + dependency("current-user/keys/", false))); + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); + + assertFalse(fn.apply(request)); + verify(accessService, never()).lookupPermissions(any(), any()); + assertTrue(proxyApiKeyData.getPerRequestSharedResources().isEmpty()); } @Test @@ -165,7 +204,7 @@ void apply_bakesGrantForConcretePublicFolderTarget() { assertFalse(fn.apply(request)); assertEquals(Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE), - proxyApiKeyData.getPerRequestReceivers().get("app").get("files/public/policies/").permissions()); + proxyApiKeyData.getPerRequestSharedResources().get("files/public/policies/").permissions()); } @Test