From dd01af7c97db9de83b8229d1145edb70fc4970d7 Mon Sep 17 00:00:00 2001 From: Dmytro Zaichenko Date: Tue, 11 Aug 2026 13:25:44 +0300 Subject: [PATCH 1/2] feat: add admin consent endpoint for DIAL_NATIVE services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An administrator approves an application's use of a DIAL-native service via POST/DELETE /v1/applications/{appId}/external-services/{id}/consent. The approval is a credential record in the APPLICATION-level slot — its existence is the consent, the audit event records who decided. Because that slot holds ordinary credentials while a service is OAUTH/API_KEY, any write that changes a service's authentication_type now purges the old APPLICATION-level record, so a leftover credential can never pass as an approval, and client secrets are never carried across a type change. Consent operations are admin-only and audited, including refusals. Co-Authored-By: Claude Fable 5 --- .../service/ResourceCredentialsService.java | 11 + docs/open_api_core.yaml | 99 +++++++ .../server/controller/ControllerSelector.java | 12 + .../ExternalServiceManagementController.java | 118 ++++++++ .../core/server/data/RouteTemplate.java | 5 + .../server/log/ExternalServiceAuditLog.java | 13 + .../server/service/ApplicationService.java | 8 +- .../service/ExternalServiceService.java | 41 ++- .../ExternalServiceCredentialsApiTest.java | 266 ++++++++++++++++++ 9 files changed, 559 insertions(+), 14 deletions(-) diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/service/ResourceCredentialsService.java b/credentials/src/main/java/com/epam/aidial/core/credentials/service/ResourceCredentialsService.java index abd33b18e..c903846fe 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/service/ResourceCredentialsService.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/service/ResourceCredentialsService.java @@ -206,6 +206,17 @@ private void validateDeleteOperation(ResourceCredentials existingCredentials, } } + /** Stores a prepared record directly, for records with no credential material to fetch. Stamps both times. */ + 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); + 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={}", diff --git a/docs/open_api_core.yaml b/docs/open_api_core.yaml index eb2a07f26..4b1df620e 100644 --- a/docs/open_api_core.yaml +++ b/docs/open_api_core.yaml @@ -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: diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java b/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java index 7ae3a67e3..39b5fa813 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java @@ -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 diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java index 9c36bbe9d..fe8f63898 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ExternalServiceManagementController.java @@ -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; @@ -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; @@ -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 @@ -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 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); + 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) { } } diff --git a/server/src/main/java/com/epam/aidial/core/server/data/RouteTemplate.java b/server/src/main/java/com/epam/aidial/core/server/data/RouteTemplate.java index 7c088ca00..9fcd64b73 100644 --- a/server/src/main/java/com/epam/aidial/core/server/data/RouteTemplate.java +++ b/server/src/main/java/com/epam/aidial/core/server/data/RouteTemplate.java @@ -267,6 +267,11 @@ public enum RouteTemplate { "^/v1/applications/(?.+?)/external-services/(?[^/]+)$", "/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/(?.+?)/external-services/(?[^/]+)/consent$", + "/v1/applications/{appId}/external-services/{id}/consent" + ), // Other routes CONFIG( diff --git a/server/src/main/java/com/epam/aidial/core/server/log/ExternalServiceAuditLog.java b/server/src/main/java/com/epam/aidial/core/server/log/ExternalServiceAuditLog.java index c8ba2e680..98770a02b 100644 --- a/server/src/main/java/com/epam/aidial/core/server/log/ExternalServiceAuditLog.java +++ b/server/src/main/java/com/epam/aidial/core/server/log/ExternalServiceAuditLog.java @@ -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"; diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java b/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java index 310094544..80f4755ee 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java @@ -180,19 +180,19 @@ public Pair putApplication(ResourceDescriptor ExternalServicesWriteMode externalServicesWriteMode) { prepareApplication(resource, application, preserveForwardAuthToken); - MutableObject> removedExternalServices = new MutableObject<>(List.of()); + MutableObject> purgeableExternalServices = new MutableObject<>(List.of()); 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 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); } diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceService.java b/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceService.java index 9c3674c2d..8bdfbf1ea 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ExternalServiceService.java @@ -20,6 +20,7 @@ import com.epam.aidial.core.storage.util.UrlUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.mutable.MutableBoolean; import java.util.ArrayList; import java.util.Collection; @@ -42,7 +43,8 @@ public class ExternalServiceService { /** * On an application write: validate, drop computed statuses, preserve omitted client_secrets, encrypt - * at rest. Returns ids removed by this write so the caller can purge their APP-level credentials. + * at rest. Returns ids whose APP-level credentials this write invalidated — services dropped outright and + * services whose {@code authentication_type} changed — so the caller can purge them. * *

With {@link ExternalServicesWriteMode#PRESERVE_IF_OMITTED} (the request omitted {@code external_services}, * e.g. a partial update saving other properties) the stored services are carried forward untouched. @@ -62,7 +64,7 @@ public List processOnWrite(ResourceDescriptor resource, Application appl } preserveOmittedSecrets(application, existing); encryptSecrets(resource, application); - return removedServiceIds(application, existing); + return findPurgeableServiceIds(application, existing); } // Drop APP-level credentials for removed services, else a same-id re-create inherits the old token/secret. @@ -82,19 +84,30 @@ public void purgeApplicationCredentials(ResourceDescriptor resource, Collection< } } - private static List removedServiceIds(Application application, Application existing) { + // A removed service's record must not be inherited by a same-id re-create, and a type change makes the old + // record meaningless at best — at worst a leftover credential would pass as DIAL_NATIVE admin consent. + private static List findPurgeableServiceIds(Application application, Application existing) { if (existing == null || existing.getExternalServices() == null || existing.getExternalServices().isEmpty()) { return List.of(); } Map newServices = application.getExternalServices() == null ? Map.of() : application.getExternalServices(); - List removed = new ArrayList<>(); - for (String id : existing.getExternalServices().keySet()) { - if (!newServices.containsKey(id)) { - removed.add(id); + List purgeable = new ArrayList<>(); + for (Map.Entry entry : existing.getExternalServices().entrySet()) { + ExternalService updated = newServices.get(entry.getKey()); + if (updated == null || authTypeChanged(entry.getValue(), updated)) { + purgeable.add(entry.getKey()); } } - return removed; + return purgeable; + } + + private static boolean authTypeChanged(ExternalService existing, ExternalService updated) { + if (existing == null || existing.getAuthSettings() == null + || updated == null || updated.getAuthSettings() == null) { + return false; + } + return existing.getAuthSettings().getAuthenticationType() != updated.getAuthSettings().getAuthenticationType(); } // Same resource id/bucket as CredentialsLocatorFactory.fromExternalServiceScope for a dynamic app, so the @@ -108,6 +121,7 @@ private static CredentialsLocator applicationCredentialsLocator(ResourceDescript public ExternalService putExternalService(ResourceDescriptor resource, String serviceId, ExternalService service, String author) { verifyApplication(resource); + MutableBoolean typeChanged = new MutableBoolean(false); resourceService.computeResource(resource, EtagHeader.ANY, author, json -> { Application app = ProxyUtil.convertToObject(json, Application.class); if (app == null) { @@ -120,7 +134,8 @@ public ExternalService putExternalService(ResourceDescriptor resource, String se ExternalService existing = app.getExternalServices().get(serviceId); validateOne(serviceId, service, existing == null); clearAuthStatuses(service); - if (existing != null && existing.getAuthSettings() != null + typeChanged.setValue(authTypeChanged(existing, service)); + if (existing != null && !typeChanged.booleanValue() && existing.getAuthSettings() != null && service.getAuthSettings() != null && service.getAuthSettings().getClientSecret() == null && existing.getAuthSettings().getClientSecret() != null) { service.getAuthSettings().setClientSecret(existing.getAuthSettings().getClientSecret()); @@ -129,6 +144,10 @@ public ExternalService putExternalService(ResourceDescriptor resource, String se encryptSecrets(resource, app); return ProxyUtil.convertToString(app); }); + // After commit, like the application write path: the old-type record must not survive under the new type. + if (typeChanged.booleanValue()) { + purgeApplicationCredentials(resource, List.of(serviceId)); + } return service; } @@ -205,7 +224,9 @@ private static void preserveOmittedSecrets(Application application, Application continue; } ExternalService existingService = existing.getExternalServices().get(entry.getKey()); - if (existingService != null && existingService.getAuthSettings() != null + // Never carry a secret across an authentication_type change — it belonged to the old type. + if (existingService != null && !authTypeChanged(existingService, service) + && existingService.getAuthSettings() != null && existingService.getAuthSettings().getClientSecret() != null) { service.getAuthSettings().setClientSecret(existingService.getAuthSettings().getClientSecret()); } diff --git a/server/src/test/java/com/epam/aidial/core/server/ExternalServiceCredentialsApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ExternalServiceCredentialsApiTest.java index fb2bf45aa..2437b3450 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ExternalServiceCredentialsApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ExternalServiceCredentialsApiTest.java @@ -1,11 +1,26 @@ package com.epam.aidial.core.server; +import ch.qos.logback.classic.Level; +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.config.Application; +import com.epam.aidial.core.config.AuthenticationType; +import com.epam.aidial.core.config.ExternalService; +import com.epam.aidial.core.config.ResourceAuthSettings; import com.epam.aidial.core.server.data.ApiKeyData; +import com.epam.aidial.core.server.service.AdminManagedFieldsWriteMode; import com.epam.aidial.core.server.util.ProxyUtil; +import com.epam.aidial.core.server.util.ResourceDescriptorFactory; +import com.epam.aidial.core.storage.util.EtagHeader; import com.fasterxml.jackson.databind.JsonNode; import io.vertx.core.http.HttpMethod; import okhttp3.mockwebserver.MockResponse; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -1522,6 +1537,236 @@ void testExternalServiceIdWithSpecialCharsRejected() throws Exception { assertEquals(400, badCred.status(), () -> badCred.body()); } + // --------------------------------------------------------------------------------------------- + // Admin consent for DIAL-native services (§4) + // --------------------------------------------------------------------------------------------- + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testAdminCanGrantAndWithdrawConsent() { + Response grant = send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + assertEquals(200, grant.status(), grant.body()); + + Response withdraw = send(HttpMethod.DELETE, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + assertEquals(200, withdraw.status(), withdraw.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testNonAdminCannotGrantConsent() { + Response grant = send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "user"); + assertEquals(403, grant.status(), grant.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testNonAdminCannotWithdrawConsent() { + send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + + Response withdraw = send(HttpMethod.DELETE, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "user"); + assertEquals(403, withdraw.status(), withdraw.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testConsentRejectedForNonDialNativeService() { + Response grant = send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/salesforce/consent", + null, "", "authorization", "admin"); + assertEquals(400, grant.status(), grant.body()); + assertTrue(grant.body().contains("DIAL_NATIVE"), grant.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testConsentForDynamicAppWithSpaceInPath() { + // The route hands the controller a decoded app id, so the scope must be re-encoded before it is parsed + // again — a raw space fails URI parsing outright, and any re-encoding difference would make the grant + // and the redemption address different storage keys. + Application app = new Application(); + app.setEndpoint("http://localhost:7001/v1/x"); + app.setExternalServices(Map.of("dial", new ExternalService() + .setAuthSettings(ResourceAuthSettings.builder() + .authenticationType(AuthenticationType.DIAL_NATIVE) + .build()))); + dial.getProxy().getApplicationService().putApplication( + ResourceDescriptorFactory.fromPublicUrl("applications/public/my%20app"), + EtagHeader.ANY, null, app, false, AdminManagedFieldsWriteMode.AUTHORITATIVE); + + String consent = "/v1/applications/public/my%20app/external-services/dial/consent"; + Response grant = send(HttpMethod.POST, consent, null, "", "authorization", "admin"); + assertEquals(200, grant.status(), grant.body()); + + // Withdraw reports true only if it addressed the very record the grant wrote. + Response withdraw = send(HttpMethod.DELETE, consent, null, "", "authorization", "admin"); + verify(withdraw, 200, "true"); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testAuthTypeChangePurgesApplicationLevelCredentials() { + // Without the purge, the API_KEY record stored below would survive the switch to DIAL_NATIVE — sitting + // exactly where the consent check looks, as an approval no administrator ever granted. + putDynamicApp("type-change-app", AuthenticationType.API_KEY); + + Response signIn = send(HttpMethod.POST, "/v1/ops/external-service/signin", null, """ + { + "url": "applications/public/type-change-app/external_services/svc", + "credentials_level": "APPLICATION", + "authentication_type": "API_KEY", + "api_key": "app-key" + } + """, "authorization", "admin"); + assertEquals(200, signIn.status(), signIn.body()); + assertEquals("SIGNED_IN", dynamicAppLevelStatus("public/type-change-app")); + + Response put = send(HttpMethod.PUT, "/v1/applications/public/type-change-app/external-services/svc", null, """ + { "auth_settings": { "authentication_type": "DIAL_NATIVE" } } + """, "authorization", "admin"); + assertEquals(200, put.status(), put.body()); + + assertEquals("SIGNED_OUT", dynamicAppLevelStatus("public/type-change-app")); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testAuthTypeChangeOnApplicationWritePurgesApplicationLevelCredentials() { + // Same invariant through the whole-application write path (processOnWrite), not the per-service PUT. + putDynamicApp("write-change-app", AuthenticationType.API_KEY); + + Response signIn = send(HttpMethod.POST, "/v1/ops/external-service/signin", null, """ + { + "url": "applications/public/write-change-app/external_services/svc", + "credentials_level": "APPLICATION", + "authentication_type": "API_KEY", + "api_key": "app-key" + } + """, "authorization", "admin"); + assertEquals(200, signIn.status(), signIn.body()); + assertEquals("SIGNED_IN", dynamicAppLevelStatus("public/write-change-app")); + + putDynamicApp("write-change-app", AuthenticationType.DIAL_NATIVE); + + assertEquals("SIGNED_OUT", dynamicAppLevelStatus("public/write-change-app")); + } + + private void putDynamicApp(String name, AuthenticationType type) { + Application app = new Application(); + app.setEndpoint("http://localhost:7001/v1/x"); + ResourceAuthSettings.ResourceAuthSettingsBuilder auth = ResourceAuthSettings.builder().authenticationType(type); + if (type == AuthenticationType.API_KEY) { + auth.apiKeyHeader("X-API-Key"); + } + app.setExternalServices(Map.of("svc", new ExternalService().setAuthSettings(auth.build()))); + dial.getProxy().getApplicationService().putApplication( + ResourceDescriptorFactory.fromPublicUrl("applications/public/" + name), + EtagHeader.ANY, null, app, false, AdminManagedFieldsWriteMode.AUTHORITATIVE); + } + + private String dynamicAppLevelStatus(String appId) { + Response list = send(HttpMethod.GET, "/v1/applications/" + appId + "/external-services", + null, "", "authorization", "admin"); + assertEquals(200, list.status(), list.body()); + for (JsonNode service : ProxyUtil.convertToObject(list.body(), JsonNode.class)) { + if ("svc".equals(service.get("id").asText())) { + return service.get("auth_settings").get("app_level_auth_status").asText(); + } + } + throw new AssertionError("service 'svc' missing from " + list.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testConsentForUnknownServiceIsNotFound() { + Response grant = send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/nope/consent", + null, "", "authorization", "admin"); + assertEquals(404, grant.status(), grant.body()); + } + + // --------------------------------------------------------------------------------------------- + // Status for DIAL-native services (§8 item 6a): app level means approved, user level means the + // caller's platform-wide offline credentials — never a per-service sign-in, which does not exist. + // --------------------------------------------------------------------------------------------- + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testDialNativeAppLevelStatusFollowsConsent() { + assertEquals("SIGNED_OUT", dialNativeStatus("app_level_auth_status", "admin")); + + send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + assertEquals("SIGNED_IN", dialNativeStatus("app_level_auth_status", "admin")); + + send(HttpMethod.DELETE, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + assertEquals("SIGNED_OUT", dialNativeStatus("app_level_auth_status", "admin")); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testConsentDoesNotAffectOtherServicesStatus() { + send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + + JsonNode salesforce = externalService("salesforce", "admin"); + assertEquals("SIGNED_OUT", salesforce.get("auth_settings").get("app_level_auth_status").asText()); + assertEquals("SIGNED_OUT", salesforce.get("auth_settings").get("user_level_auth_status").asText()); + } + + private String dialNativeStatus(String field, String user) { + return externalService("dial", user).get("auth_settings").get(field).asText(); + } + + private JsonNode externalService(String serviceId, String user) { + Response list = send(HttpMethod.GET, "/v1/applications/app-with-services/external-services", + null, "", "authorization", user); + assertEquals(200, list.status(), list.body()); + for (JsonNode service : ProxyUtil.convertToObject(list.body(), JsonNode.class)) { + if (serviceId.equals(service.get("id").asText())) { + return service; + } + } + throw new AssertionError("service '" + serviceId + "' missing from " + list.body()); + } + + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testConsentDecisionsAreAudited() { + Logger auditLogger = (Logger) LoggerFactory.getLogger("DIAL_OBO_AUDIT"); + ListAppender appender = new ListAppender<>(); + appender.start(); + auditLogger.addAppender(appender); + Level previous = auditLogger.getLevel(); + auditLogger.setLevel(Level.INFO); + try { + send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + send(HttpMethod.DELETE, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + // a refusal must be recorded too + send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "user"); + + List events = appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .filter(m -> m.startsWith("event=external_service_consent")) + .toList(); + assertEquals(3, events.size(), events::toString); + assertTrue(events.get(0).contains("action=GRANT"), events::toString); + assertTrue(events.get(0).contains("outcome=SUCCESS"), events::toString); + assertTrue(events.get(0).contains("application_id=app-with-services"), events::toString); + assertTrue(events.get(1).contains("action=WITHDRAW"), events::toString); + assertTrue(events.get(2).contains("outcome=DENIED"), events::toString); + } finally { + auditLogger.setLevel(previous); + auditLogger.detachAppender(appender); + } + } + @Test @DialConfigLocation("dial-config/external-service-credentials.json") void testSignInRejectedForDialNativeService() { @@ -1549,6 +1794,27 @@ void testSignInRejectedForDialNativeServiceAtApplicationLevel() { assertEquals(400, signIn.status()); } + @Test + @DialConfigLocation("dial-config/external-service-credentials.json") + void testSignOutCannotRemoveAdminConsent() { + // The consent record lives at APPLICATION level under the same scope the generic sign-out addresses, and + // sign-out admits app owners — so without this refusal an owner could withdraw an admin's decision, unaudited. + send(HttpMethod.POST, "/v1/applications/app-with-services/external-services/dial/consent", + null, "", "authorization", "admin"); + + Response signOut = send(HttpMethod.POST, "/v1/ops/external-service/signout", null, """ + { + "url": "%s", + "credentials_level": "APPLICATION", + "authentication_type": "DIAL_NATIVE" + } + """.formatted(DIAL_NATIVE_SCOPE), "authorization", "admin"); + assertEquals(400, signOut.status(), signOut.body()); + assertTrue(signOut.body().contains("not applicable"), signOut.body()); + + assertEquals("SIGNED_IN", dialNativeStatus("app_level_auth_status", "admin")); + } + @Test @DialConfigLocation("dial-config/external-service-credentials.json") void testSignOutRejectedForDialNativeServiceAtUserLevel() { From bd0244c52612226cee941e7a09810c53c4bc5c20 Mon Sep 17 00:00:00 2001 From: Dmytro Zaichenko Date: Wed, 12 Aug 2026 14:03:28 +0300 Subject: [PATCH 2/2] refactor: share the encrypt-and-store step and pin route separation Both credential writes ended in the same encrypt + put sequence; extracted storeEncrypted so putCredentialsRecord and addResourceCredentials share it. Also pins with a test that the consent route and the management route can never cross-match: the service id disallows '/', both patterns are anchored, and even an app path containing an external-services segment or a service named 'consent' stays on its own route. Co-Authored-By: Claude Fable 5 --- .../service/ResourceCredentialsService.java | 7 ++-- .../core/server/data/RouteTemplateTest.java | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 server/src/test/java/com/epam/aidial/core/server/data/RouteTemplateTest.java diff --git a/credentials/src/main/java/com/epam/aidial/core/credentials/service/ResourceCredentialsService.java b/credentials/src/main/java/com/epam/aidial/core/credentials/service/ResourceCredentialsService.java index c903846fe..b1553be03 100644 --- a/credentials/src/main/java/com/epam/aidial/core/credentials/service/ResourceCredentialsService.java +++ b/credentials/src/main/java/com/epam/aidial/core/credentials/service/ResourceCredentialsService.java @@ -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()); } @@ -213,6 +212,10 @@ public void putCredentialsRecord(CredentialsDescriptor credentialsDescriptor, Re 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); } diff --git a/server/src/test/java/com/epam/aidial/core/server/data/RouteTemplateTest.java b/server/src/test/java/com/epam/aidial/core/server/data/RouteTemplateTest.java new file mode 100644 index 000000000..29fde8873 --- /dev/null +++ b/server/src/test/java/com/epam/aidial/core/server/data/RouteTemplateTest.java @@ -0,0 +1,33 @@ +package com.epam.aidial.core.server.data; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class RouteTemplateTest { + + @Test + void consentAndManagementRoutesCannotCrossMatch() { + String management = "/v1/applications/my-app/external-services/dial"; + String consent = "/v1/applications/my-app/external-services/dial/consent"; + + assertTrue(RouteTemplate.EXTERNAL_SERVICE_MANAGEMENT.matches(management)); + assertTrue(RouteTemplate.EXTERNAL_SERVICE_CONSENT.matches(consent)); + assertFalse(RouteTemplate.EXTERNAL_SERVICE_MANAGEMENT.matches(consent)); + assertFalse(RouteTemplate.EXTERNAL_SERVICE_CONSENT.matches(management)); + } + + @Test + void consentRoutesStaySeparateForPathologicalNames() { + // The service id disallows '/', and both patterns are $-anchored, so neither an app path containing + // an "external-services" segment nor a service literally named "consent" can cross the boundary. + String consentUnderNestedAppPath = "/v1/applications/public/my/external-services/app1/external-services/dial/consent"; + String serviceNamedConsent = "/v1/applications/my-app/external-services/consent"; + + assertTrue(RouteTemplate.EXTERNAL_SERVICE_CONSENT.matches(consentUnderNestedAppPath)); + assertFalse(RouteTemplate.EXTERNAL_SERVICE_MANAGEMENT.matches(consentUnderNestedAppPath)); + assertTrue(RouteTemplate.EXTERNAL_SERVICE_MANAGEMENT.matches(serviceNamedConsent)); + assertFalse(RouteTemplate.EXTERNAL_SERVICE_CONSENT.matches(serviceNamedConsent)); + } +}