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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,7 +79,8 @@ private List<BaseRequestFunction<RequestObject>> 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RequestObject> {

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<ResourceDependency> 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(

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.

Supposing user makes a chat completion request via DIAL chat. PRK is not available since user is authenticated via JWT. Why do we throw the exception here?

"Resource dependencies must be resolved before the per-request key is assigned");
}
resolve(application, declaration);
return false;
}

private void resolve(Application application, List<ResourceDependency> 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);

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.

User may consent too. But the admin consent is checked only.

AccessService accessService = proxy.getAccessService();

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 service variables could be class members.

ApiKeyData proxyApiKeyData = context.getProxyApiKeyData();
AuthBucket userBucket = BucketBuilder.buildBucket(context);

List<Consent.ResourceEntry> granted = new ArrayList<>();
List<Consent.ResourceEntry> unresolved = new ArrayList<>();
List<String> requiredFailures = new ArrayList<>();

for (ResourceDependency dependency : declaration) {
Set<ResourceAccessType> 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<ResourceAccessType> userAccess =
accessService.lookupPermissions(Set.of(target), context).getOrDefault(target, Set.of());
if (userAccess.containsAll(requestedAccess)) {

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 block under the branch if could be a separate method

// Both halves of the delivery: perRequestSharedResources serves the application's own

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.

There are many unnecessary comments. Let's leave ones which have real meaning

// 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<Consent.ResourceEntry> unresolved, List<String> requiredFailures) {
String path = dependency == null || dependency.getTarget() == null ? null : dependency.getTarget().getPath();
if (dependency != null && dependency.isRequired()) {
requiredFailures.add(path == null ? "<missing target.path>" : 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<ResourceAccessType> 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) {

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.

Let's don't invent something new for parsing DIAL resource URL

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/<type-segment>/<path…> — 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import org.slf4j.LoggerFactory;

import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
Expand All @@ -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() {
}

Expand All @@ -40,20 +32,64 @@ private ResourceDependencyAuditLog() {
*/
public static void consent(ProxyContext context, String applicationId, String action,
List<Consent.ResourceEntry> 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<Consent.ResourceEntry> 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<Consent.ResourceEntry> 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<String> 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<Consent.ResourceEntry> 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<Consent.ResourceEntry> 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) {
Expand All @@ -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;
}
Expand All @@ -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("_");
}
}
Loading