diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index a4274e33..148b1a8e 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -12,6 +12,9 @@ dependencies { // to decode and validate Keycloak access tokens with user info. implementation("com.nimbusds:nimbus-jose-jwt:9.39.2") + implementation("org.keycloak:keycloak-authz-client:26.0.7") + implementation(fileTree("libs") { include("*.jar") }) + implementation("org.graalvm.polyglot:js:24.2.1") implementation("org.graalvm.polyglot:polyglot:24.2.1") diff --git a/backend/libs/anylogic-cloud-client-8.5.0.jar b/backend/libs/anylogic-cloud-client-8.5.0.jar new file mode 100644 index 00000000..91a203eb Binary files /dev/null and b/backend/libs/anylogic-cloud-client-8.5.0.jar differ diff --git a/backend/src/main/kotlin/Config.kt b/backend/src/main/kotlin/Config.kt index 33751c45..0edcdb21 100644 --- a/backend/src/main/kotlin/Config.kt +++ b/backend/src/main/kotlin/Config.kt @@ -9,6 +9,7 @@ data class Config( val baseUrl: String = getenv("BASE_URL") ?: "http://localhost:$port", val clientId: String = getEnvRequired("CLIENT_ID"), val clientSecret: String = getEnvRequired("CLIENT_SECRET"), + val anylogicApiKey: String = getEnvRequired("ANYLOGIC_API_KEY"), val smtpConfig: SmtpConfig = SmtpConfig.create(), val contactMailRecipients: List = getEnvRequired("CONTACT_MAIL_RECIPIENTS") diff --git a/backend/src/main/kotlin/Main.kt b/backend/src/main/kotlin/Main.kt index dad7439a..804de58f 100644 --- a/backend/src/main/kotlin/Main.kt +++ b/backend/src/main/kotlin/Main.kt @@ -1,9 +1,11 @@ package com.zenmo.backend +import com.zenmo.backend.anylogic.AnyLogicProxy import com.zenmo.backend.contact.ContactController import com.zenmo.backend.contact.MailService import com.zenmo.backend.js.JsServer import com.zenmo.backend.js.JsServerFilter +import com.zenmo.backend.keycloak.KeycloakAuthClient import org.http4k.core.HttpHandler import org.http4k.core.then import org.http4k.filter.DebuggingFilters @@ -32,7 +34,11 @@ fun startServer() { "/api/contact" bind org.http4k.core.Method.POST to ContactController(MailService.create(config))::handler, ) - val anyLogicProxyRoutes = AnyLogicProxy(oAuthSessions::retrieveIdToken).routes() + val anyLogicProxyRoutes = AnyLogicProxy( + idTokenProvider = oAuthSessions::retrieveIdToken, + accessTokenProvider = oAuthSessions::retrieveToken, + keycloakAuthClient = KeycloakAuthClient() + ).routes() val app: HttpHandler = DebuggingFilters.PrintRequestAndResponse() .then(corsFilter) diff --git a/backend/src/main/kotlin/AnyLogicProxy.kt b/backend/src/main/kotlin/anylogic/AnyLogicProxy.kt similarity index 51% rename from backend/src/main/kotlin/AnyLogicProxy.kt rename to backend/src/main/kotlin/anylogic/AnyLogicProxy.kt index d00e3229..43c52ce3 100644 --- a/backend/src/main/kotlin/AnyLogicProxy.kt +++ b/backend/src/main/kotlin/anylogic/AnyLogicProxy.kt @@ -1,21 +1,25 @@ -package com.zenmo.backend +package com.zenmo.backend.anylogic +import com.anylogic.cloud.clients.client_8_5_0.AnyLogicCloudClient import com.fasterxml.jackson.databind.node.ArrayNode import com.fasterxml.jackson.databind.node.ObjectNode +import com.zenmo.backend.Config +import com.zenmo.backend.keycloak.KeycloakAuthClient import org.http4k.client.JavaHttpClient -import org.http4k.core.Method -import org.http4k.core.Request -import org.http4k.core.Response -import org.http4k.core.Uri +import org.http4k.core.* import org.http4k.format.Jackson import org.http4k.routing.RoutingHttpHandler import org.http4k.routing.bind import org.http4k.routing.path +import org.http4k.security.AccessToken import org.http4k.security.openid.IdToken - class AnyLogicProxy( - private val idTokenProvider: (Request) -> IdToken? + private val idTokenProvider: (Request) -> IdToken?, + private val accessTokenProvider: (Request) -> AccessToken?, + private val keycloakAuthClient: KeycloakAuthClient, + private val anylogicApiKey: String = Config().anylogicApiKey, + private val anylogicCloudClient: AnyLogicCloudClient = AnyLogicCloudClient(Config().anylogicApiKey) ) { private val httpClient = JavaHttpClient() private val anylogicUpstream = Uri.of("https://anylogic.zenmo.com") @@ -26,21 +30,56 @@ class AnyLogicProxy( } private fun proxyHandler(request: Request): Response { - val path = request.path("path") ?: "" + val path = request.path("path") ?: return Response(Status.BAD_REQUEST).body("Missing path") + + val versionId = Regex("api/open/8\\.5\\.0/versions/([^/]+)/runs").find(path) + ?.groupValues?.get(1) + ?: return Response(Status.BAD_REQUEST).body("Missing version ID in path") + + val modelId = resolveModelId(versionId) + ?: return Response(Status.BAD_REQUEST).body("Unable to resolve model ID for version $versionId") + + val accessToken = accessTokenProvider(request) + + // if access token exists, check authorization via Keycloak + if (accessToken != null && !keycloakAuthClient.hasAccess(accessToken, modelId)) { + return Response(Status.FORBIDDEN).body("Access denied to model $modelId") + } + val targetUri = anylogicUpstream.path("/$path").query(request.uri.query) val forwardReq = when { request.method == Method.POST && path.matches(animationStartPathRegex) -> { val bodyWithToken = injectTokenAsInputParameter(request.bodyString(), idTokenProvider(request)) - request.uri(targetUri).body(bodyWithToken) + request + .uri(targetUri) + .header("Authorization", "Bearer $anylogicApiKey") + .header("Content-Type", "application/json") + .body(bodyWithToken) } - else -> request.uri(targetUri) // passthrough + else -> request.uri(targetUri) + .header("Authorization", "Bearer $anylogicApiKey") } return httpClient(forwardReq) } + + /** + * Resolves modelId from versionId by querying AnyLogic Cloud API + */ + private fun resolveModelId(versionId: String): String? { + return try { + anylogicCloudClient.models.firstNotNullOfOrNull { model -> + model.modelVersions.find { it == versionId }?.let { model.id } + } + } catch (e: Exception) { + println("Error resolving modelId for version $versionId: ${e.message}") + null + } + } + /** * Injects the user's ID token into the AnyLogic request body if needed. * diff --git a/backend/src/main/kotlin/keycloak/KeycloakClient.kt b/backend/src/main/kotlin/keycloak/KeycloakClient.kt new file mode 100644 index 00000000..d8b9a5a3 --- /dev/null +++ b/backend/src/main/kotlin/keycloak/KeycloakClient.kt @@ -0,0 +1,41 @@ +package com.zenmo.backend.keycloak + +import com.zenmo.backend.Config +import org.http4k.security.AccessToken +import org.keycloak.authorization.client.AuthzClient +import org.keycloak.authorization.client.Configuration +import org.keycloak.representations.idm.authorization.AuthorizationRequest + +class KeycloakAuthClient( + serverUrl: String = "https://keycloak.zenmo.com", + realm: String = "zenmo", + clientId: String = Config().clientId, // maybe im wrong + clientSecret: String = Config().clientSecret +) { + private val authzClient: AuthzClient + + init { + val config = Configuration( + serverUrl, + realm, + clientId, + mapOf("secret" to clientSecret), + null + ) + authzClient = AuthzClient.create(config) + } + + + fun hasAccess(accessToken: AccessToken, modelResourceId: String): Boolean { + return try { + val request = AuthorizationRequest().apply { + addPermission(modelResourceId) + } + val response = authzClient.authorization(accessToken.value).authorize(request) + response.token != null + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} \ No newline at end of file