Skip to content
Merged
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 @@ -75,8 +75,7 @@ public void addResourceCredentials(CredentialsDescriptor credentialsDescriptor,
verifier.accept(resourceCredentials);
resourceCredentials.setIdToken(null);

byte[] encryptedBody = encrypt(credentialsDescriptor, resourceCredentials);
resourceService.putResourceBytes(credentialsDescriptor.toResourceDescriptor(), encryptedBody, EtagHeader.ANY);
storeEncrypted(credentialsDescriptor, resourceCredentials);
log.info("Resource credentials for resourceId={}, bucket={} stored successfully",
credentialsDescriptor.getResourceId(), credentialsDescriptor.getBucketName());
}
Expand Down Expand Up @@ -206,6 +205,21 @@ private void validateDeleteOperation(ResourceCredentials existingCredentials,
}
}

/** Stores a prepared record directly, for records with no credential material to fetch. Stamps both times. */
Comment thread
astsiapanay marked this conversation as resolved.
public void putCredentialsRecord(CredentialsDescriptor credentialsDescriptor, ResourceCredentials credentials) {
log.info("Storing credentials record for resourceId={}, bucket={}",
credentialsDescriptor.getResourceId(), credentialsDescriptor.getBucketName());
long now = timeProvider.getCurrentTime();
credentials.setCreatedAt(now);
credentials.setUpdatedAt(now);
storeEncrypted(credentialsDescriptor, credentials);
}

private void storeEncrypted(CredentialsDescriptor credentialsDescriptor, ResourceCredentials credentials) {
byte[] encryptedBody = encrypt(credentialsDescriptor, credentials);
resourceService.putResourceBytes(credentialsDescriptor.toResourceDescriptor(), encryptedBody, EtagHeader.ANY);
}

/** Deletes one record addressed directly, for records that are not app-scoped. */
public boolean deleteCredentialsRecord(CredentialsDescriptor credentialsDescriptor) {
log.info("Deleting resource credentials for resourceId={}, bucket={}",
Expand Down
99 changes: 99 additions & 0 deletions docs/open_api_core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2964,6 +2964,105 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
/v1/applications/{appId}/external-services/{id}/consent:
post:
tags:
- External Services
summary: "/v1/applications/{appId}/external-services/{id}/consent"
operationId: grantExternalServiceConsent
parameters:
- name: appId
in: path
description: The application ID. Can be either a static config application name or a dynamic application path.
required: true
schema:
type: string
- name: id
in: path
description: The external service ID defined in the application's external_services configuration.
required: true
schema:
type: string
responses:
"200":
description: Success
content:
application/json:
schema:
type: boolean
"400":
description: Bad request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
"403":
description: Forbidden
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
"404":
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
"500":
description: The server had an error while processing your request.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
delete:
tags:
- External Services
summary: "/v1/applications/{appId}/external-services/{id}/consent"
operationId: withdrawExternalServiceConsent
parameters:
- name: appId
in: path
description: The application ID. Can be either a static config application name or a dynamic application path.
required: true
schema:
type: string
- name: id
in: path
description: The external service ID defined in the application's external_services configuration.
required: true
schema:
type: string
responses:
"200":
description: Success
content:
application/json:
schema:
type: boolean
"400":
description: Bad request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
"403":
description: Forbidden
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
"404":
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
"500":
description: The server had an error while processing your request.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorData"
/v1/applications/{bucket}/{application_path}:
get:
tags:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ public class ControllerSelector {
ExternalServiceManagementController controller = new ExternalServiceManagementController(proxy, context);
return () -> controller.deleteExternalService(appId, serviceId);
});
post(RouteTemplate.EXTERNAL_SERVICE_CONSENT, (proxy, context, pathMatcher) -> {
String appId = UrlUtil.decodePath(pathMatcher.group("appId"));
String serviceId = UrlUtil.decodePath(pathMatcher.group("id"));
ExternalServiceManagementController controller = new ExternalServiceManagementController(proxy, context);
return () -> controller.grantConsent(appId, serviceId);
});
delete(RouteTemplate.EXTERNAL_SERVICE_CONSENT, (proxy, context, pathMatcher) -> {
String appId = UrlUtil.decodePath(pathMatcher.group("appId"));
String serviceId = UrlUtil.decodePath(pathMatcher.group("id"));
ExternalServiceManagementController controller = new ExternalServiceManagementController(proxy, context);
return () -> controller.withdrawConsent(appId, serviceId);
});

// API-managed applications/toolsets in the platform bucket. Registered before the generic
// RESOURCE/RESOURCE_METADATA routes so /v1/(applications|toolsets)/platform/... is not
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.epam.aidial.core.server.controller;

import com.epam.aidial.core.config.Application;
import com.epam.aidial.core.config.AuthenticationType;
import com.epam.aidial.core.config.CredentialsLevel;
import com.epam.aidial.core.config.Deployment;
import com.epam.aidial.core.config.ExternalService;
import com.epam.aidial.core.config.ResourceAuthSettings;
import com.epam.aidial.core.credentials.data.credentials.CredentialsDescriptor;
import com.epam.aidial.core.credentials.data.credentials.CredentialsLocator;
import com.epam.aidial.core.credentials.data.credentials.ResourceCredentials;
import com.epam.aidial.core.credentials.service.ResourceAuthSettingsService;
import com.epam.aidial.core.credentials.service.ResourceCredentialsService;
import com.epam.aidial.core.openapi.annotations.ApiOperation;
Expand All @@ -17,6 +20,7 @@
import com.epam.aidial.core.server.Proxy;
import com.epam.aidial.core.server.ProxyContext;
import com.epam.aidial.core.server.data.ExternalServiceData;
import com.epam.aidial.core.server.log.ExternalServiceAuditLog;
import com.epam.aidial.core.server.security.AccessService;
import com.epam.aidial.core.server.security.EncryptionService;
import com.epam.aidial.core.server.service.ApplicationService;
Expand All @@ -41,6 +45,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

/** Admin/app-owner CRUD for an application's external-service definitions; static-config apps are read-only. */
@Slf4j
Expand Down Expand Up @@ -341,6 +346,119 @@ private void respondError(String message, Throwable error) {
ExternalServiceErrorHandler.respond(context, message, error);
}

/**
* An administrator approves this application's use of a DIAL-native service. Applies to every user who has
* offline credentials, not only those who opted into this application.
*/
@ApiOperation(
method = "POST",
path = "/v1/applications/{appId}/external-services/{id}/consent",
operationId = "grantExternalServiceConsent",
tags = {"External Services"},
parameters = {
@ApiParameter(name = "appId", in = ParameterIn.PATH, required = true,
description = OpenApiDescriptions.EXTERNAL_SERVICE_APP_ID),
@ApiParameter(name = "id", in = ParameterIn.PATH, required = true,
description = OpenApiDescriptions.EXTERNAL_SERVICE_ID)
},
responses = {
@ApiResponse(code = 200, description = OpenApiDescriptions.RESPONSE_SUCCESS,
body = @ApiSchema(implementation = Boolean.class)),
@ApiResponse(code = 400),
@ApiResponse(code = 403),
@ApiResponse(code = 404),
@ApiResponse(code = 500)
}
)
public Future<?> grantConsent(String appId, String serviceId) {
return consentOperation(appId, serviceId, "GRANT", "Can't grant consent", service -> {
CredentialsDescriptor descriptor = consentDescriptor(appId, serviceId);
// The record's existence is the approval; who granted it is in the audit event, which keeps history.
resourceCredentialsService.putCredentialsRecord(descriptor, ResourceCredentials.builder()
.resourceId(descriptor.getResourceId())
.credentialsLevel(CredentialsLevel.APPLICATION)
.authenticationType(service.getAuthSettings().getAuthenticationType())
.build());
return true;
});
}

/** Withdraws the approval. The application stops working for every user immediately. */
@ApiOperation(
method = "DELETE",
path = "/v1/applications/{appId}/external-services/{id}/consent",
operationId = "withdrawExternalServiceConsent",
tags = {"External Services"},
parameters = {
@ApiParameter(name = "appId", in = ParameterIn.PATH, required = true,
description = OpenApiDescriptions.EXTERNAL_SERVICE_APP_ID),
@ApiParameter(name = "id", in = ParameterIn.PATH, required = true,
description = OpenApiDescriptions.EXTERNAL_SERVICE_ID)
},
responses = {
@ApiResponse(code = 200, description = OpenApiDescriptions.RESPONSE_SUCCESS,
body = @ApiSchema(implementation = Boolean.class)),
@ApiResponse(code = 400),
@ApiResponse(code = 403),
@ApiResponse(code = 404),
@ApiResponse(code = 500)
}
)
public Future<?> withdrawConsent(String appId, String serviceId) {
return consentOperation(appId, serviceId, "WITHDRAW", "Can't withdraw consent",
service -> resourceCredentialsService.deleteCredentialsRecord(consentDescriptor(appId, serviceId)));
}

/** Both consent operations are the same act with a different verb: admin only, and audited either way. */
private Future<?> consentOperation(String appId, String serviceId, String action, String errorMessage,
Function<ExternalService, Boolean> operation) {
taskExecutor.submit(() -> {
requireAdmin();
return operation.apply(resolveDialNativeService(appId, serviceId));
})
.onComplete(result -> ExternalServiceAuditLog.consent(
context, appId, serviceId, action, ExternalServiceErrorHandler.asRuntime(result.cause())))
.onSuccess(applied -> context.respond(HttpStatus.OK, applied))
.onFailure(error -> respondError(errorMessage, error));
return Future.succeededFuture();
}

/** Consent is meaningful only for DIAL-native services; other types are authorized by a stored credential. */
private ExternalService resolveDialNativeService(String appId, String serviceId) {
ResolvedApp resolved = resolveApp(appId);
ExternalService service = resolved.application.getExternalServices() == null
? null : resolved.application.getExternalServices().get(serviceId);
if (service == null || service.getAuthSettings() == null) {
throw new ResourceNotFoundException(
"External service '%s' is not defined for application '%s'".formatted(serviceId, appId));
}
if (service.getAuthSettings().getAuthenticationType() != AuthenticationType.DIAL_NATIVE) {
throw new HttpException(HttpStatus.BAD_REQUEST,
"Consent applies only to %s services".formatted(AuthenticationType.DIAL_NATIVE));
}
return service;
}

/**
* Administrators only — an app owner approving their own application would be granting it the right to act as
* every user with offline credentials. Checked before the service is resolved, so 403 leaks nothing.
*/
private void requireAdmin() {
if (!accessService.hasAdminAccess(context)) {
throw new PermissionDeniedException("Only administrators may consent to a DIAL-native external service");
}
}

private CredentialsDescriptor consentDescriptor(String appId, String serviceId) {
// scopeId encodes both parts: the ids arrive decoded, and fromExternalServiceScope decodes again.
CredentialsLocator locator = CredentialsLocatorFactory.fromExternalServiceScope(scopeId(appId, serviceId), context);
Comment thread
astsiapanay marked this conversation as resolved.
CredentialsDescriptor descriptor = locator.getCredentialsDescriptors().get(CredentialsLevel.APPLICATION);
if (descriptor == null) {
throw new HttpException(HttpStatus.BAD_REQUEST, "Application-level consent is not supported for: " + appId);
}
return descriptor;
}

private record ResolvedApp(Application application, ResourceDescriptor descriptor, String author, boolean staticApp) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ public enum RouteTemplate {
"^/v1/applications/(?<appId>.+?)/external-services/(?<id>[^/]+)$",
"/v1/applications/{appId}/external-services/{id}"
),
// Admin consent for a DIAL-native service: a separate door from sign-in, with its own authorization rule.
EXTERNAL_SERVICE_CONSENT(
"^/v1/applications/(?<appId>.+?)/external-services/(?<id>[^/]+)/consent$",
Comment thread
astsiapanay marked this conversation as resolved.
"/v1/applications/{appId}/external-services/{id}/consent"
),

// Other routes
CONFIG(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ public static void offlineCredentials(ProxyContext context, String action, Runti
reasonOf(error));
}

/**
* One event per administrator decision on an application's use of a DIAL-native service. Records who decided,
* since consent reaches every user who has enabled offline credentials.
*/
public static void consent(ProxyContext context, String applicationId, String externalServiceId,
String action, RuntimeException error) {
AUDIT.info("event=external_service_consent action={} outcome={} actor={} admin_user_id={} "
+ "application_id={} external_service_id={} trace_id={}{}",
sanitizeToken(action), outcomeOf(error), actorEvidence(context),
sanitizeToken(context.getUserId()), sanitizeToken(applicationId),
sanitizeToken(externalServiceId), context.getTraceId(), reasonOf(error));
}

private static String outcomeOf(RuntimeException error) {
return switch (error) {
case null -> "SUCCESS";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,19 +180,19 @@ public Pair<ResourceItemMetadata, Application> putApplication(ResourceDescriptor
ExternalServicesWriteMode externalServicesWriteMode) {
prepareApplication(resource, application, preserveForwardAuthToken);

MutableObject<List<String>> removedExternalServices = new MutableObject<>(List.of());
MutableObject<List<String>> purgeableExternalServices = new MutableObject<>(List.of());
Comment thread
astsiapanay marked this conversation as resolved.
ResourceItemMetadata meta = resourceService.computeResource(resource, etag, author, json -> {
Application existing = ProxyUtil.convertToObject(json, Application.class);
verifySchemaRichApp(application, existing);
prepareApplicationFunction(resource, application, existing);
prepareAdminManagedFields(application, existing, adminManagedFieldsWriteMode);
List<String> externalServices = externalServiceService.processOnWrite(resource, application, existing, externalServicesWriteMode);
removedExternalServices.setValue(externalServices);
purgeableExternalServices.setValue(externalServices);
return ProxyUtil.convertToString(application);
});

// Purge credentials of services dropped by this write (after commit), like the dedicated DELETE.
externalServiceService.purgeApplicationCredentials(resource, removedExternalServices.get());
// Purge credentials of services this write dropped or changed the auth type of (after commit).
externalServiceService.purgeApplicationCredentials(resource, purgeableExternalServices.get());

return Pair.of(meta, application);
}
Expand Down
Loading
Loading