diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/DeploymentPostController.java b/server/src/main/java/com/epam/aidial/core/server/controller/DeploymentPostController.java index 6d643c8fa..5d9f41870 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/DeploymentPostController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/DeploymentPostController.java @@ -24,6 +24,7 @@ import com.epam.aidial.core.server.function.CollectRequestApplicationFilesFn; import com.epam.aidial.core.server.function.CollectRequestStandardAttachmentsFn; import com.epam.aidial.core.server.function.CollectResponseChatCompletionAttachmentsFn; +import com.epam.aidial.core.server.function.ResolveResourceDependenciesFn; import com.epam.aidial.core.server.function.StripUsagePerModelFn; import com.epam.aidial.core.server.function.enhancement.ApplyDefaultDeploymentSettingsFn; import com.epam.aidial.core.server.function.enhancement.EnhanceDeploymentRequestFn; @@ -78,7 +79,8 @@ private List> buildEnhancementFunctions() { new EnhanceDeploymentRequestFn(proxy, context), new CollectRequestApplicationFilesFn(proxy, context), new BuildUpstreamCacheFn(proxy, context, InterfaceType.OPENAI_CHAT_COMPLETIONS), - new CollectDeploymentsFn(proxy, context)); + new CollectDeploymentsFn(proxy, context), + new ResolveResourceDependenciesFn(proxy, context)); } @ApiOperation( 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 new file mode 100644 index 000000000..d5fd0cfc8 --- /dev/null +++ b/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java @@ -0,0 +1,210 @@ +package com.epam.aidial.core.server.function; + +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.server.Proxy; +import com.epam.aidial.core.server.ProxyContext; +import com.epam.aidial.core.server.data.ApiKeyData; +import com.epam.aidial.core.server.data.AuthBucket; +import com.epam.aidial.core.server.data.consent.Consent; +import com.epam.aidial.core.server.data.permission.PerRequestSharedData; +import com.epam.aidial.core.server.function.request.RequestObject; +import com.epam.aidial.core.server.log.ResourceDependencyAuditLog; +import com.epam.aidial.core.server.security.AccessService; +import com.epam.aidial.core.server.service.ResourceDependencyValidator; +import com.epam.aidial.core.server.util.BucketBuilder; +import com.epam.aidial.core.server.util.ResourceDescriptorFactory; +import com.epam.aidial.core.storage.http.HttpException; +import com.epam.aidial.core.storage.http.HttpStatus; +import com.epam.aidial.core.storage.resource.ResourceDescriptor; +import com.epam.aidial.core.storage.resource.ResourceType; +import com.epam.aidial.core.storage.resource.ResourceTypes; +import com.epam.aidial.core.storage.util.UrlUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +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. + */ +public class ResolveResourceDependenciesFn extends BaseRequestFunction { + + public ResolveResourceDependenciesFn(Proxy proxy, ProxyContext context) { + super(proxy, context); + } + + @Override + public Boolean apply(RequestObject request) { + if (!(context.getDeployment() instanceof Application application)) { + // Interceptor hop or a non-application deployment — dependencies resolve for the + // application being called, nothing else. + return false; + } + List declaration = application.getResourceDependencies(); + 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"); + } + resolve(application, declaration); + return false; + } + + 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. + 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 requiredFailures = new ArrayList<>(); + + for (ResourceDependency dependency : declaration) { + Set requestedAccess = requestedAccessOf(dependency); + 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)); + } 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); + } + } + + ResourceDependencyAuditLog.grant(context, applicationId, granted); + ResourceDependencyAuditLog.denial(context, applicationId, unresolved); + if (!requiredFailures.isEmpty()) { + // A required dependency is unresolvable — the application never half-works silently. + ResourceDependencyAuditLog.runtimeFail(context, applicationId, requiredFailures); + throw new HttpException(HttpStatus.FORBIDDEN, + "Required resource dependencies are not accessible: " + String.join(", ", requiredFailures)); + } + } + + 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()) { + requiredFailures.add(path == null ? "" : path); + } + unresolved.add(entryOf(dependency)); + } + + private static Consent.ResourceEntry entryOf(@Nullable ResourceDependency dependency) { + Consent.ResourceEntry entry = new Consent.ResourceEntry(); + if (dependency != null && dependency.getTarget() != null) { + entry.setUrl(dependency.getTarget().getPath()); + } + if (dependency != null && dependency.getAccess() != null) { + entry.setAccess(new HashSet<>(dependency.getAccess())); + } + return entry; + } + + /** The dependency's requested rights, or null when the record cannot be granted at all. */ + @Nullable + private static Set requestedAccessOf(@Nullable ResourceDependency dependency) { + if (dependency == null || !ResourceDependency.KIND.equals(dependency.getKind())) { + // Wrong or missing kind is not a resource link — unresolvable (config-file apps bypass + // write-time validation, so the read side enforces the same vocabulary). + return null; + } + if (dependency.getAccess() == null || dependency.getAccess().isEmpty()) { + return null; + } + // Only READ and WRITE are dependency rights; anything else (SHARE, future vocabulary that + // bypassed write-time validation) makes the record unresolvable. + for (ResourceAccessType access : dependency.getAccess()) { + if (access != ResourceAccessType.READ && access != ResourceAccessType.WRITE) { + return null; + } + } + return Set.copyOf(dependency.getAccess()); + } + + /** + * 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) { + if (dependency.getTarget() == null || dependency.getTarget().getPath() == null) { + return null; + } + String path = dependency.getTarget().getPath().trim(); + if (path.isEmpty()) { + return null; + } + boolean folder = path.endsWith("/"); + try { + String[] segments = decodedSegments(path, folder); + if (segments.length == 0) { + 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) { + return null; + } + ResourceType type = ResourceTypes.of(segments[1]); + String relativePath = segments.length == 2 + ? "" : String.join("/", Arrays.asList(segments).subList(2, segments.length)); + if (folder && !relativePath.isEmpty()) { + relativePath += "/"; + } + return ResourceDescriptorFactory.fromDecoded( + type, userBucket.getUserBucket(), userBucket.getUserBucketLocation(), relativePath); + } + return ResourceDescriptorFactory.fromAnyUrl(path, proxy.getEncryptionService()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String[] decodedSegments(String path, boolean folder) { + String trimmed = folder ? path.substring(0, path.length() - 1) : path; + return Arrays.stream(trimmed.split("/")).map(UrlUtil::tryDecodePath).toArray(String[]::new); + } +} 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 b83d235e9..8f2840420 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 @@ -10,7 +10,6 @@ import org.slf4j.LoggerFactory; import java.util.List; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -23,13 +22,6 @@ public final class ResourceDependencyAuditLog { private static final Logger AUDIT = LoggerFactory.getLogger("DIAL_RESOURCE_DEPS_AUDIT"); - // \p{Cntrl} is ASCII-only, so Unicode line breaks (NEL, LS, PS) are listed explicitly — some log viewers - // treat them as line terminators. Tokens additionally forbid whitespace, '=' and '"' so a caller-supplied - // value can't forge key=value pairs within the line; reason keeps spaces (it is quoted) but drops '=' and - // '"' so it can neither escape its quotes nor carry a parseable forged token. - private static final Pattern TOKEN_UNSAFE = Pattern.compile("[\\p{Cntrl}\\s=\"\\u0085\\u2028\\u2029]"); - private static final Pattern REASON_UNSAFE = Pattern.compile("[\\p{Cntrl}=\"\\u0085\\u2028\\u2029]"); - private ResourceDependencyAuditLog() { } @@ -40,20 +32,64 @@ private ResourceDependencyAuditLog() { */ public static void consent(ProxyContext context, String applicationId, String action, List declaration, RuntimeException error) { - String targets = declaration == null ? "" : declaration.stream() + String targets = declaration == null ? "" : targetsOf(declaration); + String accessTypes = declaration == null ? "" : accessTypesOf(declaration); + AUDIT.info("event=resource_dependency_consent action={} outcome={} actor={} admin_user_id={} " + + "application_id={} targets={} access_types={} trace_id={}{}", + AuditLogSanitizer.sanitizeToken(action), outcomeOf(error), actorEvidence(context), + AuditLogSanitizer.sanitizeToken(context.getUserId()), AuditLogSanitizer.sanitizeToken(applicationId), + targets, accessTypes, context.getTraceId(), AuditLogSanitizer.reasonOf(error)); + } + + /** One event per run whose declared dependencies resolved into grants, listing what was granted. */ + public static void grant(ProxyContext context, String applicationId, List granted) { + if (granted.isEmpty()) { + return; + } + AUDIT.info("event=resource_dependency_grant outcome=SUCCESS actor={} user_id={} application_id={} " + + "targets={} access_types={} trace_id={}", + actorEvidence(context), AuditLogSanitizer.sanitizeToken(context.getUserId()), AuditLogSanitizer.sanitizeToken(applicationId), + targetsOf(granted), accessTypesOf(granted), context.getTraceId()); + } + + /** + * One event per run listing declared targets that did not resolve — the originating user + * cannot reach them with the declared rights, or the declaration is not admin-consented. + * No grant, no failure (unless a required record failed — see {@link #runtimeFail}). + */ + public static void denial(ProxyContext context, String applicationId, List unresolved) { + if (unresolved.isEmpty()) { + return; + } + AUDIT.info("event=resource_dependency_denial outcome=DENIED actor={} user_id={} application_id={} " + + "targets={} trace_id={}", + actorEvidence(context), AuditLogSanitizer.sanitizeToken(context.getUserId()), AuditLogSanitizer.sanitizeToken(applicationId), + targetsOf(unresolved), context.getTraceId()); + } + + /** One event when a required dependency failed to resolve and the call is rejected. */ + public static void runtimeFail(ProxyContext context, String applicationId, List requiredFailures) { + AUDIT.info("event=resource_dependency_runtime_fail outcome=RUNTIME_FAIL actor={} user_id={} " + + "application_id={} targets={} trace_id={} reason=\"required dependencies unresolvable\"", + actorEvidence(context), AuditLogSanitizer.sanitizeToken(context.getUserId()), AuditLogSanitizer.sanitizeToken(applicationId), + requiredFailures.stream().map(AuditLogSanitizer::sanitizeToken) + .collect(Collectors.joining(",")), + context.getTraceId()); + } + + private static String targetsOf(List entries) { + return entries.stream() .map(Consent.ResourceEntry::getUrl) - .map(ResourceDependencyAuditLog::sanitizeToken) + .map(AuditLogSanitizer::sanitizeToken) .collect(Collectors.joining(",")); - String accessTypes = declaration == null ? "" : declaration.stream() + } + + private static String accessTypesOf(List entries) { + return entries.stream() .flatMap(entry -> entry.getAccess().stream()) .map(Enum::name) .distinct() .collect(Collectors.joining(",")); - AUDIT.info("event=resource_dependency_consent action={} outcome={} actor={} admin_user_id={} " - + "application_id={} targets={} access_types={} trace_id={}{}", - sanitizeToken(action), outcomeOf(error), actorEvidence(context), - sanitizeToken(context.getUserId()), sanitizeToken(applicationId), - targets, accessTypes, context.getTraceId(), reasonOf(error)); } private static String outcomeOf(RuntimeException error) { @@ -65,17 +101,13 @@ private static String outcomeOf(RuntimeException error) { }; } - private static String reasonOf(RuntimeException error) { - return error == null ? "" : " reason=\"%s\"".formatted(sanitizeReason(error.getMessage())); - } - // Non-secret evidence of the calling actor: the DIAL key's project and/or the workload JWT's azp. private static String actorEvidence(ProxyContext context) { Key key = context.getKey(); ExtractedClaims claims = context.getExtractedClaims(); String azp = claims == null ? null : claims.authorizedParty(); - String project = key == null ? null : "project:" + sanitizeToken(key.getProject()); - String authorizedParty = azp == null ? null : "azp:" + sanitizeToken(azp); + String project = key == null ? null : "project:" + AuditLogSanitizer.sanitizeToken(key.getProject()); + String authorizedParty = azp == null ? null : "azp:" + AuditLogSanitizer.sanitizeToken(azp); if (project != null && authorizedParty != null) { return project + " " + authorizedParty; } @@ -84,12 +116,4 @@ private static String actorEvidence(ProxyContext context) { } return authorizedParty == null ? "unknown" : authorizedParty; } - - private static String sanitizeToken(String value) { - return value == null ? null : TOKEN_UNSAFE.matcher(value).replaceAll("_"); - } - - private static String sanitizeReason(String value) { - return value == null ? null : REASON_UNSAFE.matcher(value).replaceAll("_"); - } } diff --git a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyResolutionApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyResolutionApiTest.java new file mode 100644 index 000000000..b4f769d2b --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyResolutionApiTest.java @@ -0,0 +1,72 @@ +package com.epam.aidial.core.server; + +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * The v1 happy path, end to end on the chat-completions mint site: an admin-authored app + * declares a typed personal dependency, an admin grants, a user chats — and the per-request key + * the app's upstream receives carries the grant, verified by exercising the user's folder with + * that key. The full six-scenario suite lands with the resolution-coverage PR. + */ +public class ResourceDependencyResolutionApiTest extends ResourceBaseTest { + + @Test + void testChatPathBakesConsentedDependencyGrantIntoThePerRequestKey() { + Response response = send(HttpMethod.GET, "/v1/bucket", null, "", "authorization", "user"); + String userBucket = new JsonObject(response.body()).getString("bucket"); + String appUrl = "applications/public/dep-smoke-app"; + + verify(send(HttpMethod.PUT, "/v1/applications/public/dep-smoke-app", null, """ + { + "endpoint": "http://localhost:4848/chat/completions", + "display_name": "Dependency Smoke App", + "resource_dependencies": [ + {"kind": "dial.resourceLink", "link_id": "lnk_prompts", + "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + ] + } + """, "authorization", "admin", "If-None-Match", "*"), 200); + + verify(send(HttpMethod.POST, "/v1/consent/" + appUrl + "/admin-consent", null, "", + "authorization", "admin"), 200); + + AtomicReference appKey = new AtomicReference<>(); + AtomicReference inScopeStatus = new AtomicReference<>(); + AtomicReference outOfScopeStatus = new AtomicReference<>(); + try (TestWebServer server = new TestWebServer(4848)) { + server.map(HttpMethod.POST, "/chat/completions", request -> { + String perRequestKey = request.getHeader("Api-Key"); + appKey.set(perRequestKey); + // Exercise the grant from inside the request, while the per-request key is live — + // the only window an application ever holds it. + inScopeStatus.set(send(HttpMethod.PUT, + "/v1/prompts/%s/dep-smoke/written-by-app".formatted(userBucket), null, + "{\"id\":\"prompt-by-app\",\"folderId\":\"dep-smoke/\",\"name\":\"prompt-by-app\",\"content\":\"app content\"}", "api-key", perRequestKey).status()); + outOfScopeStatus.set(send(HttpMethod.PUT, + "/v1/prompts/%s/outside-scope".formatted(userBucket), null, + "{\"id\":\"prompt-by-app\",\"folderId\":\"dep-smoke/\",\"name\":\"prompt-by-app\",\"content\":\"app content\"}", "api-key", perRequestKey).status()); + return TestWebServer.createResponse(200, + "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion\",\"choices\":[]}", + "Content-Type", "application/json"); + }); + + Response completion = send(HttpMethod.POST, "/openai/deployments/%s/chat/completions".formatted(appUrl), null, """ + {"messages":[{"role":"user","content":"how are you?"}]} + """, "authorization", "user", "Content-Type", "application/json"); + assertEquals(200, completion.status(), () -> "Body: " + completion.body()); + } + + assertNotNull(appKey.get(), "the app upstream must receive a per-request key"); + // The grant travels with the key: the app writes under the user's dep-smoke folder… + assertEquals(200, inScopeStatus.get(), "the consented dependency target must be writable by the app's key"); + // …and only there — the user's files root stays off-limits to the app's key. + assertEquals(403, outOfScopeStatus.get(), "anything outside the declared target must stay off-limits"); + } +} 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 new file mode 100644 index 000000000..cc4878f66 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java @@ -0,0 +1,242 @@ +package com.epam.aidial.core.server.function; + +import com.epam.aidial.core.config.Application; +import com.epam.aidial.core.config.Deployment; +import com.epam.aidial.core.config.ResourceAccessType; +import com.epam.aidial.core.config.ResourceDependency; +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; +import com.epam.aidial.core.server.security.EncryptionService; +import com.epam.aidial.core.server.service.ConsentService; +import com.epam.aidial.core.storage.http.HttpException; +import com.epam.aidial.core.storage.http.HttpStatus; +import com.epam.aidial.core.storage.resource.ResourceDescriptor; +import com.epam.aidial.core.storage.resource.ResourceTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Every branch of request-start resolution: the declaration is a request, not a grant — reach is + * the originating user's own permissions, consent is the content-bound admin record, and the + * passing intersection is baked into the per-request key the application will hold. + */ +@ExtendWith(MockitoExtension.class) +public class ResolveResourceDependenciesFnTest { + + private static final String USER_BUCKET = "encrypted-user-bucket"; + + @Mock + private Proxy proxy; + + @Mock + private ProxyContext context; + + @Mock + private AccessService accessService; + + @Mock + private ConsentService consentService; + + @Mock + private EncryptionService encryptionService; + + @InjectMocks + private ResolveResourceDependenciesFn fn; + + private final ApiKeyData proxyApiKeyData = new ApiKeyData(); + private Application application; + private RequestObject request; + + @BeforeEach + void setUp() { + application = new Application(); + application.setName("app"); + request = new ChatCompletionRequest(com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode()); + + // The resolution window: the context still carries the originating user (no per-request key), + // and the proxy key data the grants are baked into. + lenient().when(context.getApiKeyData()).thenReturn(new ApiKeyData()); + lenient().when(context.getProxyApiKeyData()).thenReturn(proxyApiKeyData); + lenient().when(context.getUserId()).thenReturn("user-sub"); + lenient().when(context.getProxy()).thenReturn(proxy); + lenient().when(proxy.getAccessService()).thenReturn(accessService); + lenient().when(proxy.getConsentService()).thenReturn(consentService); + lenient().when(proxy.getEncryptionService()).thenReturn(encryptionService); + lenient().when(encryptionService.encrypt(anyString())).thenReturn(USER_BUCKET); + } + + private static ResourceDependency dependency(String path, boolean required) { + return new ResourceDependency() + .setKind(ResourceDependency.KIND) + .setLinkId("lnk_1") + .setTarget(new ResourceDependency.Target().setPath(path)) + .setAccess(Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE)) + .setRequired(required); + } + + @Test + void apply_isNoOpForNonApplicationDeployments() { + // Interceptor hop or a plain model/toolset call — dependencies resolve for the application + // being called, nothing else. + when(context.getDeployment()).thenReturn(mock(Deployment.class)); + + assertFalse(fn.apply(request)); + verify(proxy, never()).getConsentService(); + verify(context, never()).getProxyApiKeyData(); + } + + @Test + void apply_isNoOpWithoutDeclaration() { + when(context.getDeployment()).thenReturn(application); + + assertFalse(fn.apply(request)); + verify(consentService, never()).isAdminConsented(anyString(), any()); + } + + @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. + 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)); + } + + @Test + void apply_bakesGrantForConsentedReachablePlaceholderTarget() { + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of(dependency("current-user/skills/", false))); + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); + when(accessService.lookupPermissions(any(), eq(context))) + .thenReturn(Map.of(userSkillsFolder(), Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE))); + + 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). + assertEquals(Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE), + proxyApiKeyData.getPerRequestSharedResources().get(userSkillsFolder().getUrl()).permissions()); + Map receiverGrants = proxyApiKeyData.getPerRequestReceivers().get("app"); + assertEquals(Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE), + receiverGrants.get(userSkillsFolder().getUrl()).permissions()); + } + + @Test + void apply_bakesGrantForConcretePublicFolderTarget() { + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of(dependency("files/public/policies/", false))); + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); + ResourceDescriptor target = com.epam.aidial.core.server.util.ResourceDescriptorFactory + .fromAnyUrl("files/public/policies/", 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), + proxyApiKeyData.getPerRequestReceivers().get("app").get("files/public/policies/").permissions()); + } + + @Test + void apply_skipsUnreachableTargetWithoutFailure() { + // Fail closed per record: the user cannot reach the target — no grant, no failure. + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of(dependency("files/public/policies/", false))); + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); + when(accessService.lookupPermissions(any(), eq(context))).thenReturn(Map.of()); + + assertFalse(fn.apply(request)); + assertTrue(proxyApiKeyData.getPerRequestReceivers().isEmpty()); + } + + @Test + void apply_skipsUnconsentedDeclarationWithoutFailure() { + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of(dependency("files/public/policies/", false))); + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(false); + + assertFalse(fn.apply(request)); + verify(accessService, never()).lookupPermissions(any(), any()); + assertTrue(proxyApiKeyData.getPerRequestReceivers().isEmpty()); + } + + @Test + void apply_failsCallWhenRequiredTargetIsUnreachable() { + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of(dependency("files/public/policies/", true))); + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); + when(accessService.lookupPermissions(any(), eq(context))).thenReturn(Map.of()); + + HttpException error = assertThrows(HttpException.class, () -> fn.apply(request)); + assertEquals(HttpStatus.FORBIDDEN, error.getStatus()); + assertTrue(error.getMessage().contains("files/public/policies/")); + } + + @Test + void apply_failsCallWhenRequiredDeclarationIsUnconsented() { + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of(dependency("current-user/skills/", true))); + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(false); + + HttpException error = assertThrows(HttpException.class, () -> fn.apply(request)); + assertEquals(HttpStatus.FORBIDDEN, error.getStatus()); + } + + @Test + void apply_treatsMalformedRecordsAsUnresolvable() { + // Config-file apps bypass write-time validation: a malformed record is unresolvable — + // skip, or fail the call when required — never a crash and never a grant. + when(context.getDeployment()).thenReturn(application); + application.setResourceDependencies(List.of( + new ResourceDependency().setKind("dial.resource") // wrong kind — still parsed + .setLinkId("lnk_bad") + .setTarget(new ResourceDependency.Target().setPath("files/public/p/")) + .setAccess(Set.of(ResourceAccessType.READ)), + dependency("buckets/unknown/root/", false), // unknown root — no descriptor resolvable + new ResourceDependency().setKind(ResourceDependency.KIND) + .setLinkId("lnk_share") + .setTarget(new ResourceDependency.Target().setPath("files/public/p/")) + .setAccess(Set.of(ResourceAccessType.SHARE)))); // SHARE is not a dependency right + when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); + + assertFalse(fn.apply(request)); + assertTrue(proxyApiKeyData.getPerRequestReceivers().isEmpty()); + verify(accessService, never()).lookupPermissions(any(), any()); + } + + private ResourceDescriptor userSkillsFolder() { + return com.epam.aidial.core.server.util.ResourceDescriptorFactory.fromDecoded( + ResourceTypes.SKILL, USER_BUCKET, "Users/user-sub/", ""); + } +}