diff --git a/authorization/build.gradle.kts b/authorization/build.gradle.kts index cfe23204d7..9554a66a46 100644 --- a/authorization/build.gradle.kts +++ b/authorization/build.gradle.kts @@ -5,10 +5,13 @@ plugins { kotlin("plugin.serialization") } +java { + withSourcesJar() +} + dependencies { implementation(libs.kotlin.serialization.json) implementation(libs.kotlin.serialization.yaml) - implementation(libs.keycloak.authz.client) implementation(libs.guava) api(libs.ktor.server.auth) api(libs.ktor.server.auth.jwt) @@ -19,13 +22,19 @@ dependencies { implementation(libs.ktor.client.cio) implementation(libs.kotlin.reflect) implementation(libs.kotlin.logging) + api(libs.nimbus.jose.jwt) + runtimeOnly(libs.bouncycastle.bcpkix) { + because("conversion of RSA keys from PEM to JWK") + } testImplementation(kotlin("test")) + testImplementation(libs.ktor.server.test.host) + testImplementation(libs.kotlin.coroutines.test) } publishing { publications { create("maven") { - from(components["kotlin"]) + from(components["java"]) } } } diff --git a/authorization/src/main/kotlin/org/modelix/authorization/AccessTokenPrincipal.kt b/authorization/src/main/kotlin/org/modelix/authorization/AccessTokenPrincipal.kt index cfc46870aa..ca155a5fb8 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/AccessTokenPrincipal.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/AccessTokenPrincipal.kt @@ -4,8 +4,7 @@ import com.auth0.jwt.interfaces.DecodedJWT import io.ktor.server.auth.Principal class AccessTokenPrincipal(val jwt: DecodedJWT) : Principal { - fun getUserName(): String? = jwt.getClaim("email")?.asString() - ?: jwt.getClaim("preferred_username")?.asString() + fun getUserName(): String? = ModelixJWTUtil.extractUserId(jwt) override fun equals(other: Any?): Boolean { if (other !is AccessTokenPrincipal) return false diff --git a/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationConfig.kt b/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationConfig.kt index 54ceb015a1..260ff39e01 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationConfig.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationConfig.kt @@ -1,18 +1,18 @@ package org.modelix.authorization -import com.auth0.jwk.JwkProvider -import com.auth0.jwk.JwkProviderBuilder -import com.auth0.jwt.JWT -import com.auth0.jwt.JWTVerifier -import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.interfaces.DecodedJWT +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWK import io.ktor.server.application.Application import io.ktor.server.application.plugin +import org.modelix.authorization.permissions.FileSystemAccessControlPersistence +import org.modelix.authorization.permissions.IAccessControlPersistence +import org.modelix.authorization.permissions.InMemoryAccessControlPersistence import org.modelix.authorization.permissions.Schema import org.modelix.authorization.permissions.buildPermissionSchema import java.io.File import java.net.URI -import java.security.interfaces.RSAPublicKey +import java.security.MessageDigest private val LOG = mu.KotlinLogging.logger { } @@ -36,6 +36,16 @@ interface IModelixAuthorizationConfig { */ var debugEndpointsEnabled: Boolean + /** + * At /permissions/manage users can grant permissions to identity tokens. + */ + var permissionManagementEnabled: Boolean + + /** + * NotLoggedInException and NoPermissionException will be turned into HTTP status codes 401 and 403 + */ + var installStatusPages: Boolean + /** * The pre-shared key for the HMAC512 signature algorithm. * The environment variables MODELIX_JWT_SIGNATURE_HMAC512_KEY or MODELIX_JWT_SIGNATURE_HMAC512_KEY_FILE can be @@ -57,14 +67,27 @@ interface IModelixAuthorizationConfig { */ var hmac256Key: String? + /** + * This key is made available at /.well-known/jwks.json so that other services can verify that a token was created + * by this server. + */ + var ownPublicKey: JWK? + + /** + * In addition to JWKS URLs you can directly provide keys for verification of tokens sent in requests to + * this server. + */ + fun addForeignPublicKey(key: JWK) + /** * If RSA signatures a used, the public key will be downloaded from this registry. */ var jwkUri: URI? /** - * The ID of the public key for the RSA signature. + * If set, only this key is allowed to sign tokens, even if the jwkUri provides multiple keys. */ + @Deprecated("Untrusted keys shouldn't even be return by the jwkUri or configured in some other way") var jwkKeyId: String? /** @@ -72,6 +95,14 @@ interface IModelixAuthorizationConfig { */ var permissionSchema: Schema + /** + * Via /permissions/manage, users can grant permissions to ID tokens. + * By default, changes are not persisted. + * As an alternative to this configuration option, the environment variable MODELIX_ACCESS_CONTROL_FILE can be used + * to write changes to disk. + */ + var accessControlPersistence: IAccessControlPersistence + /** * Generates fake tokens and allows all requests. */ @@ -79,20 +110,22 @@ interface IModelixAuthorizationConfig { } class ModelixAuthorizationConfig : IModelixAuthorizationConfig { - override var permissionChecksEnabled: Boolean? = getBooleanFromEnv("MODELIX_PERMISSION_CHECKS_ENABLED") + override var permissionChecksEnabled: Boolean? = PERMISSION_CHECKS_ENABLED override var generateFakeTokens: Boolean? = getBooleanFromEnv("MODELIX_GENERATE_FAKE_JWT") override var debugEndpointsEnabled: Boolean = true + override var permissionManagementEnabled: Boolean = true + override var installStatusPages: Boolean = false override var hmac512Key: String? = null override var hmac384Key: String? = null override var hmac256Key: String? = null - override var jwkUri: URI? = System.getenv("MODELIX_JWK_URI")?.let { URI(it) } - ?: System.getenv("KEYCLOAK_BASE_URL")?.let { keycloakBaseUrl -> - System.getenv("KEYCLOAK_REALM")?.let { keycloakRealm -> - URI("${keycloakBaseUrl}realms/$keycloakRealm/protocol/openid-connect/certs") - } - } + override var ownPublicKey: JWK? = null + private val foreignPublicKeys = ArrayList() + override var jwkUri: URI? = null override var jwkKeyId: String? = System.getenv("MODELIX_JWK_KEY_ID") override var permissionSchema: Schema = buildPermissionSchema { } + override var accessControlPersistence: IAccessControlPersistence = System.getenv("MODELIX_ACCESS_CONTROL_FILE") + ?.let { path -> FileSystemAccessControlPersistence(File(path)) } + ?: InMemoryAccessControlPersistence() private val hmac512KeyFromEnv by lazy { System.getenv("MODELIX_JWT_SIGNATURE_HMAC512_KEY") @@ -107,58 +140,35 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig { ?: System.getenv("MODELIX_JWT_SIGNATURE_HMAC256_KEY_FILE")?.let { File(it).readText() } } - private val cachedJwkProvider: JwkProvider? by lazy { - jwkUri?.let { JwkProviderBuilder(it.toURL()).build() } - } + val jwtUtil: ModelixJWTUtil by lazy { + val util = ModelixJWTUtil() - private val algorithm: Algorithm? by lazy { - hmac512Key?.let { return@lazy Algorithm.HMAC512(it) } - hmac384Key?.let { return@lazy Algorithm.HMAC384(it) } - hmac256Key?.let { return@lazy Algorithm.HMAC256(it) } - hmac512KeyFromEnv?.let { return@lazy Algorithm.HMAC512(it) } - hmac384KeyFromEnv?.let { return@lazy Algorithm.HMAC384(it) } - hmac256KeyFromEnv?.let { return@lazy Algorithm.HMAC256(it) } - - val localJwkProvider = cachedJwkProvider - val localJwkKeyId = jwkKeyId - if (localJwkProvider == null || localJwkKeyId == null) { - return@lazy null - } - return@lazy getAlgorithmFromJwkProviderAndKeyId(localJwkProvider, localJwkKeyId) - } + util.accessControlDataProvider = accessControlPersistence + util.loadKeysFromEnvironment() - private fun getAlgorithmFromJwkProviderAndKeyId(jwkProvider: JwkProvider, jwkKeyId: String): Algorithm { - val jwk = jwkProvider.get(jwkKeyId) - val publicKey = jwk.publicKey as? RSAPublicKey ?: error("Invalid key type: ${jwk.publicKey}") - return when (jwk.algorithm) { - "RS256" -> Algorithm.RSA256(publicKey, null) - "RSA384" -> Algorithm.RSA384(publicKey, null) - "RS512" -> Algorithm.RSA512(publicKey, null) - else -> error("Unsupported algorithm: ${jwk.algorithm}") - } - } + listOfNotNull>( + hmac512Key?.let { it to JWSAlgorithm.HS512 }, + hmac384Key?.let { it to JWSAlgorithm.HS384 }, + hmac256Key?.let { it to JWSAlgorithm.HS256 }, + hmac512KeyFromEnv?.let { it to JWSAlgorithm.HS512 }, + hmac384KeyFromEnv?.let { it to JWSAlgorithm.HS384 }, + hmac256KeyFromEnv?.let { it to JWSAlgorithm.HS256 }, + ).forEach { util.addHmacKey(it.first, it.second) } + + jwkUri?.let { util.addJwksUrl(it.toURL()) } + + foreignPublicKeys.forEach { util.addPublicKey(it) } - fun getJwtSignatureAlgorithmOrNull(): Algorithm? { - return algorithm + jwkKeyId?.let { util.requireKeyId(it) } + util } - fun getJwkProvider(): JwkProvider? { - return cachedJwkProvider + override fun addForeignPublicKey(key: JWK) { + foreignPublicKeys.add(key) } fun verifyTokenSignature(token: DecodedJWT) { - val algorithm = getJwtSignatureAlgorithmOrNull() - val jwkProvider = getJwkProvider() - - val verifier = if (algorithm != null) { - getVerifierForSpecificAlgorithm(algorithm) - } else if (jwkProvider != null) { - val algorithmForKeyFromToken = getAlgorithmFromJwkProviderAndKeyId(jwkProvider, token.keyId) - getVerifierForSpecificAlgorithm(algorithmForKeyFromToken) - } else { - error("Either an JWT algorithm or a JWK URI must be configured.") - } - verifier.verify(token) + jwtUtil.verifyToken(token.token) // will throw an exception if it's invalid } fun nullIfInvalid(token: DecodedJWT): DecodedJWT? { @@ -178,17 +188,21 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig { * * The fake token is generated so that we always have a username that can be used in the server logic. */ - fun shouldGenerateFakeTokens() = generateFakeTokens ?: (algorithm == null && cachedJwkProvider == null) + fun shouldGenerateFakeTokens() = generateFakeTokens ?: !jwtUtil.canVerifyTokens() /** * Whether permission checking should be enabled based on the configuration values provided. */ - fun permissionCheckingEnabled() = permissionChecksEnabled ?: (algorithm != null || cachedJwkProvider != null) + fun permissionCheckingEnabled() = permissionChecksEnabled ?: jwtUtil.canVerifyTokens() override fun configureForUnitTests() { generateFakeTokens = true permissionChecksEnabled = false } + + companion object { + val PERMISSION_CHECKS_ENABLED = getBooleanFromEnv("MODELIX_PERMISSION_CHECKS_ENABLED") + } } fun Application.getModelixAuthorizationConfig(): ModelixAuthorizationConfig { @@ -203,6 +217,37 @@ private fun getBooleanFromEnv(name: String): Boolean? { } } -internal fun getVerifierForSpecificAlgorithm(algorithm: Algorithm): JWTVerifier = - JWT.require(algorithm) - .build() +internal fun ByteArray.repeatBytes(minimumSize: Int): ByteArray { + if (size >= minimumSize) return this + val repeated = ByteArray(minimumSize) + for (i in repeated.indices) repeated[i] = this[i % size] + return repeated +} + +fun ByteArray.ensureMinSecretLength(algorithm: JWSAlgorithm): ByteArray { + val secret = this + when (algorithm) { + JWSAlgorithm.HS512 -> { + if (secret.size < 512) { + val digest = MessageDigest.getInstance("SHA-512") + digest.update(secret) + return digest.digest() + } + } + JWSAlgorithm.HS384 -> { + if (secret.size < 384) { + val digest = MessageDigest.getInstance("SHA-384") + digest.update(secret) + return digest.digest() + } + } + JWSAlgorithm.HS256 -> { + if (secret.size < 256) { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(secret) + return digest.digest() + } + } + } + return secret +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt b/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt index 6ee6ecb3b2..a040fa2a82 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt @@ -6,6 +6,8 @@ import com.auth0.jwt.exceptions.JWTVerificationException import com.auth0.jwt.interfaces.DecodedJWT import com.auth0.jwt.interfaces.JWTVerifier import com.google.common.cache.CacheBuilder +import com.nimbusds.jose.jwk.JWKSet +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.ApplicationCall @@ -21,6 +23,7 @@ import io.ktor.server.auth.jwt.jwt import io.ktor.server.auth.principal import io.ktor.server.html.respondHtml import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders +import io.ktor.server.plugins.statuspages.StatusPages import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.server.routing.Route @@ -29,8 +32,13 @@ import io.ktor.server.routing.get import io.ktor.server.routing.routing import io.ktor.util.AttributeKey import org.modelix.authorization.permissions.PermissionEvaluator +import org.modelix.authorization.permissions.PermissionInstanceReference +import org.modelix.authorization.permissions.PermissionParser import org.modelix.authorization.permissions.PermissionParts import org.modelix.authorization.permissions.SchemaInstance +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.Collections import java.util.concurrent.TimeUnit private val LOG = mu.KotlinLogging.logger { } @@ -39,6 +47,7 @@ private val LOG = mu.KotlinLogging.logger { } * JWT based authorization plugin. */ object ModelixAuthorization : BaseRouteScopedPlugin { + override fun install( pipeline: ApplicationCallPipeline, configure: IModelixAuthorizationConfig.() -> Unit, @@ -59,8 +68,10 @@ object ModelixAuthorization : BaseRouteScopedPlugin { call, cause -> + call.respondText(text = "401: ${cause.message}", status = HttpStatusCode.Unauthorized) + } + exception { call, cause -> + call.respondText(text = "403: ${cause.message}", status = HttpStatusCode.Forbidden) + } + exception { call, cause -> + call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) + } + } + } + + application.routing { + authenticate(MODELIX_JWT_AUTH) { + (installedIntoRoute ?: this).apply { + if (config.debugEndpointsEnabled) { get("/user") { val jwt = call.principal()?.jwt ?: call.jwtFromHeaders() if (jwt == null) { @@ -117,9 +152,13 @@ object ModelixAuthorization : BaseRouteScopedPlugin = Collections.synchronizedSet(LinkedHashSet()) private val permissionCache = CacheBuilder.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) - .build, Boolean>() + .build, Boolean>() + + fun getDeniedPermissions(): Set = deniedPermissionRequests.toSet() fun hasPermission(call: ApplicationCall, permissionToCheck: PermissionParts): Boolean { + return hasPermission(call, PermissionParser(config.permissionSchema).parse(permissionToCheck)) + } + + fun hasPermission(call: ApplicationCall, permissionToCheck: PermissionInstanceReference): Boolean { if (!config.permissionCheckingEnabled()) return true val principal = call.principal() ?: throw NotLoggedInException() return permissionCache.get(principal to permissionToCheck) { - getPermissionEvaluator(principal).hasPermission(permissionToCheck) + getPermissionEvaluator(principal).hasPermission(permissionToCheck).also { granted -> + if (!granted) { + val userId = principal.getUserName() + if (userId != null) { + synchronized(deniedPermissionRequests) { + deniedPermissionRequests += DeniedPermissionRequest( + permissionRef = permissionToCheck, + userId = userId, + jwtPayload = principal.jwt.payload, + ) + while (deniedPermissionRequests.size >= 100) { + deniedPermissionRequests.iterator().also { it.next() }.remove() + } + } + } + } + } } } @@ -160,24 +223,18 @@ class ModelixAuthorizationPluginInstance(val config: ModelixAuthorizationConfig) fun createSchemaInstance() = SchemaInstance(config.permissionSchema) fun loadGrantedPermissions(principal: AccessTokenPrincipal, evaluator: PermissionEvaluator) { - val permissions = principal.jwt.claims["permissions"]?.asList(String::class.java) - - // There is a difference between access tokens and identity tokens. - // An identity token just contains the user ID and the service has to know the granted permissions. - // An access token has more limited permissions and is issued for a specific task. It contains the list of - // granted permissions. Since tokens are signed and created by a trusted authority we don't have to check the - // list of permissions against our own access control data. - if (permissions != null) { - permissions.forEach { evaluator.grantPermission(it) } - } else { - val userId = principal.getUserName() - if (userId != null) { - // TODO load permissions for the user from some external source - } - } + config.jwtUtil.loadGrantedPermissions(principal.jwt, evaluator) } } +data class DeniedPermissionRequest( + val permissionRef: PermissionInstanceReference, + val userId: String, + val jwtPayload: String, +) { + fun jwtPayloadJson() = String(Base64.getUrlDecoder().decode(jwtPayload), StandardCharsets.UTF_8) +} + /** * Returns an [JWTVerifier] that wraps our common authorization logic, * so that it can be configured in the verification with Ktor's JWT authorization. diff --git a/authorization/src/main/kotlin/org/modelix/authorization/CompositeJWSKeySelector.kt b/authorization/src/main/kotlin/org/modelix/authorization/CompositeJWSKeySelector.kt new file mode 100644 index 0000000000..ce9f037117 --- /dev/null +++ b/authorization/src/main/kotlin/org/modelix/authorization/CompositeJWSKeySelector.kt @@ -0,0 +1,15 @@ +package org.modelix.authorization + +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.proc.JWSKeySelector +import com.nimbusds.jose.proc.SecurityContext +import java.security.Key + +internal class CompositeJWSKeySelector(val selectors: List>) : JWSKeySelector { + override fun selectJWSKeys( + header: JWSHeader, + context: C?, + ): List { + return selectors.flatMap { it.selectJWSKeys(header, context) } + } +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/IAccessControlDataProvider.kt b/authorization/src/main/kotlin/org/modelix/authorization/IAccessControlDataProvider.kt new file mode 100644 index 0000000000..8b3102567f --- /dev/null +++ b/authorization/src/main/kotlin/org/modelix/authorization/IAccessControlDataProvider.kt @@ -0,0 +1,18 @@ +package org.modelix.authorization + +import org.modelix.authorization.permissions.PermissionParts + +interface IAccessControlDataProvider { + fun getGrantedPermissionsForUser(userId: String): Set + fun getGrantedPermissionsForRole(role: String): Set +} + +class EmptyAccessControlDataProvider : IAccessControlDataProvider { + override fun getGrantedPermissionsForUser(userId: String): Set { + return emptySet() + } + + override fun getGrantedPermissionsForRole(role: String): Set { + return emptySet() + } +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/KeycloakTokenConstants.kt b/authorization/src/main/kotlin/org/modelix/authorization/KeycloakTokenConstants.kt new file mode 100644 index 0000000000..6ca036f7fd --- /dev/null +++ b/authorization/src/main/kotlin/org/modelix/authorization/KeycloakTokenConstants.kt @@ -0,0 +1,8 @@ +package org.modelix.authorization + +object KeycloakTokenConstants { + val EMAIL = "email" + val PREFERRED_USERNAME = "preferred_username" + val REALM_ACCESS = "realm_access" + val REALM_ACCESS_ROLES = "roles" +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/KeycloakUtils.kt b/authorization/src/main/kotlin/org/modelix/authorization/KeycloakUtils.kt deleted file mode 100644 index 0d63a3aab8..0000000000 --- a/authorization/src/main/kotlin/org/modelix/authorization/KeycloakUtils.kt +++ /dev/null @@ -1,245 +0,0 @@ -package org.modelix.authorization - -import com.auth0.jwk.JwkProvider -import com.auth0.jwk.JwkProviderBuilder -import com.auth0.jwt.JWT -import com.auth0.jwt.interfaces.DecodedJWT -import com.google.common.cache.CacheBuilder -import org.keycloak.authorization.client.AuthorizationDeniedException -import org.keycloak.authorization.client.AuthzClient -import org.keycloak.authorization.client.Configuration -import org.keycloak.authorization.client.resource.ProtectedResource -import org.keycloak.representations.idm.authorization.AuthorizationRequest -import org.keycloak.representations.idm.authorization.Permission -import org.keycloak.representations.idm.authorization.PermissionRequest -import org.keycloak.representations.idm.authorization.ResourceRepresentation -import org.keycloak.representations.idm.authorization.ScopeRepresentation -import java.net.URL -import java.time.Instant -import java.util.concurrent.TimeUnit - -@Deprecated("Permission are not managed in keycloak anymore") -object KeycloakUtils { - val BASE_URL = System.getenv("KEYCLOAK_BASE_URL") - val REALM = System.getenv("KEYCLOAK_REALM") - val CLIENT_ID = System.getenv("KEYCLOAK_CLIENT_ID") - val CLIENT_SECRET = System.getenv("KEYCLOAK_CLIENT_SECRET") - - fun isEnabled() = BASE_URL != null - - val authzClient: AuthzClient by lazy { - require(isEnabled()) { "Keycloak is not enabled" } - patchUrls( - AuthzClient.create( - Configuration( - BASE_URL, - REALM, - CLIENT_ID, - mapOf("secret" to CLIENT_SECRET), - null, - ), - ), - ) - } - - val jwkProvider: JwkProvider by lazy { - require(isEnabled()) { "Keycloak is not enabled" } - JwkProviderBuilder(URL("${BASE_URL}realms/$REALM/protocol/openid-connect/certs")).build() - } - - private val permissionCache = CacheBuilder.newBuilder() - .expireAfterWrite(30, TimeUnit.SECONDS) - .build, KeycloakScope>, Boolean>() - private val existingResources = CacheBuilder.newBuilder() - .expireAfterWrite(3, TimeUnit.MINUTES) - .build() - - private fun patchUrls(c: AuthzClient): AuthzClient { - patchObject(c.serverConfiguration) - patchObject(c.configuration) - return c - } - - private fun patchObject(obj: Any) { - obj.javaClass.superclass - var cls: Class? = obj.javaClass - while (cls != null) { - for (field in cls.declaredFields) { - field.trySetAccessible() - val value = field.get(obj) - if (value is String && value.contains("://")) { - field.set(obj, patchUrl(value)) - } - } - cls = cls.superclass - } - } - - private fun patchUrl(url: String): String { - return if (url.contains("/realms/")) { - BASE_URL + "realms/" + url.substringAfter("/realms/") - } else { - url - } - } - - fun getServiceAccountToken(): DecodedJWT { - return JWT.decode(authzClient.obtainAccessToken().token) - } - - private fun isAccessToken(token: DecodedJWT): Boolean { - val authClaim = token.getClaim("authorization") - return !(authClaim.isNull || authClaim.isMissing) - } - - private fun readPermissions(token: DecodedJWT): List { - require(isAccessToken(token)) { "Not an access token: ${token.token}" } - try { - val rpt = token.token - val introspect = authzClient.protection().introspectRequestingPartyToken(rpt) - return introspect.permissions ?: emptyList() - } catch (e: Exception) { - throw RuntimeException("Can't get permissions for token: ${token.token}", e) - } - } - - private fun createAccessToken(identityToken: DecodedJWT, permissions: List>>): DecodedJWT { - return JWT.decode( - authzClient.authorization(identityToken.token).authorize( - AuthorizationRequest().also { - for (permission in permissions) { - it.addPermission(permission.first, permission.second) - } - }, - ).token, - ) - } - - @Synchronized - fun hasPermission(identityOrAccessToken: DecodedJWT, resourceSpec: KeycloakResource, scope: KeycloakScope): Boolean { - val key = identityOrAccessToken to resourceSpec to scope - return permissionCache.get(key) { checkPermission(identityOrAccessToken, resourceSpec, scope) } - } - - private fun checkPermission(identityOrAccessToken: DecodedJWT, resourceSpec: KeycloakResource, scope: KeycloakScope): Boolean { - ensureResourcesExists(resourceSpec, identityOrAccessToken) - - if (isAccessToken(identityOrAccessToken)) { - val grantedPermissions = readPermissions(identityOrAccessToken) - val forResource = grantedPermissions.filter { it.resourceName == resourceSpec.name } - if (forResource.isEmpty()) return false - val scopes: Set = forResource.mapNotNull { it.scopes }.flatten().toSet() - if (scopes.isEmpty()) { - // If the permissions are not restricted to any scope we assume they are valid for all scopes. - return true - } - return scopes.contains(scope.name) - } else { - return try { - createAccessToken(identityOrAccessToken, listOf(resourceSpec.name to listOf(scope.name))) - true - } catch (_: AuthorizationDeniedException) { - false - } - } - } - - @Synchronized - fun createToken(permissions: List>>): DecodedJWT { - val requests = permissions.map { - PermissionRequest( - ensureResourcesExists(it.first, null).id, - *it.second.map { it.name }.toTypedArray(), - ) - } - val ticketResponse = authzClient.protection().permission().create(requests) - val authResponse = authzClient.authorization().authorize(AuthorizationRequest(ticketResponse.ticket)) - return JWT.decode(authResponse.token) - } - - @Synchronized - fun ensureResourcesExists( - resourceSpec: KeycloakResource, - owner: DecodedJWT? = null, - ): ResourceRepresentation { - return existingResources.get(resourceSpec.name) { - var resource = authzClient.protection().resource().findByNameAnyOwner(resourceSpec.name) - if (resource != null) return@get resource -// val protection = owner?.let { authzClient.protection(owner.token) } -// ?.takeIf { resourceSpec.type.createByUser } -// ?: authzClient.protection() - val protection = authzClient.protection() - resource = ResourceRepresentation().apply { - name = resourceSpec.name - scopes = resourceSpec.type.scopes.map { ScopeRepresentation(it.name) }.toSet() - type = resourceSpec.type.name -// if (resourceSpec.type.createByUser) ownerManagedAccess = true - if (resourceSpec.type.createByUser) { - attributes = mapOf( - "created-by" to listOfNotNull(owner?.subject, owner?.getClaim("email")?.asString()), - "creation-timestamp" to listOf(Instant.now().epochSecond.toString()), - ) - } - } - resource = protection.resource().create(resource) - permissionCache.invalidateAll() - return@get resource - } - } -} - -data class KeycloakScope(val name: String) { - operator fun plus(other: KeycloakScope): Set = setOf(this, other) - fun toSet() = setOf(this) - - companion object { - val ADD = KeycloakScope("add") // the user can add a new item, but not remove other items in a list - val LIST = KeycloakScope("list") // the user can see that an item exists, but not read the contents - val READ = KeycloakScope("read") - val WRITE = KeycloakScope("write") - val DELETE = KeycloakScope("delete") - val READ_WRITE_DELETE = setOf(READ, WRITE, DELETE) - val READ_WRITE_DELETE_LIST = setOf(READ, WRITE, DELETE, LIST) - val READ_WRITE = setOf(READ, WRITE) - val READ_WRITE_LIST = setOf(READ, WRITE, LIST) - val READ_ONLY = setOf(READ) - val READ_LIST = setOf(READ, LIST) - val ALL_SCOPES = READ_WRITE_DELETE_LIST - } -} -fun EPermissionType.toKeycloakScope(): KeycloakScope = when (this) { - EPermissionType.READ -> KeycloakScope.READ - EPermissionType.WRITE -> KeycloakScope.WRITE -} - -data class KeycloakResource(val name: String, val type: KeycloakResourceType) - -data class KeycloakResourceType(val name: String, val scopes: Set, val createByUser: Boolean = false) { - fun createInstance(resourceName: String) = KeycloakResource(this.name + "/" + resourceName, this) - - companion object { - val DEFAULT_TYPE = KeycloakResourceType("default", KeycloakScope.READ_WRITE) - val MODEL_SERVER_ENTRY = KeycloakResourceType("model-server-entry", KeycloakScope.READ_WRITE_DELETE) - val REPOSITORY = KeycloakResourceType("repository", KeycloakScope.READ_WRITE_DELETE_LIST) - } -} - -fun String.asResource() = KeycloakResourceType.DEFAULT_TYPE.createInstance(this) - -private fun ProtectedResource.findByNameAnyOwner(name: String): ResourceRepresentation? { - val resources: List = org.modelix.authorization.KeycloakUtils.authzClient.protection().resource() - .find( - null, - name, - null, - null, - null, - null, - false, - true, - true, - null, - null, - ) - return resources.firstOrNull() -} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/KtorAuthUtils.kt b/authorization/src/main/kotlin/org/modelix/authorization/KtorAuthUtils.kt index f6d98a8a70..1f079b3c6d 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/KtorAuthUtils.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/KtorAuthUtils.kt @@ -1,14 +1,12 @@ package org.modelix.authorization import com.auth0.jwt.JWT -import com.auth0.jwt.JWTCreator -import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.interfaces.DecodedJWT +import com.nimbusds.jwt.JWTClaimsSet import io.ktor.http.auth.AuthScheme import io.ktor.http.auth.HttpAuthHeader import io.ktor.server.application.Application import io.ktor.server.application.ApplicationCall -import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.call import io.ktor.server.application.install import io.ktor.server.application.plugin @@ -19,9 +17,8 @@ import io.ktor.server.request.header import io.ktor.server.routing.Route import io.ktor.util.pipeline.PipelineContext import org.modelix.authorization.permissions.PermissionEvaluator +import org.modelix.authorization.permissions.PermissionInstanceReference import org.modelix.authorization.permissions.PermissionParts -import java.time.Instant -import java.time.temporal.ChronoUnit internal const val MODELIX_JWT_AUTH = "modelixJwtAuth" @@ -32,45 +29,12 @@ fun Application.installAuthentication(unitTestMode: Boolean = false) { } } -fun Route.requiresPermission(resource: KeycloakResource, permissionType: EPermissionType, body: Route.() -> Unit) { - requiresPermission(resource, permissionType.toKeycloakScope(), body) -} - -fun Route.requiresRead(resource: KeycloakResource, body: Route.() -> Unit) { - requiresPermission(resource, KeycloakScope.READ, body) -} - -fun Route.requiresWrite(resource: KeycloakResource, body: Route.() -> Unit) { - requiresPermission(resource, KeycloakScope.WRITE, body) -} - -fun Route.requiresDelete(resource: KeycloakResource, body: Route.() -> Unit) { - requiresPermission(resource, KeycloakScope.DELETE, body) -} - -fun Route.requiresPermission(resource: KeycloakResource, scope: KeycloakScope, body: Route.() -> Unit) { - requiresLogin { - intercept(ApplicationCallPipeline.Call) { - call.checkPermission(resource, scope) - } - body() - } -} - fun Route.requiresLogin(body: Route.() -> Unit) { authenticate(MODELIX_JWT_AUTH) { body() } } -fun ApplicationCall.checkPermission(resource: KeycloakResource, scope: KeycloakScope) { - if (!application.getModelixAuthorizationConfig().permissionCheckingEnabled()) return - val principal = principal() ?: throw NotLoggedInException() - if (!KeycloakUtils.hasPermission(principal.jwt, resource, scope)) { - throw NoPermissionException(principal, resource.name, scope.name) - } -} - fun PipelineContext<*, ApplicationCall>.checkPermission(permissionParts: PermissionParts) { call.checkPermission(permissionParts) } @@ -86,28 +50,20 @@ fun ApplicationCall.hasPermission(permissionToCheck: PermissionParts): Boolean { return application.plugin(ModelixAuthorization).hasPermission(this, permissionToCheck) } -fun ApplicationCall.getPermissionEvaluator(): PermissionEvaluator { - return application.plugin(ModelixAuthorization).getPermissionEvaluator(this) -} - -fun createModelixAccessToken(hmac512key: String, user: String, grantedPermissions: List, additionalTokenContent: (JWTCreator.Builder) -> Unit = {}): String { - return createModelixAccessToken(Algorithm.HMAC512(hmac512key), user, grantedPermissions, additionalTokenContent) +fun ApplicationCall.hasPermission(permissionToCheck: PermissionInstanceReference): Boolean { + return application.plugin(ModelixAuthorization).hasPermission(this, permissionToCheck) } -/** - * Creates a valid JWT token that is compatible to servers with the [ModelixAuthorization] plugin installed. - */ -fun createModelixAccessToken(algorithm: Algorithm, user: String, grantedPermissions: List, additionalTokenContent: (JWTCreator.Builder) -> Unit = {}): String { - return JWT.create() - .withClaim("preferred_username", user) - .withClaim("permissions", grantedPermissions) - .withExpiresAt(Instant.now().plus(12, ChronoUnit.HOURS)) - .also(additionalTokenContent) - .sign(algorithm) +fun ApplicationCall.getPermissionEvaluator(): PermissionEvaluator { + return application.plugin(ModelixAuthorization).getPermissionEvaluator(this) } -private fun Map?.readRolesArray(): List { - return this?.get("roles") as? List ?: emptyList() +fun createModelixAccessToken(hmac512key: String, user: String, grantedPermissions: List, additionalTokenContent: (JWTClaimsSet.Builder) -> Unit = {}): String { + return ModelixJWTUtil().also { + it.setHmac512Key(hmac512key) + }.createAccessToken(user, grantedPermissions) { + additionalTokenContent(it.claimSetBuilder) + } } fun ApplicationCall.getBearerToken(): String? { @@ -139,13 +95,3 @@ fun ApplicationCall.getUserName(): String? { fun DecodedJWT.nullIfInvalid(): DecodedJWT? { return ModelixAuthorizationConfig().nullIfInvalid(this) } - -private var cachedServiceAccountToken: DecodedJWT? = null -val serviceAccountTokenProvider: () -> String = { - var token: DecodedJWT? = cachedServiceAccountToken?.nullIfInvalid() - if (token == null) { - token = KeycloakUtils.getServiceAccountToken() - cachedServiceAccountToken = token - } - token.token -} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/ModelixJWTUtil.kt b/authorization/src/main/kotlin/org/modelix/authorization/ModelixJWTUtil.kt new file mode 100644 index 0000000000..b662954dc1 --- /dev/null +++ b/authorization/src/main/kotlin/org/modelix/authorization/ModelixJWTUtil.kt @@ -0,0 +1,345 @@ +package org.modelix.authorization + +import com.auth0.jwt.interfaces.DecodedJWT +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSObject +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.JWSVerifier +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory +import com.nimbusds.jose.jwk.JWK +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.KeyType +import com.nimbusds.jose.jwk.KeyUse +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.jwk.source.RemoteJWKSet +import com.nimbusds.jose.proc.BadJOSEException +import com.nimbusds.jose.proc.JWSAlgorithmFamilyJWSKeySelector +import com.nimbusds.jose.proc.JWSKeySelector +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jose.proc.SingleKeyJWSKeySelector +import com.nimbusds.jose.util.AbstractRestrictedResourceRetriever +import com.nimbusds.jose.util.Resource +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.JWTParser +import com.nimbusds.jwt.proc.DefaultJWTProcessor +import com.nimbusds.jwt.proc.JWTProcessor +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.contentType +import kotlinx.coroutines.runBlocking +import org.modelix.authorization.permissions.PermissionEvaluator +import org.modelix.authorization.permissions.Schema +import org.modelix.authorization.permissions.SchemaInstance +import java.io.File +import java.net.URI +import java.net.URL +import java.security.Key +import java.security.MessageDigest +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Base64 +import java.util.Date +import java.util.UUID +import javax.crypto.spec.SecretKeySpec + +class ModelixJWTUtil { + private var hmacKeys = LinkedHashMap() + private var rsaPrivateKey: JWK? = null + private var rsaPublicKeys = ArrayList() + private val jwksUrls = LinkedHashSet() + private var expectedKeyId: String? = null + private var ktorClient: HttpClient? = null + var accessControlDataProvider: IAccessControlDataProvider = EmptyAccessControlDataProvider() + + private var jwtProcessor: JWTProcessor? = null + + @Synchronized + private fun getOrCreateJwtProcessor(): JWTProcessor { + return jwtProcessor ?: DefaultJWTProcessor().also { processor -> + val keySelectors: List> = hmacKeys.map { it.toPair() }.map { + SingleKeyJWSKeySelector(it.first, SecretKeySpec(it.second, it.first.name)) + } + jwksUrls.map { + val client = this.ktorClient + if (client == null) { + JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(it) + } else { + JWSAlgorithmFamilyJWSKeySelector.fromJWKSource(RemoteJWKSet(it, KtorResourceRetriever(client))) + } + } + rsaPublicKeys.map { + JWSAlgorithmFamilyJWSKeySelector.fromJWKSource(ImmutableJWKSet(JWKSet(it.toPublicJWK()))) + } + + processor.jwsKeySelector = if (keySelectors.size == 1) keySelectors.single() else CompositeJWSKeySelector(keySelectors) + + val expectedKeyId = this.expectedKeyId + if (expectedKeyId != null) { + processor.jwsVerifierFactory = object : DefaultJWSVerifierFactory() { + override fun createJWSVerifier(header: JWSHeader, key: Key): JWSVerifier { + if (header.keyID != expectedKeyId) { + throw BadJOSEException("Invalid key ID. [expected=$expectedKeyId, actual=${header.keyID}]") + } + return super.createJWSVerifier(header, key) + } + } + } + }.also { jwtProcessor = it } + } + + private fun resetJwtProcess() { + jwtProcessor = null + } + + @Synchronized + fun canVerifyTokens(): Boolean { + return hmacKeys.isNotEmpty() || rsaPublicKeys.isNotEmpty() || jwksUrls.isNotEmpty() + } + + /** + * Tokens are only valid if they are signed with this key. + */ + @Synchronized + fun requireKeyId(id: String) { + expectedKeyId = id + } + + @Synchronized + fun useKtorClient(client: HttpClient) { + resetJwtProcess() + this.ktorClient = client.config { + expectSuccess = true + } + } + + @Synchronized + fun addJwksUrl(url: String) { + addJwksUrl(URI(url).toURL()) + } + + @Synchronized + fun addJwksUrl(url: URL) { + resetJwtProcess() + jwksUrls += url + } + + fun setHmac512Key(key: String) { + addHmacKey(key, JWSAlgorithm.HS512) + } + + fun addHmacKey(key: String, algorithm: JWSAlgorithm) { + // nimbusds checks for weak keys that are shorter than 256 bytes + addHmacKey(key.toByteArray().ensureMinSecretLength(algorithm), algorithm) + } + + @Synchronized + fun addPublicKey(key: JWK) { + requireNotNull(key.keyID) { "Key doesn't specify a key ID: $key" } + requireNotNull(key.algorithm) { "Key doesn't specify an algorithm: $key" } + resetJwtProcess() + rsaPublicKeys.add(key) + } + + @Synchronized + fun setRSAPrivateKey(key: JWK) { + requireNotNull(key.keyID) { "Key doesn't specify a key ID: $key" } + requireNotNull(key.algorithm) { "Key doesn't specify an algorithm: $key" } + resetJwtProcess() + this.rsaPrivateKey = key + addPublicKey(key.toPublicJWK()) + } + + @Synchronized + private fun addHmacKey(key: ByteArray, algorithm: JWSAlgorithm) { + resetJwtProcess() + hmacKeys[algorithm] = key + } + + @Synchronized + fun getPublicJWKS(): JWKSet { + return JWKSet(listOfNotNull(rsaPrivateKey)).toPublicJWKSet() + } + + @Synchronized + fun loadKeysFromEnvironment() { + resetJwtProcess() + System.getenv().filter { it.key.startsWith("MODELIX_JWK_FILE") }.values.forEach { + File(it).walk().forEach { file -> + when (file.extension) { + "pem" -> loadPemFile(file.readText()) + "json" -> loadJwkFile(file.readText()) + } + } + } + + // allows multiple URLs (MODELIX_JWK_URI1, MODELIX_JWK_URI2, MODELIX_JWK_URI_MODEL_SERVER, ...) + System.getenv().filter { it.key.startsWith("MODELIX_JWK_URI") }.values + .forEach { addJwksUrl(URI(it).toURL()) } + } + + @Synchronized + fun createAccessToken(user: String, grantedPermissions: List, additionalTokenContent: (TokenBuilder) -> Unit = {}): String { + val signer: JWSSigner + val algorithm: JWSAlgorithm + val signingKeyId: String? + val jwk = this.rsaPrivateKey + if (jwk != null) { + signer = RSASSASigner(jwk.toRSAKey().toRSAPrivateKey()) + algorithm = checkNotNull(jwk.algorithm) { "RSA key doesn't specify an algorithm" } as JWSAlgorithm + signingKeyId = checkNotNull(jwk.keyID) { "RSA key doesn't specify a key ID" } + } else { + val entry = checkNotNull(hmacKeys.entries.firstOrNull()) { "No keys for signing provided" } + signer = MACSigner(entry.value) + algorithm = entry.key + signingKeyId = null + } + + val payload = JWTClaimsSet.Builder() + .claim(KeycloakTokenConstants.PREFERRED_USERNAME, user) + .claim(ModelixTokenConstants.PERMISSIONS, grantedPermissions) + .expirationTime(Date(Instant.now().plus(12, ChronoUnit.HOURS).toEpochMilli())) + .also { additionalTokenContent(TokenBuilder(it)) } + .build() + .toPayload() + val header = JWSHeader.Builder(algorithm).keyID(signingKeyId).build() + return JWSObject(header, payload).also { it.sign(signer) }.serialize() + } + + fun isAccessToken(token: DecodedJWT): Boolean { + return extractPermissions(token) != null + } + + fun isIdentityToken(token: DecodedJWT): Boolean { + return !isAccessToken(token) + } + + fun createPermissionEvaluator(token: DecodedJWT, schema: Schema): PermissionEvaluator { + return createPermissionEvaluator(token, SchemaInstance(schema)) + } + + fun createPermissionEvaluator(token: DecodedJWT, schema: SchemaInstance): PermissionEvaluator { + return PermissionEvaluator(schema).also { loadGrantedPermissions(token, it) } + } + + fun extractPermissions(token: DecodedJWT): List? { + return token.claims[ModelixTokenConstants.PERMISSIONS]?.asList(String::class.java) + } + + @Synchronized + fun loadGrantedPermissions(token: DecodedJWT, evaluator: PermissionEvaluator) { + val permissions = extractPermissions(token) + + // There is a difference between access tokens and identity tokens. + // An identity token just contains the user ID and the service has to know the granted permissions. + // An access token has more limited permissions and is issued for a specific task. It contains the list of + // granted permissions. Since tokens are signed and created by a trusted authority we don't have to check the + // list of permissions against our own access control data. + if (permissions != null) { + permissions.forEach { evaluator.grantPermission(it) } + } else { + val directGrants = extractUserId(token)?.let { userId -> + accessControlDataProvider.getGrantedPermissionsForUser(userId) + }.orEmpty() + extractUserRoles(token).flatMap { role -> + accessControlDataProvider.getGrantedPermissionsForRole(role) + }.toSet() + directGrants.forEach { permission -> + evaluator.grantPermission(permission) + } + } + } + + fun extractUserId(jwt: DecodedJWT): String? { + return Companion.extractUserId(jwt) + } + + fun extractUserRoles(jwt: DecodedJWT): List { + val keycloakRoles = jwt + .getClaim(KeycloakTokenConstants.REALM_ACCESS)?.asMap() + ?.get(KeycloakTokenConstants.REALM_ACCESS_ROLES) + ?.let { it as? List<*> } + ?.mapNotNull { it as? String } + ?: emptyList() + return keycloakRoles + } + + fun generateRSAPrivateKey(): JWK { + return RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .issueTime(Date()) + .algorithm(JWSAlgorithm.RS256) + .generate() + .also { setRSAPrivateKey(it) } + } + + fun loadPemFile(fileContent: String): JWK { + return ensureValidKey(JWK.parseFromPEMEncodedObjects(fileContent)).also { loadJwk(it) } + } + + private fun ensureValidKey(key: JWK): JWK { + return ensureKeyId(ensureAlgorithmSet(key)) + } + + private fun ensureAlgorithmSet(key: JWK): JWK { + if (key.algorithm != null) return key + require(key.keyType == KeyType.RSA) { "Unsupported key type: ${key.keyType}" } + return RSAKey.Builder(key.toRSAKey()).algorithm(JWSAlgorithm.RS256).build() + } + + private fun ensureKeyId(key: JWK): JWK { + if (key.keyID != null) return key + + val rsaKey = key.toRSAKey() + val digest = MessageDigest.getInstance("SHA-256") + digest.update(rsaKey.modulus.decode()) + digest.update(0) + digest.update(rsaKey.publicExponent.decode()) + val keyId = Base64.getUrlEncoder().withoutPadding().encodeToString(digest.digest()) + return RSAKey.Builder(rsaKey).keyID(keyId).build() + } + + fun loadJwkFile(fileContent: String): JWK { + return JWK.parse(fileContent).also { loadJwk(it) } + } + + private fun loadJwk(key: JWK) { + resetJwtProcess() + if (key.isPrivate) { + setRSAPrivateKey(key) + } else { + addPublicKey(key) + } + } + + @Synchronized + fun verifyToken(token: String) { + getOrCreateJwtProcessor().process(JWTParser.parse(token), null) + } + + class TokenBuilder(private val builder: JWTClaimsSet.Builder) { + val claimSetBuilder: JWTClaimsSet.Builder get() = builder + fun claim(name: String, value: String) { + builder.claim(name, value) + } + } + + companion object { + fun extractUserId(jwt: DecodedJWT): String? { + return jwt.getClaim(KeycloakTokenConstants.EMAIL)?.asString() + ?: jwt.getClaim(KeycloakTokenConstants.PREFERRED_USERNAME)?.asString() + } + } +} + +class KtorResourceRetriever(val client: HttpClient) : AbstractRestrictedResourceRetriever(1000, 1000, 0) { + override fun retrieveResource(url: URL): Resource? { + return runBlocking { + val response = client.get(url.toString()) + Resource(response.bodyAsText(), response.contentType()?.toString()) + } + } +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/ModelixTokenConstants.kt b/authorization/src/main/kotlin/org/modelix/authorization/ModelixTokenConstants.kt new file mode 100644 index 0000000000..ec8d34d47c --- /dev/null +++ b/authorization/src/main/kotlin/org/modelix/authorization/ModelixTokenConstants.kt @@ -0,0 +1,5 @@ +package org.modelix.authorization + +object ModelixTokenConstants { + val PERMISSIONS = "permissions" +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/PermissionManagementPage.kt b/authorization/src/main/kotlin/org/modelix/authorization/PermissionManagementPage.kt new file mode 100644 index 0000000000..7a7ce86898 --- /dev/null +++ b/authorization/src/main/kotlin/org/modelix/authorization/PermissionManagementPage.kt @@ -0,0 +1,277 @@ +package org.modelix.authorization + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.application +import io.ktor.server.application.call +import io.ktor.server.application.plugin +import io.ktor.server.auth.principal +import io.ktor.server.html.respondHtml +import io.ktor.server.request.receiveParameters +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import kotlinx.html.HTML +import kotlinx.html.body +import kotlinx.html.br +import kotlinx.html.div +import kotlinx.html.h1 +import kotlinx.html.head +import kotlinx.html.hiddenInput +import kotlinx.html.postForm +import kotlinx.html.style +import kotlinx.html.submitInput +import kotlinx.html.table +import kotlinx.html.td +import kotlinx.html.textInput +import kotlinx.html.th +import kotlinx.html.tr +import org.modelix.authorization.permissions.PermissionInstanceReference +import org.modelix.authorization.permissions.PermissionParser + +fun Route.installPermissionManagementHandlers() { + route("permissions") { + get("manage") { + call.respondHtml { + buildPermissionManagementPage(call, application.plugin(ModelixAuthorization)) + } + } + post("grant") { + val formParameters = call.receiveParameters() + val userId = formParameters["userId"] + val roleId = formParameters["roleId"] + require(userId != null || roleId != null) { "userId or roleId required" } + val permissionId = requireNotNull(formParameters["permissionId"]) { "permissionId not specified" } + call.checkCanGranPermission(permissionId) + + if (userId != null) { + application.plugin(ModelixAuthorization).config.accessControlPersistence.update { + it.withGrantToUser(userId, permissionId) + } + } + if (roleId != null) { + application.plugin(ModelixAuthorization).config.accessControlPersistence.update { + it.withGrantToRole(roleId, permissionId) + } + } + call.respond("Granted $permissionId to ${userId ?: roleId}") + } + post("remove-grant") { + val formParameters = call.receiveParameters() + val userId = formParameters["userId"] + val roleId = formParameters["roleId"] + require(userId != null || roleId != null) { "userId or roleId required" } + val permissionId = requireNotNull(formParameters["permissionId"]) { "permissionId not specified" } + call.checkCanGranPermission(permissionId) + if (userId != null) { + application.plugin(ModelixAuthorization).config.accessControlPersistence.update { + it.withoutGrantToUser(userId, permissionId) + } + } + if (roleId != null) { + application.plugin(ModelixAuthorization).config.accessControlPersistence.update { + it.withoutGrantToUser(roleId, permissionId) + } + } + call.respond("Removed $permissionId to ${userId ?: roleId}") + } + } +} + +fun HTML.buildPermissionManagementPage(call: ApplicationCall, pluginInstance: ModelixAuthorizationPluginInstance) { + head { + style { + //language=CSS + +""" + table { + border: 1px solid #ccc; + border-collapse: collapse; + } + td, th { + border: 1px solid #ccc; + padding: 3px 12px; + } + """.trimIndent() + } + } + body { + h1 { + +"Grant Permission" + } + postForm(action = "grant") { + +"Grant permission" + textInput { + name = "permissionId" + } + +" to user " + textInput { + name = "userId" + } + submitInput { + value = "Grant" + } + } + br {} + postForm(action = "grant") { + +"Grant permission" + textInput { + name = "permissionId" + } + +" to role " + textInput { + name = "roleId" + } + submitInput { + value = "Grant" + } + } + + h1 { + +"Granted Permissions" + } + + table { + tr { + th { +"User" } + th { +"Permission" } + } + for ((userId, permission) in pluginInstance.config.accessControlPersistence.read().grantsToUsers.flatMap { entry -> entry.value.map { entry.key to it } }) { + if (!call.canGrantPermission(permission)) continue + + tr { + td { + +userId + } + td { + +permission + } + td { + postForm(action = "remove-grant") { + hiddenInput { + name = "userId" + value = userId + } + hiddenInput { + name = "permissionId" + value = permission + } + submitInput { + value = "Remove" + } + } + } + } + } + } + + br {} + + table { + tr { + th { +"Role" } + th { +"Permission" } + } + for ((roleId, permission) in pluginInstance.config.accessControlPersistence.read().grantsToRoles.flatMap { entry -> entry.value.map { entry.key to it } }) { + if (!call.canGrantPermission(permission)) continue + + tr { + td { + +roleId + } + td { + +permission + } + td { + postForm(action = "remove-grant") { + hiddenInput { + name = "roleId" + value = roleId + } + hiddenInput { + name = "permissionId" + value = permission + } + submitInput { + value = "Remove" + } + } + } + } + } + } + + h1 { + +"Denied Permissions" + } + + table { + tr { + th { +"User" } + th { +"Denied Permission" } + th { +"Grant" } + } + for (deniedPermission in pluginInstance.getDeniedPermissions()) { + if (!call.canGrantPermission(deniedPermission.permissionRef)) continue + + val userId = deniedPermission.userId + tr { + td { + +userId + } + td { + +deniedPermission.permissionRef.toPermissionParts().fullId + } + td { + val evaluator = pluginInstance.createPermissionEvaluator() + val permissionInstance = evaluator.instantiatePermission(deniedPermission.permissionRef) + val candidates = (setOf(permissionInstance) + permissionInstance.transitiveIncludedIn()) + postForm(action = "grant") { + hiddenInput { + name = "userId" + value = userId + } + for (candidate in candidates) { + div { + submitInput { + name = "permissionId" + value = candidate.ref.toString() + } + } + } + } + } + } + } + } + } +} + +fun ApplicationCall.canGrantPermission(permissionId: String): Boolean { + return canGrantPermission(parsePermission(permissionId)) +} + +fun ApplicationCall.canGrantPermission(permissionRef: PermissionInstanceReference): Boolean { + val plugin = application.plugin(ModelixAuthorization) + val schema = plugin.config.permissionSchema + val resources = generateSequence(permissionRef.resource) { it.parent } + return resources.any { + // hardcoded admin/owner to keep it simple and not having to introduce a permission schema for permissions + val managers = listOf( + PermissionInstanceReference("admin", it), + PermissionInstanceReference("owner", it), + ) + managers.any { it.isValid(schema) && plugin.hasPermission(this, it) } + } +} + +fun ApplicationCall.checkCanGranPermission(id: String) { + if (!canGrantPermission(id)) { + val principal = principal() + throw NoPermissionException(principal, null, null, "${principal?.getUserName()} has no permission '$id'") + } +} + +fun ApplicationCall.parsePermission(id: String): PermissionInstanceReference { + return application.plugin(ModelixAuthorization).config.permissionSchema.let { PermissionParser(it) }.parse(id) +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/permissions/AccessControlData.kt b/authorization/src/main/kotlin/org/modelix/authorization/permissions/AccessControlData.kt new file mode 100644 index 0000000000..03ee4d57d5 --- /dev/null +++ b/authorization/src/main/kotlin/org/modelix/authorization/permissions/AccessControlData.kt @@ -0,0 +1,125 @@ +package org.modelix.authorization.permissions + +import com.auth0.jwt.interfaces.DecodedJWT +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.modelix.authorization.IAccessControlDataProvider +import org.modelix.authorization.ModelixJWTUtil +import java.io.File +import kotlin.collections.get + +@Serializable +data class AccessControlData( + /** + * User ID to granted permission IDs + */ + val grantsToUsers: Map> = emptyMap(), + + /** + * Grants based on user roles extracted from the JWT token. + */ + val grantsToRoles: Map> = emptyMap(), +) { + + /** + * Load permissions for an identity token. + * Identity tokens, unlike access tokens, don't have any permissions encoded in the token. + * The resource server then is expected to know which permissions the user has. + */ + fun load(jwt: DecodedJWT, permissionEvaluator: PermissionEvaluator) { + val util = ModelixJWTUtil() + if (util.isAccessToken(jwt)) { + // The purpose of an access token is to restrict the permissions to the ones specified in the token. + return + } + val userId = util.extractUserId(jwt) + for (permissionId in (grantsToUsers[userId] ?: emptyList())) { + permissionEvaluator.grantPermission(permissionId) + } + val roles = util.extractUserRoles(jwt) + for (role in roles) { + for (permissionId in (grantsToRoles[role] ?: emptyList())) { + permissionEvaluator.grantPermission(permissionId) + } + } + } + + fun withLegacyRoles(): AccessControlData { + return this + .withGrantToRole("modelix-user", PermissionSchemaBase.cluster.user.fullId) + .withGrantToRole("modelix-admin", PermissionSchemaBase.cluster.admin.fullId) + } + + fun withGrantToRole(role: String, permissionId: String): AccessControlData { + return copy(grantsToRoles = grantsToRoles + (role to (grantsToRoles[role] ?: emptySet()) + permissionId)) + } + + fun withGrantToUser(user: String, permissionId: String): AccessControlData { + return copy(grantsToUsers = grantsToUsers + (user to (grantsToUsers[user] ?: emptySet()) + permissionId)) + } + + fun withoutGrantToUser(user: String, permissionId: String): AccessControlData { + val newGrants = (grantsToUsers[user] ?: emptySet()) - permissionId + return if (newGrants.isEmpty()) { + copy(grantsToUsers = grantsToUsers - user) + } else { + copy(grantsToUsers = grantsToUsers + (user to newGrants)) + } + } + + fun withoutGrantToRole(role: String, permissionId: String): AccessControlData { + val newGrants = (grantsToRoles[role] ?: emptySet()) - permissionId + return if (newGrants.isEmpty()) { + copy(grantsToRoles = grantsToRoles - role) + } else { + copy(grantsToRoles = grantsToRoles + (role to newGrants)) + } + } +} + +interface IAccessControlPersistence : IAccessControlDataProvider { + fun read(): AccessControlData + fun update(updater: (AccessControlData) -> AccessControlData) + override fun getGrantedPermissionsForUser(userId: String): Set = + read().grantsToUsers[userId]?.map { PermissionParts.fromString(it) }?.toSet() ?: emptySet() + override fun getGrantedPermissionsForRole(role: String): Set = + read().grantsToRoles[role]?.map { PermissionParts.fromString(it) }?.toSet() ?: emptySet() +} + +class FileSystemAccessControlPersistence(val file: File) : IAccessControlPersistence { + + private var data: AccessControlData = if (file.exists()) { + Json.decodeFromString(file.readText()) + } else { + AccessControlData() + }.withLegacyRoles() + + override fun read(): AccessControlData { + return data + } + + @Synchronized + override fun update(updater: (AccessControlData) -> AccessControlData) { + data = updater(data) + writeFile() + } + + private fun writeFile() { + file.writeText(Json.encodeToString(data)) + } +} + +class InMemoryAccessControlPersistence : IAccessControlPersistence { + + private var data: AccessControlData = AccessControlData().withLegacyRoles() + + override fun read(): AccessControlData { + return data + } + + @Synchronized + override fun update(updater: (AccessControlData) -> AccessControlData) { + data = updater(data) + } +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionEvaluator.kt b/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionEvaluator.kt index 1801f470bd..a9c46783c3 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionEvaluator.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionEvaluator.kt @@ -1,5 +1,7 @@ package org.modelix.authorization.permissions +import org.modelix.authorization.UnknownPermissionException + class PermissionEvaluator(val schemaInstance: SchemaInstance) { private val allGrantedPermissions: MutableSet = HashSet() private val parser = PermissionParser(schemaInstance.schema) @@ -7,11 +9,15 @@ class PermissionEvaluator(val schemaInstance: SchemaInstance) { fun getAllGrantedPermissions(): Set = schemaInstance.getAllPermissions().map { it.ref }.filter { hasPermission(it) }.toSet() fun grantPermission(permissionId: String) { - grantPermission(parser.parse(permissionId)) + grantPermission(PermissionParts.fromString(permissionId)) } fun grantPermission(permissionId: PermissionParts) { - grantPermission(parser.parse(permissionId)) + try { + grantPermission(parser.parse(permissionId)) + } catch (ex: UnknownPermissionException) { + // Tokens may also contain permissions for other services. + } } fun grantPermission(permissionRef: PermissionInstanceReference) { @@ -32,7 +38,10 @@ class PermissionEvaluator(val schemaInstance: SchemaInstance) { } fun instantiatePermission(permissionId: PermissionParts): SchemaInstance.ResourceInstance.PermissionInstance { - val permissionRef = parser.parse(permissionId) + return instantiatePermission(parser.parse(permissionId)) + } + + fun instantiatePermission(permissionRef: PermissionInstanceReference): SchemaInstance.ResourceInstance.PermissionInstance { val instance = schemaInstance.instantiatePermission(permissionRef) hasPermission(permissionRef) // permissions are instantiated during the check return instance diff --git a/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionSchemaBase.kt b/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionSchemaBase.kt new file mode 100644 index 0000000000..0be1813a0e --- /dev/null +++ b/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionSchemaBase.kt @@ -0,0 +1,31 @@ +@file:Suppress("ClassName") + +package org.modelix.authorization.permissions + +object PermissionSchemaBase { + val SCHEMA: Schema = SchemaBuilder().apply { + resource("permission-data") { + permission("write") { + includedIn("cluster", "admin") + permission("read") + } + } + resource("cluster") { + permission("admin") { + permission("user") + } + } + }.build() + + object permissionData { + val resource = PermissionParts("permission-data") + val write = resource + "write" + val read = resource + "read" + } + + object cluster { + val resource = PermissionParts("cluster") + val admin = resource + "admin" + val user = resource + "user" + } +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/permissions/SchemaBuilder.kt b/authorization/src/main/kotlin/org/modelix/authorization/permissions/SchemaBuilder.kt index 660dda78a2..d803ec28ad 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/permissions/SchemaBuilder.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/permissions/SchemaBuilder.kt @@ -1,7 +1,7 @@ package org.modelix.authorization.permissions fun buildPermissionSchema(body: SchemaBuilder.() -> Unit): Schema { - return SchemaBuilder().also(body).build() + return SchemaBuilder().apply { extends(PermissionSchemaBase.SCHEMA) }.also(body).build() } /** diff --git a/authorization/src/main/kotlin/org/modelix/authorization/permissions/SchemaInstance.kt b/authorization/src/main/kotlin/org/modelix/authorization/permissions/SchemaInstance.kt index d6021ff03b..50da21a0b2 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/permissions/SchemaInstance.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/permissions/SchemaInstance.kt @@ -1,5 +1,7 @@ package org.modelix.authorization.permissions +import org.modelix.authorization.UnknownPermissionException + /** * Instantiates the abstract schema with data from an actual system. */ @@ -69,9 +71,8 @@ class SchemaInstance(val schema: Schema) { fun getOrCreatePermissionInstance(name: String): PermissionInstance { return permissions.getOrPut(name) { - val permissionSchema = requireNotNull(resourceSchema.permissions[name]) { - "Permission '$name' not found in $reference" - } + val permissionSchema = resourceSchema.permissions[name] + if (permissionSchema == null) throw UnknownPermissionException("", unknownElement = name) PermissionInstance(permissionSchema, PermissionInstanceReference(name, reference)) } } @@ -100,6 +101,23 @@ class SchemaInstance(val schema: Schema) { val includedIn: MutableSet = HashSet() val includes: MutableSet = HashSet() + fun transitiveIncludes(acc: MutableSet = LinkedHashSet()): Set { + for (p in includes) { + acc.add(p) + p.transitiveIncludes(acc) + } + return acc + } + + fun transitiveIncludedIn(acc: MutableSet = LinkedHashSet()): Set { + for (p in includedIn) { + if (acc.contains(p)) continue + acc.add(p) + p.transitiveIncludedIn(acc) + } + return acc + } + fun updateIncludes() { permissionSchema.includedIn.forEach { target -> resolveResourceInstance(target.resourceName)?.let { @@ -141,4 +159,12 @@ data class PermissionInstanceReference(val permissionName: String, val resource: override fun toString(): String { return toPermissionParts().toString() } + fun isValid(schema: Schema): Boolean { + return try { + PermissionParser(schema).parse(toPermissionParts()) + true + } catch (ex: UnknownPermissionException) { + false + } + } } diff --git a/authorization/src/test/kotlin/org/modelix/authorization/AccessControlDataTest.kt b/authorization/src/test/kotlin/org/modelix/authorization/AccessControlDataTest.kt new file mode 100644 index 0000000000..0590369805 --- /dev/null +++ b/authorization/src/test/kotlin/org/modelix/authorization/AccessControlDataTest.kt @@ -0,0 +1,66 @@ +package org.modelix.authorization + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import org.modelix.authorization.permissions.AccessControlData +import org.modelix.authorization.permissions.PermissionEvaluator +import org.modelix.authorization.permissions.PermissionParts +import org.modelix.authorization.permissions.SchemaInstance +import org.modelix.authorization.permissions.buildPermissionSchema +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AccessControlDataTest { + private val schema = buildPermissionSchema { + resource("r1") { + permission("delete") { + permission("write") { + permission("read") + } + } + } + } + private val email = "unit-tests@example.com" + + @Test + fun `can grant permissions to identity tokens`() { + val token = JWT.create() + .withClaim(KeycloakTokenConstants.EMAIL, email) + .sign(Algorithm.HMAC256("unit-tests")) + .let { JWT.decode(it) } + val data = AccessControlData().withGrantToUser(email, PermissionParts("r1", "write").fullId) + val evaluator = PermissionEvaluator(SchemaInstance(schema)) + + assertFalse(evaluator.hasPermission(PermissionParts("r1", "read"))) + assertFalse(evaluator.hasPermission(PermissionParts("r1", "write"))) + assertFalse(evaluator.hasPermission(PermissionParts("r1", "delete"))) + + data.load(token, evaluator) + + assertTrue(evaluator.hasPermission(PermissionParts("r1", "read"))) + assertTrue(evaluator.hasPermission(PermissionParts("r1", "write"))) + assertFalse(evaluator.hasPermission(PermissionParts("r1", "delete"))) + } + + @Test + fun `granted permissions are not applied to access tokens`() { + val email = "unit-tests@example.com" + val token = ModelixJWTUtil() + .also { it.setHmac512Key("xxx") } + .createAccessToken(email, emptyList()) + .let { JWT.decode(it) } + val data = AccessControlData().withGrantToUser(email, PermissionParts("r1", "write").fullId) + val evaluator = PermissionEvaluator(SchemaInstance(schema)) + + assertFalse(evaluator.hasPermission(PermissionParts("r1", "read"))) + assertFalse(evaluator.hasPermission(PermissionParts("r1", "write"))) + assertFalse(evaluator.hasPermission(PermissionParts("r1", "delete"))) + + data.load(token, evaluator) + + assertFalse(evaluator.hasPermission(PermissionParts("r1", "read"))) + assertFalse(evaluator.hasPermission(PermissionParts("r1", "write"))) + assertFalse(evaluator.hasPermission(PermissionParts("r1", "delete"))) + } +} diff --git a/authorization/src/test/kotlin/org/modelix/authorization/PermissionManagementTest.kt b/authorization/src/test/kotlin/org/modelix/authorization/PermissionManagementTest.kt new file mode 100644 index 0000000000..a4caf6020e --- /dev/null +++ b/authorization/src/test/kotlin/org/modelix/authorization/PermissionManagementTest.kt @@ -0,0 +1,90 @@ +package org.modelix.authorization + +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.forms.submitForm +import io.ktor.http.HttpStatusCode +import io.ktor.http.parameters +import io.ktor.server.application.install +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import org.modelix.authorization.permissions.buildPermissionSchema +import kotlin.test.Test +import kotlin.test.assertEquals + +class PermissionManagementTest { + private val schema = buildPermissionSchema { + resource("server") { + permission("admin") + resource("repository") { + parameter("name") + permission("owner") { + permission("write") { + permission("read") + } + } + } + } + } + private val hmacKey = "unit-test" + + private fun runTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + install(ModelixAuthorization) { + permissionSchema = schema + hmac512Key = hmacKey + permissionManagementEnabled = true + installStatusPages = true + } + } + block() + } + + private fun createToken(user: String, vararg permissions: String): String { + return ModelixJWTUtil().also { it.setHmac512Key(hmacKey) }.createAccessToken(user, permissions.toList()) + } + + @Test + fun `direct resource owner can grant permission`() = runTest { + val response = client.submitForm( + url = "http://localhost/permissions/grant", + formParameters = parameters { + append("userId", "userB") + append("permissionId", "server/repository/my-repo/read") + }, + block = { + bearerAuth(createToken("userA", "server/repository/my-repo/owner")) + }, + ) + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `indirect resource owner can grant permission`() = runTest { + val response = client.submitForm( + url = "http://localhost/permissions/grant", + formParameters = parameters { + append("userId", "userB") + append("permissionId", "server/repository/my-repo/read") + }, + block = { + bearerAuth(createToken("userA", "server/admin")) + }, + ) + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `non-owners cannot grant permissions`() = runTest { + val response = client.submitForm( + url = "http://localhost/permissions/grant", + formParameters = parameters { + append("userId", "userB") + append("permissionId", "server/repository/my-repo/read") + }, + block = { + bearerAuth(createToken("userA", "server/repository/my-repo/write")) + }, + ) + assertEquals(HttpStatusCode.Forbidden, response.status) + } +} diff --git a/authorization/src/test/kotlin/org/modelix/authorization/RSATest.kt b/authorization/src/test/kotlin/org/modelix/authorization/RSATest.kt new file mode 100644 index 0000000000..66ba42c5a4 --- /dev/null +++ b/authorization/src/test/kotlin/org/modelix/authorization/RSATest.kt @@ -0,0 +1,154 @@ +package org.modelix.authorization + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.proc.BadJOSEException +import io.ktor.server.application.install +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import org.modelix.authorization.permissions.buildPermissionSchema +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class RSATest { + + private val rsaPrivateKey = ModelixJWTUtil().generateRSAPrivateKey() + + private fun runTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + install(ModelixAuthorization) { + permissionSchema = buildPermissionSchema { } + ownPublicKey = rsaPrivateKey.toPublicJWK() + } + } + block() + } + + @Test + fun `verify signature against public key provided by server`() = runTest { + val util = ModelixJWTUtil() + util.addJwksUrl(URI("http://localhost/.well-known/jwks.json").toURL()) + util.useKtorClient(client) + val token = ModelixJWTUtil().also { it.setRSAPrivateKey(rsaPrivateKey) }.createAccessToken("unit-test@example.com", listOf()) + util.verifyToken(token) + } + + @Test + fun `verification with mismatching keys fails`() = runTest { + val util = ModelixJWTUtil() + util.addJwksUrl(URI("http://localhost/.well-known/jwks.json").toURL()) + util.useKtorClient(client) + util.generateRSAPrivateKey() + val token = ModelixJWTUtil().also { it.generateRSAPrivateKey() }.createAccessToken("unit-test@example.com", listOf()) + val ex = assertFailsWith(BadJOSEException::class) { + util.verifyToken(token) + } + assertTrue((ex.message ?: "").contains("no matching key")) + } + + @Test + fun `can load keys in pem format`() { + val publicKeyPem = """ + -----BEGIN CERTIFICATE----- + MIIDBDCCAeygAwIBAgIRAMsOxfAGx0Q8gryFhrNZoNowDQYJKoZIhvcNAQELBQAw + HDEaMBgGA1UEAxMRd29ya3NwYWNlLW1hbmFnZXIwIBcNMjQxMTE1MTMyNzQyWhgP + MjEyNDExMTUxMzI3NDJaMBwxGjAYBgNVBAMTEXdvcmtzcGFjZS1tYW5hZ2VyMIIB + IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyB+2c/hRX7lhcTKHOom13F7d + Vnujy1XndcYp4y42NIxRZDuimOU/inkH6tJsclIftPeYSWnSTWRc5ZG268pRMjD6 + rMCxCTyo1S7VGuXtdPbfL1makCYfpKALBZdLgrYVkor49CP2cBdKPldYUT7+EpqF + xXkaeL073bS3vPPdxN/riuYu3df3tLe9+st6Tr6+rv1+HK+dRegPok8ryMOogT96 + QyF7ygLDQ1WW/v/CZI5y+jW1xEpWnHRkRqHWTtIMjWN6WK+ez1kg4tlQDWmMn4by + wmTPRs38weLEMnTUrjfrOxOc59rWOyE7b186RrDf1F1ezLiVUlLA9L7ThydM3QID + AQABoz8wPTAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsG + AQUFBwMCMAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggEBAE+fIPlFYLiP + 4QoxWBVIaQVC1o/DMtfDe7qSzd561+4fsgqbTE07DnKSX1Y7hHHSoUOOI42UUzyR + wcqTMqkoF4fdoT9onPCDldc6SJQHrRmH7l3YFiVk+bM2NR7QuL9/9Dn5sqzoaWEh + 9zB8fk6T/g/56OPyvzs4tzC1Pvmz4JfwX9hTKIbqh3duUBfov2m3nkzbmoMF987x + 0hdxnMqzOWq9y4dBOLQNheCkVDctImDNIPLQ1IJuzm3GpIpPxuOSLgDi7Nh1QHnI + S3F48Kap0hI/OhqgM3mBUGs56Fc5THNh0zVuGqsIAW7jUiYH+lrnmWzNC/Uf9CpH + SZWUy6UZNS4= + -----END CERTIFICATE----- + """.trimIndent().trim() + + val privateKeyPem = """ + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEAyB+2c/hRX7lhcTKHOom13F7dVnujy1XndcYp4y42NIxRZDui + mOU/inkH6tJsclIftPeYSWnSTWRc5ZG268pRMjD6rMCxCTyo1S7VGuXtdPbfL1ma + kCYfpKALBZdLgrYVkor49CP2cBdKPldYUT7+EpqFxXkaeL073bS3vPPdxN/riuYu + 3df3tLe9+st6Tr6+rv1+HK+dRegPok8ryMOogT96QyF7ygLDQ1WW/v/CZI5y+jW1 + xEpWnHRkRqHWTtIMjWN6WK+ez1kg4tlQDWmMn4bywmTPRs38weLEMnTUrjfrOxOc + 59rWOyE7b186RrDf1F1ezLiVUlLA9L7ThydM3QIDAQABAoIBAEXspsCgrDYpPP3j + bNKsWWn1j5rvOo0KqARDyFEDzZbQzIOcPrTzrR8CKR0IhzHutftyY7iLDBtUjQz9 + vA9pMrO532zLK1CR7GAIrBdo7W5n8BXIVjQ1zeqkrRU4Bv9WBfWdL12Gz03dJWjg + 9g/1VatEaKdWKES1whw2T9jq0Ls/7/uRTtL31g6SnI/UW5RnZe4TQhNtnTltts6T + eHUU7MjKIlB4VQrHx8up/QdsMIvXihv72jm374nZe6U3e8HmuGb71qXA4YPFju5c + Aict16PVNUTb2ZAylH33NB0k1LlHaCbkQM+Cy3jhhtb1XERXt7tDyS/hiC++HG6b + jlAvqzUCgYEA27OjEbEbw60ca9goC/mafZoDofZWA3aNI+TR15EsFAYQHtoE4DLy + Nrlm0syqqJJwf117jLhu+KpKrJtb36XqfUqnwwISAilnr6OnPT47qs8dbrRIxnap + COh9yw0YerLFPuJ9HTPZMCWs7ufDcXJyuRfjL25lq/kv7jGD6jHRvnMCgYEA6TAG + PK/OyIizT4OtdzNbejQ7W+9wi4tfhjF8OMmgQb6kpsmSmhoaFCQ5SAg9MwqbL2q1 + 3XSEkPXljONqWmkQZ/2Eo4WHveOKoKj/07LiRucs5jjHyr5pea80z5lTnE8i7MJX + eNSTqi3b9WnV0J0EHhg7qgAbH/q+c5gtiqgkI28CgYB9z0ONSQdmKUaCNzjPirK+ + RCjaYW7l8shmCo1jzT0ZhlNK53wtSt9LGSZZhlwfxiPnu4eZkK/zc8jpSNn2m1NJ + RiwFTrUzSbSXbrbBKlcOvCXVlCWsiJzJfiEy2p/u+1paZWZSB7PSj3CVKmDQIUKy + 3Yv6SFSugzbARtiMjtTWIwKBgGFKDyAcvap/FkjTiHkWLVFkH2vxD0S5RoaHeOt8 + e+dSMgIAUbEHuN+0aU27WkVEZJC49d3KclDEtxw7+bB060pnxIIxAPxhxgHX4Lyj + grLQWrRG9lyJaxpA1kjTEMZDYi/juXkJP/6dmYrfuDyMdh5UP/hiiO6jv/gcgsu5 + 8THzAoGAUGCnccd4JAXK3/rmkCLT++M18G6ik+qaTMdhGnC9opTDWDvxWF6jHj7w + 4/wol7RQf0qmWZr6sSg+dg/cEOvAxBDiayl7WALnEpGhh2+aKkDVIy7JSTOm3fkO + P1Z2sotIDXrYJrdKl/BvWh80ifVYjHp9J/cOhMSyj/HCMhxexhY= + -----END RSA PRIVATE KEY----- + """.trimIndent().trim() + + val publicJwk = ModelixJWTUtil().loadPemFile(publicKeyPem) + val privateJwk = ModelixJWTUtil().loadPemFile(privateKeyPem) + println(publicJwk) + println(privateJwk) + assertEquals("uDTdtRkJdv2y7WfxBVtXiV4jgxyJhPcQg-byYepLjGM", publicJwk.keyID) + assertEquals(publicJwk.keyID, privateJwk.keyID) + assertEquals(JWSAlgorithm.RS256, privateJwk.algorithm) + assertEquals(publicJwk.algorithm, privateJwk.algorithm) + } + + @Test + fun `can load keys in JWK format`() { + // language=json + val publicKeyJson = """ + { + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "sig-1733907425", + "alg": "RS256", + "n": "jIRZzMHFd9tlVvqE3Ovh8DrhrVu7VCoxatCdUv1-kZJ1iFsE7gMIimdJNoqZumoFOOIV418bfHbu0yTWiYKZhqYTM2Q_VVPY3cavu74aYsbQ_j2O6P-CcC0IB1th2icesQRqC2j-z9CztFGfRsLMnFkmyalMAmXvABBxKV-auZm0BlRKyfJ_eacIwk0_rmQW0pEUafFGhOgU9NgxxV-tJXl1gxKn6kH3WNhpwaTJtH9q4nfB03kZHqwvvLKEQW6QVmyPuzfhVXOZRSjzwsdyzCFTzwaAVdaVFxOiEfH8zbOto-8CBWcXzcTlY9FXSq_1QQINkqnYxyPsZT4lHsRjjw" + } + """.trimIndent().trim() + + // language=json + val privateKeyJson = """ + { + "p": "xtB2RMru_9pBiF1kNJCngdqVvdIhsA4AzbZ44-rD6oMH0GMI9JYmbbmTYo6zu8qMa1CLJ4dLQcFgAyBNV8JkC4-WsRa-dWEdLeidvyH34xTKfOGTGlBeFMunvRAJYQHn4TcF7J74fRddxd2F99JYsoUmCRfu0gjLq03l3cFhFuc", + "kty": "RSA", + "q": "tO82HFPW8fDYuwM6nTOdF888lSv_zs4AGauqPFr9HN5hIpt2k35Xl7-gXdCzxEd8D7o7CWL_AcXy2qSfb0HGgQeavOEb8ELwLP0RHgNAB0LA3oShEnWAvpOzrr72M3aSL2dELdy9kNW-rJiF-RoDZ84BsBLfSArv6Qb7K3YzwRk", + "d": "JP1iNkh8FwUmNDNWbmGZ5IdbiSswsQM6ZwfrokEg5GlNj0uGjLE3uldeKoFp3myyWzsI0AXlUmpsjCCSaTh7-boWK90j3u5nlFoNQLrWb1IvCf5idGtuhuETz_v6Ulch-S9USxSkn0gtRjaGWzZEbpP5ZfSvEaKLu9SYNW_5ZwnvJoplNK5RzbU48HHedVc8t1Ef0aWsxYW1tXz80j-NG4PNF-Ey7C7cqSbLgBTqIV513K3dm8S8bU8SCMuA-XPCGMEULj2ZpiVoHfIhqfBy113zOoTDgo6R7C8N-ameVXEJKMc0PEbidsVhG75vKD2i0CI9PenW1ZuOMyCPi9g0QQ", + "e": "AQAB", + "use": "sig", + "kid": "sig-1733907425", + "qi": "sM5Pog0X9GBdflzq4MNdDLZ-il6a-sMrhSVDx_2hZWjOs8aVKdnwZNJl7jx2UFgtKIBKCVCh473dNrrRi4dRu7FnAUaxXoCOdHABk-GIHQlPkO3BAZyMFp4UyrdlaZiA-zyUGYBPf1SEESb1enQYsCc8y3Fk8NqxxL5BFj6l2FI", + "dp": "kwlXfrcrHRP4xXZ0hp-5EsNrXXDMM12X4IwkSkO1U3pGzCqCVAm8MAhAZXKuoKNDSJbP45Me6GmwrX81VENTJG20gBIXF86T-wD_sXzYzRvySXu3BI4Nlomr65qxpQn4yUqdWguUMUeXtZ-I1ei-aoEoyS7nFHUm0_GPoHrFaF8", + "alg": "RS256", + "dq": "HJUhdi4kaYoDot9qtgS-T1GUn3gY7CGM0IFW3jv9ej8DF0V54Oj3i2hhPBDJJTuptI5V3zC9WhlcOQACk7_PTPjXj_j7wePBL0o3FweqaLs53q0TCOh5EyIgI33VROH5S_XDRn91jtjFS1y45VYfrZlUmO0SSr43khdhPEdq-5k", + "n": "jIRZzMHFd9tlVvqE3Ovh8DrhrVu7VCoxatCdUv1-kZJ1iFsE7gMIimdJNoqZumoFOOIV418bfHbu0yTWiYKZhqYTM2Q_VVPY3cavu74aYsbQ_j2O6P-CcC0IB1th2icesQRqC2j-z9CztFGfRsLMnFkmyalMAmXvABBxKV-auZm0BlRKyfJ_eacIwk0_rmQW0pEUafFGhOgU9NgxxV-tJXl1gxKn6kH3WNhpwaTJtH9q4nfB03kZHqwvvLKEQW6QVmyPuzfhVXOZRSjzwsdyzCFTzwaAVdaVFxOiEfH8zbOto-8CBWcXzcTlY9FXSq_1QQINkqnYxyPsZT4lHsRjjw" + } + """.trimIndent().trim() + + val publicJwk = ModelixJWTUtil().loadJwkFile(publicKeyJson) + val privateJwk = ModelixJWTUtil().loadJwkFile(privateKeyJson) + assertEquals("sig-1733907425", publicJwk.keyID) + assertEquals(publicJwk.keyID, privateJwk.keyID) + assertEquals(JWSAlgorithm.RS256, privateJwk.algorithm) + assertEquals(publicJwk.algorithm, privateJwk.algorithm) + } +} diff --git a/authorization/src/test/kotlin/org/modelix/authorization/RolesFromTokenTest.kt b/authorization/src/test/kotlin/org/modelix/authorization/RolesFromTokenTest.kt new file mode 100644 index 0000000000..742189c39c --- /dev/null +++ b/authorization/src/test/kotlin/org/modelix/authorization/RolesFromTokenTest.kt @@ -0,0 +1,15 @@ +package org.modelix.authorization + +import com.auth0.jwt.JWT +import kotlin.test.Test +import kotlin.test.assertEquals + +class RolesFromTokenTest { + + @Test + fun `extract roles from token created by Keycloak`() { + val token = JWT.decode("eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI5T1VVR2pRa3ZQRE1OMmJjcFA1RDRLUnpOM3l2elRWOV9TZFVrdlpUUG1NIn0.eyJleHAiOjE3MzIwNTgzODcsImlhdCI6MTczMjAyMjM4NywiYXV0aF90aW1lIjoxNzMyMDA1Njk5LCJqdGkiOiI3MjI4ZTkxMS03YmY3LTQ3YWMtODYxMy1jNDQyNTYwODJjZDkiLCJpc3MiOiJodHRwczovL2xvY2FsaG9zdC9yZWFsbXMvbW9kZWxpeCIsImF1ZCI6WyJtb2RlbGl4IiwiYWNjb3VudCJdLCJzdWIiOiIzZmYwYWMxNi00NjU4LTRjOTItOGUyZS01NTIwNTM1YzFhN2YiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJtb2RlbGl4Iiwibm9uY2UiOiI3ZmtBMFJ6ZUhSNkpNa0ZZeV9qTEQ1cE5aSmdRdVVBNUszcVFyUjdTeVR3Iiwic2Vzc2lvbl9zdGF0ZSI6IjU2NDIzYTZiLTM2OGUtNDZjYS04ZDM0LWI5YTA1ZjExM2Q0NyIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiKiJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibW9kZWxpeC11c2VyIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImRlZmF1bHQtcm9sZXMtbW9kZWxpeCJdfSwicmVzb3VyY2VfYWNjZXNzIjp7Im1vZGVsaXgiOnsicm9sZXMiOlsidW1hX3Byb3RlY3Rpb24iXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJzaWQiOiI1NjQyM2E2Yi0zNjhlLTQ2Y2EtOGQzNC1iOWEwNWYxMTNkNDciLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJTIEwiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzbEBxNjAuZGUiLCJnaXZlbl9uYW1lIjoiUyIsImZhbWlseV9uYW1lIjoiTCIsImVtYWlsIjoic2xAcTYwLmRlIn0.m-PW8gNmrjQhLJw6BJez-pQSUk8jMZ5QB2HuPv-pyJZon6idsxp5sSpMelWb_3Cb78BEf5AeSbzxB_yZJEf7uFbAYURsRAumaiq8u5HofHuwIoofyCoJjGKlBYnkZpL1mNRPy1sHZfdMre3Yh6bKsztz0PWaEVlSx8wGyXPup84p2uy5-k0eThAI2zKmIa-YxGXmCwb0IbQakp5Q77mQeWa1e_ozr4zf72ScbvB80ourRJEY6YwkZyEbIoM015CvlE3hgN5fL0AVg9Zr18pY4oSwwNYbIiaIbWlUN29QcelDq1jX969fIQw2O1GJEusU3K_ZtWZJsMZdPWYpxf-uiw") + val roles = ModelixJWTUtil().extractUserRoles(token) + assertEquals(listOf("modelix-user", "offline_access", "uma_authorization", "default-roles-modelix"), roles) + } +} diff --git a/commitlint.config.js b/commitlint.config.js index fb6f76de9a..c5dbd5206f 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -5,6 +5,7 @@ module.exports = { 2, "always", [ + "authorization", "bulk-model-sync", "bulk-model-sync-gradle", "deps", diff --git a/docs/global/modules/core/pages/reference/component-model-server.adoc b/docs/global/modules/core/pages/reference/component-model-server.adoc index 05c157f160..ac03136ab2 100644 --- a/docs/global/modules/core/pages/reference/component-model-server.adoc +++ b/docs/global/modules/core/pages/reference/component-model-server.adoc @@ -85,10 +85,6 @@ To enable it you can specify the following environment variables. |MODELIX_JWK_KEY_ID |Optional key ID that can be used together with `MODELIX_JWK_URI`. If specified, it ensures that only tokens that use the specified key are valid. If not specified, a token can use any RSA (256, 384 and 512) key provided by `MODELIX_JWK_URI`. -|KEYCLOAK_BASE_URL - KEYCLOAK_REALM -|Legacy variables for the keycloak based authorization used by the Modelix Helm charts for workspaces. - |=== The `permissions` claim of the token is expected to list directly granted permission. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 21f6e7973a..3c65bd6a52 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -143,3 +143,6 @@ reaktive = { group = "org.modelix.reaktive", name = "reaktive", version.ref = "r reaktive-testing = { group = "org.modelix.reaktive", name = "reaktive-testing", version.ref = "reaktive" } reaktive-annotations = { group = "org.modelix.reaktive", name = "reaktive-annotations", version.ref = "reaktive" } reaktive-coroutines-interop = { group = "org.modelix.reaktive", name = "coroutines-interop", version.ref = "reaktive" } + +nimbus-jose-jwt = { group = "com.nimbusds", name = "nimbus-jose-jwt", version = "9.46" } +bouncycastle-bcpkix = { group = "org.bouncycastle", name = "bcpkix-lts8on", version = "2.73.7" } diff --git a/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/NonWrittenEntry.kt b/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/NonWrittenEntry.kt index 9945def968..72fb271f76 100644 --- a/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/NonWrittenEntry.kt +++ b/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/NonWrittenEntry.kt @@ -37,4 +37,8 @@ class NonWrittenEntry : IKVEntryReference { deserialized.isWritten = true } } + + override fun toString(): String { + return hash + } } diff --git a/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/WrittenEntry.kt b/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/WrittenEntry.kt index a5c466e135..9909bf2c07 100644 --- a/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/WrittenEntry.kt +++ b/model-datastructure/src/commonMain/kotlin/org/modelix/model/lazy/WrittenEntry.kt @@ -30,4 +30,8 @@ class WrittenEntry( override fun write(store: IDeserializingKeyValueStore) {} override fun getDeserializer(): (String) -> E = deserializer + + override fun toString(): String { + return hash + } } diff --git a/model-server/src/main/kotlin/org/modelix/model/server/Main.kt b/model-server/src/main/kotlin/org/modelix/model/server/Main.kt index 26936eada4..0a0b1a4842 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/Main.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/Main.kt @@ -176,6 +176,7 @@ object Main { install(Routing) install(ModelixAuthorization) { permissionSchema = ModelServerPermissionSchema.SCHEMA + installStatusPages = false } install(ForwardedHeaders) install(CallLogging) { diff --git a/model-server/src/main/kotlin/org/modelix/model/server/ModelServerPermissionSchema.kt b/model-server/src/main/kotlin/org/modelix/model/server/ModelServerPermissionSchema.kt index 522243fe96..511a8d5429 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/ModelServerPermissionSchema.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/ModelServerPermissionSchema.kt @@ -1,6 +1,7 @@ package org.modelix.model.server import org.modelix.authorization.permissions.PermissionParts +import org.modelix.authorization.permissions.PermissionSchemaBase import org.modelix.authorization.permissions.buildPermissionSchema import org.modelix.model.lazy.BranchReference import org.modelix.model.lazy.RepositoryId @@ -8,7 +9,6 @@ import org.modelix.model.lazy.RepositoryId object ModelServerPermissionSchema { private const val MODEL_SERVER = "model-server" private const val ADMIN = "admin" - private const val PERMISSION_SCHEMA = "permission-schema" private const val WRITE = "write" private const val READ = "read" private const val LEGACY_USER_DEFINED_ENTRIES = "legacy-user-defined-entries" @@ -29,31 +29,22 @@ object ModelServerPermissionSchema { val SCHEMA = buildPermissionSchema { resource(MODEL_SERVER) { - permission(ADMIN) - } - - resource(PERMISSION_SCHEMA) { - permission(WRITE) { - includedIn(MODEL_SERVER, ADMIN) - permission(READ) + permission(ADMIN) { + includedIn(PermissionSchemaBase.cluster.admin.parts[0], PermissionSchemaBase.cluster.admin.parts[1]) } } resource(LEGACY_USER_DEFINED_ENTRIES) { - permission(READ) { - includedIn(MODEL_SERVER, ADMIN) - } permission(WRITE) { includedIn(MODEL_SERVER, ADMIN) + permission(READ) } } resource(LEGACY_GLOBAL_OBJECTS) { - permission(READ) { - includedIn(MODEL_SERVER, ADMIN) - } permission(ADD) { includedIn(MODEL_SERVER, ADMIN) + permission(READ) } } diff --git a/model-server/src/main/kotlin/org/modelix/model/server/handlers/KeyValueLikeModelServer.kt b/model-server/src/main/kotlin/org/modelix/model/server/handlers/KeyValueLikeModelServer.kt index 931b31ed88..a2e99af62c 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/handlers/KeyValueLikeModelServer.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/handlers/KeyValueLikeModelServer.kt @@ -21,10 +21,7 @@ import kotlinx.html.span import org.json.JSONArray import org.json.JSONObject import org.modelix.authorization.EPermissionType -import org.modelix.authorization.KeycloakResourceType -import org.modelix.authorization.KeycloakScope import org.modelix.authorization.NoPermissionException -import org.modelix.authorization.asResource import org.modelix.authorization.checkPermission import org.modelix.authorization.getUserName import org.modelix.authorization.requiresLogin @@ -40,9 +37,6 @@ import java.io.IOException import java.util.* import java.util.regex.Pattern -val PERMISSION_MODEL_SERVER = "model-server".asResource() -val MODEL_SERVER_ENTRY = KeycloakResourceType("model-server-entry", KeycloakScope.READ_WRITE_DELETE) - private class NotFoundException(description: String?) : RuntimeException(description) typealias CallContext = PipelineContext @@ -286,6 +280,9 @@ class KeyValueLikeModelServer( serverId = { throw NoPermissionException("'$key' is read-only.") }, + legacyClientId = { + throw NoPermissionException("Directly writing to 'clientId' is not allowed") + }, unknown = { userDefinedEntries[key] = value }, @@ -341,6 +338,7 @@ class KeyValueLikeModelServer( serverId = { if (isWrite) throw NoPermissionException("'$key' is read-only.") }, + legacyClientId = {}, unknown = { call.checkPermission(ModelServerPermissionSchema.legacyUserDefinedObjects.run { if (isWrite) write else read }) }, @@ -352,6 +350,7 @@ class KeyValueLikeModelServer( immutableObject: () -> R, branch: (branch: BranchReference) -> R, serverId: () -> R, + legacyClientId: () -> R, unknown: () -> R, ): R { return when { @@ -360,6 +359,7 @@ class KeyValueLikeModelServer( key.startsWith(PROTECTED_PREFIX) -> throw NoPermissionException("Access to keys starting with '$PROTECTED_PREFIX' is only permitted to the model server itself.") key.startsWith(RepositoriesManager.KEY_PREFIX) -> throw NoPermissionException("Access to keys starting with '${RepositoriesManager.KEY_PREFIX}' is only permitted to the model server itself.") key == RepositoriesManager.LEGACY_SERVER_ID_KEY || key == RepositoriesManager.LEGACY_SERVER_ID_KEY2 -> serverId() + key == "clientId" -> legacyClientId() else -> unknown() } } diff --git a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/ContentExplorer.kt b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/ContentExplorer.kt index 34e747660f..488e10aaee 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/ContentExplorer.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/ContentExplorer.kt @@ -44,6 +44,7 @@ import kotlinx.html.tr import kotlinx.html.ul import kotlinx.html.unsafe import org.modelix.authorization.checkPermission +import org.modelix.authorization.requiresLogin import org.modelix.model.api.BuiltinLanguages import org.modelix.model.api.INodeResolutionScope import org.modelix.model.api.ITree @@ -69,127 +70,129 @@ class ContentExplorer(private val repoManager: IRepositoriesManager) { get("/content") { call.respondRedirect("../repos/") } - get("/content/repositories/{repository}/branches/{branch}/latest") { - val repository = call.parameters["repository"] - val branch = call.parameters["branch"] - if (repository.isNullOrEmpty()) { - call.respondText("repository not found", status = HttpStatusCode.BadRequest) - return@get - } - if (branch.isNullOrEmpty()) { - call.respondText("branch not found", status = HttpStatusCode.BadRequest) - return@get - } - call.checkPermission(ModelServerPermissionSchema.repository(repository).branch(branch).pull) + requiresLogin { + get("/content/repositories/{repository}/branches/{branch}/latest") { + val repository = call.parameters["repository"] + val branch = call.parameters["branch"] + if (repository.isNullOrEmpty()) { + call.respondText("repository not found", status = HttpStatusCode.BadRequest) + return@get + } + if (branch.isNullOrEmpty()) { + call.respondText("branch not found", status = HttpStatusCode.BadRequest) + return@get + } + call.checkPermission(ModelServerPermissionSchema.repository(repository).branch(branch).pull) - val latestVersion = repoManager.getVersion(BranchReference(RepositoryId(repository), branch)) - if (latestVersion == null) { - call.respondText("unable to find latest version", status = HttpStatusCode.InternalServerError) - return@get - } else { - call.respondRedirect("../../../versions/${latestVersion.getContentHash()}/") - } - } - get("/content/repositories/{repository}/versions/{versionHash}") { - val repositoryId = call.parameters["repository"]?.let { RepositoryId(it) } - if (repositoryId == null) { - call.respondText("repository parameter missing", status = HttpStatusCode.BadRequest) - return@get - } - call.checkPermission(ModelServerPermissionSchema.repository(repositoryId).objects.read) - val versionHash = call.parameters["versionHash"] - if (versionHash.isNullOrEmpty()) { - call.respondText("version parameter missing", status = HttpStatusCode.BadRequest) - return@get + val latestVersion = repoManager.getVersion(BranchReference(RepositoryId(repository), branch)) + if (latestVersion == null) { + call.respondText("unable to find latest version", status = HttpStatusCode.InternalServerError) + return@get + } else { + call.respondRedirect("../../../versions/${latestVersion.getContentHash()}/") + } } + get("/content/repositories/{repository}/versions/{versionHash}") { + val repositoryId = call.parameters["repository"]?.let { RepositoryId(it) } + if (repositoryId == null) { + call.respondText("repository parameter missing", status = HttpStatusCode.BadRequest) + return@get + } + call.checkPermission(ModelServerPermissionSchema.repository(repositoryId).objects.read) + val versionHash = call.parameters["versionHash"] + if (versionHash.isNullOrEmpty()) { + call.respondText("version parameter missing", status = HttpStatusCode.BadRequest) + return@get + } - // IMPORTANT Do not let `expandTo` be an arbitrary string to avoid code injection. - // The value of `expandTo` is expanded into JavaScript. - val expandTo = call.request.queryParameters["expandTo"]?.let { - it.toLongOrNull() ?: return@get call.respondText("Invalid expandTo value. Provide a node id.", status = HttpStatusCode.BadRequest) - } + // IMPORTANT Do not let `expandTo` be an arbitrary string to avoid code injection. + // The value of `expandTo` is expanded into JavaScript. + val expandTo = call.request.queryParameters["expandTo"]?.let { + it.toLongOrNull() ?: return@get call.respondText("Invalid expandTo value. Provide a node id.", status = HttpStatusCode.BadRequest) + } - val tree = CLVersion.loadFromHash(versionHash, repoManager.getLegacyObjectStore(repositoryId)).getTree() - val rootNode = PNodeAdapter(ITree.ROOT_ID, TreePointer(tree)) + val tree = CLVersion.loadFromHash(versionHash, repoManager.getLegacyObjectStore(repositoryId)).getTree() + val rootNode = PNodeAdapter(ITree.ROOT_ID, TreePointer(tree)) - val expandedNodes = expandTo?.let { nodeId -> getAncestorsAndSelf(nodeId, tree) }.orEmpty() + val expandedNodes = expandTo?.let { nodeId -> getAncestorsAndSelf(nodeId, tree) }.orEmpty() - call.respondHtmlTemplate(PageWithMenuBar("repos/", "../../../../..")) { - headContent { - title("Content Explorer") - link("../../../../../public/content-explorer.css", rel = "stylesheet") - script("text/javascript", src = "../../../../../public/content-explorer.js") {} - if (expandTo != null) { - script("text/javascript") { - unsafe { - +""" + call.respondHtmlTemplate(PageWithMenuBar("repos/", "../../../../..")) { + headContent { + title("Content Explorer") + link("../../../../../public/content-explorer.css", rel = "stylesheet") + script("text/javascript", src = "../../../../../public/content-explorer.js") {} + if (expandTo != null) { + script("text/javascript") { + unsafe { + +""" document.addEventListener("DOMContentLoaded", function(event) { scrollToElement('$expandTo'); }); - """.trimIndent() + """.trimIndent() + } } } } + bodyContent { contentPageBody(rootNode, versionHash, expandedNodes, expandTo) } } - bodyContent { contentPageBody(rootNode, versionHash, expandedNodes, expandTo) } - } - } - post("/content/repositories/{repository}/versions/{versionHash}") { - val repositoryId = call.parameters["repository"]?.let { RepositoryId(it) } - if (repositoryId == null) { - call.respondText("repository parameter missing", status = HttpStatusCode.BadRequest) - return@post - } - val versionHash = call.parameters["versionHash"] - if (versionHash.isNullOrEmpty()) { - call.respondText("version parameter missing", status = HttpStatusCode.BadRequest) - return@post } + post("/content/repositories/{repository}/versions/{versionHash}") { + val repositoryId = call.parameters["repository"]?.let { RepositoryId(it) } + if (repositoryId == null) { + call.respondText("repository parameter missing", status = HttpStatusCode.BadRequest) + return@post + } + val versionHash = call.parameters["versionHash"] + if (versionHash.isNullOrEmpty()) { + call.respondText("version parameter missing", status = HttpStatusCode.BadRequest) + return@post + } - call.checkPermission(ModelServerPermissionSchema.repository(repositoryId).objects.read) + call.checkPermission(ModelServerPermissionSchema.repository(repositoryId).objects.read) - val expandedNodes = call.receive() + val expandedNodes = call.receive() - val tree = CLVersion.loadFromHash(versionHash, stores.getLegacyObjectStore(repositoryId)).getTree() - val rootNode = PNodeAdapter(ITree.ROOT_ID, TreePointer(tree)) + val tree = CLVersion.loadFromHash(versionHash, stores.getLegacyObjectStore(repositoryId)).getTree() + val rootNode = PNodeAdapter(ITree.ROOT_ID, TreePointer(tree)) - var expandedNodeIds = expandedNodes.expandedNodeIds - if (expandedNodes.expandAll) { - expandedNodeIds = expandedNodeIds + collectExpandableChildNodes(rootNode, expandedNodes.expandedNodeIds.toSet()) - } + var expandedNodeIds = expandedNodes.expandedNodeIds + if (expandedNodes.expandAll) { + expandedNodeIds = expandedNodeIds + collectExpandableChildNodes(rootNode, expandedNodes.expandedNodeIds.toSet()) + } - call.respondText( - buildString { - appendHTML().ul("treeRoot") { - nodeItem(rootNode, expandedNodeIds.toSet()) - } - }, - ) - } - get("/content/repositories/{repository}/versions/{versionHash}/{nodeId}") { - val id = call.parameters["nodeId"]?.toLongOrNull() - ?: return@get call.respondText("node id not found", status = HttpStatusCode.NotFound) + call.respondText( + buildString { + appendHTML().ul("treeRoot") { + nodeItem(rootNode, expandedNodeIds.toSet()) + } + }, + ) + } + get("/content/repositories/{repository}/versions/{versionHash}/{nodeId}") { + val id = call.parameters["nodeId"]?.toLongOrNull() + ?: return@get call.respondText("node id not found", status = HttpStatusCode.NotFound) - val versionHash = call.parameters["versionHash"] - ?: return@get call.respondText("version hash not found", status = HttpStatusCode.NotFound) + val versionHash = call.parameters["versionHash"] + ?: return@get call.respondText("version hash not found", status = HttpStatusCode.NotFound) - val repositoryId = call.parameters["repository"] - ?: return@get call.respondText("repository parameter missing", status = HttpStatusCode.BadRequest) + val repositoryId = call.parameters["repository"] + ?: return@get call.respondText("repository parameter missing", status = HttpStatusCode.BadRequest) - call.checkPermission(ModelServerPermissionSchema.repository(repositoryId).objects.read) + call.checkPermission(ModelServerPermissionSchema.repository(repositoryId).objects.read) - val version = try { - CLVersion.loadFromHash(versionHash, stores.getLegacyObjectStore(RepositoryId(repositoryId))) - } catch (ex: RuntimeException) { - return@get call.respondText("version not found", status = HttpStatusCode.NotFound) - } + val version = try { + CLVersion.loadFromHash(versionHash, stores.getLegacyObjectStore(RepositoryId(repositoryId))) + } catch (ex: RuntimeException) { + return@get call.respondText("version not found", status = HttpStatusCode.NotFound) + } - val node = PNodeAdapter(id, TreePointer(version.getTree())).takeIf { it.isValid } + val node = PNodeAdapter(id, TreePointer(version.getTree())).takeIf { it.isValid } - if (node != null) { - call.respondHtml { body { nodeInspector(node) } } - } else { - call.respondText("node id not found", status = HttpStatusCode.NotFound) + if (node != null) { + call.respondHtml { body { nodeInspector(node) } } + } else { + call.respondText("node id not found", status = HttpStatusCode.NotFound) + } } } } diff --git a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/DiffView.kt b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/DiffView.kt index 6b14058315..8c008c19e9 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/DiffView.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/DiffView.kt @@ -32,6 +32,7 @@ import kotlinx.html.thead import kotlinx.html.tr import org.modelix.authorization.checkPermission import org.modelix.authorization.hasPermission +import org.modelix.authorization.requiresLogin import org.modelix.model.api.BuiltinLanguages import org.modelix.model.api.ITreeChangeVisitorEx import org.modelix.model.lazy.CLTree @@ -67,48 +68,50 @@ class DiffView(private val repositoryManager: RepositoriesManager) { */ fun init(application: Application) { application.routing { - get("/diff") { - val visibleRepositories = repositoryManager.getRepositories().filter { - call.hasPermission(ModelServerPermissionSchema.repository(it).list) - } - call.respondHtmlTemplate(PageWithMenuBar("diff", "..")) { - bodyContent { - buildDiffInputPage(visibleRepositories) + requiresLogin { + get("/diff") { + val visibleRepositories = repositoryManager.getRepositories().filter { + call.hasPermission(ModelServerPermissionSchema.repository(it).list) + } + call.respondHtmlTemplate(PageWithMenuBar("diff", "..")) { + bodyContent { + buildDiffInputPage(visibleRepositories) + } } } - } - get("/diff/view") { - val repoId = - (call.request.queryParameters["repository"])?.let { param -> RepositoryId(param) } ?: throw HttpException( + get("/diff/view") { + val repoId = + (call.request.queryParameters["repository"])?.let { param -> RepositoryId(param) } ?: throw HttpException( + HttpStatusCode.BadRequest, + "missing repository", + ) + call.checkPermission(ModelServerPermissionSchema.repository(repoId).objects.read) + + val oldVersionHash = call.request.queryParameters["oldVersionHash"] ?: throw HttpException( + HttpStatusCode.BadRequest, + "missing oldVersionHash", + ) + val newVersionHash = call.request.queryParameters["newVersionHash"] ?: throw HttpException( HttpStatusCode.BadRequest, - "missing repository", + "missing newVersionHash", + ) + + val sizeLimit = call.request.queryParameters["sizeLimit"]?.let { param -> + param.toIntOrNull() ?: throw HttpException(HttpStatusCode.BadRequest, "invalid sizeLimit") + } ?: DEFAULT_SIZE_LIMIT + + val oldVersion = repositoryManager.getVersion(repoId, oldVersionHash) ?: throw VersionNotFoundException( + oldVersionHash, ) - call.checkPermission(ModelServerPermissionSchema.repository(repoId).objects.read) - - val oldVersionHash = call.request.queryParameters["oldVersionHash"] ?: throw HttpException( - HttpStatusCode.BadRequest, - "missing oldVersionHash", - ) - val newVersionHash = call.request.queryParameters["newVersionHash"] ?: throw HttpException( - HttpStatusCode.BadRequest, - "missing newVersionHash", - ) - - val sizeLimit = call.request.queryParameters["sizeLimit"]?.let { param -> - param.toIntOrNull() ?: throw HttpException(HttpStatusCode.BadRequest, "invalid sizeLimit") - } ?: DEFAULT_SIZE_LIMIT - - val oldVersion = repositoryManager.getVersion(repoId, oldVersionHash) ?: throw VersionNotFoundException( - oldVersionHash, - ) - val newVersion = repositoryManager.getVersion(repoId, newVersionHash) ?: throw VersionNotFoundException( - newVersionHash, - ) - - val diff = calculateDiff(oldVersion, newVersion, sizeLimit) - call.respondHtmlTemplate(PageWithMenuBar("diff", baseUrl)) { - bodyContent { - buildDiffView(diff, oldVersionHash, newVersionHash, repoId.id, sizeLimit) + val newVersion = repositoryManager.getVersion(repoId, newVersionHash) ?: throw VersionNotFoundException( + newVersionHash, + ) + + val diff = calculateDiff(oldVersion, newVersion, sizeLimit) + call.respondHtmlTemplate(PageWithMenuBar("diff", baseUrl)) { + bodyContent { + buildDiffView(diff, oldVersionHash, newVersionHash, repoId.id, sizeLimit) + } } } } diff --git a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/HistoryHandler.kt b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/HistoryHandler.kt index 988eaacc2d..78059cb68b 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/HistoryHandler.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/HistoryHandler.kt @@ -36,6 +36,7 @@ import kotlinx.html.ul import kotlinx.html.unsafe import org.modelix.authorization.checkPermission import org.modelix.authorization.getUserName +import org.modelix.authorization.requiresLogin import org.modelix.model.LinearHistory import org.modelix.model.api.PBranch import org.modelix.model.lazy.BranchReference @@ -66,45 +67,47 @@ class HistoryHandler(private val repositoriesManager: IRepositoriesManager) { get("/history") { call.respondRedirect("../repos/") } - get("/history/{repoId}/{branch}") { - val repositoryId = RepositoryId(call.parameters["repoId"]!!) - val branch = repositoryId.getBranchReference(call.parameters["branch"]!!) - call.checkPermission(ModelServerPermissionSchema.branch(branch).pull) - val params = call.request.queryParameters - val limit = toInt(params["limit"], 500) - val skip = toInt(params["skip"], 0) - val latestVersion = repositoriesManager.getVersion(branch) - checkNotNull(latestVersion) { "Branch not found: $branch" } - call.respondHtmlTemplate(PageWithMenuBar("repos/", "../../..")) { - headContent { - style { - unsafe { - raw( - """ + requiresLogin { + get("/history/{repoId}/{branch}") { + val repositoryId = RepositoryId(call.parameters["repoId"]!!) + val branch = repositoryId.getBranchReference(call.parameters["branch"]!!) + call.checkPermission(ModelServerPermissionSchema.branch(branch).pull) + val params = call.request.queryParameters + val limit = toInt(params["limit"], 500) + val skip = toInt(params["skip"], 0) + val latestVersion = repositoriesManager.getVersion(branch) + checkNotNull(latestVersion) { "Branch not found: $branch" } + call.respondHtmlTemplate(PageWithMenuBar("repos/", "../../..")) { + headContent { + style { + unsafe { + raw( + """ body { font-family: sans-serif; } - """.trimIndent(), - ) + """.trimIndent(), + ) + } } + repositoryPageStyle() + } + bodyContent { + buildRepositoryPage(branch, latestVersion, params["head"], skip, limit) } - repositoryPageStyle() - } - bodyContent { - buildRepositoryPage(branch, latestVersion, params["head"], skip, limit) } } - } - post("/history/{repoId}/{branch}/revert") { - val repositoryId = RepositoryId(call.parameters["repoId"]!!) - val branch = repositoryId.getBranchReference(call.parameters["branch"]!!) - call.checkPermission(ModelServerPermissionSchema.branch(branch).write) - val params = call.receiveParameters() - val fromVersion = params["from"]!! - val toVersion = params["to"]!! - val user = getUserName() - revert(branch, fromVersion, toVersion, user) - call.respondRedirect(".") + post("/history/{repoId}/{branch}/revert") { + val repositoryId = RepositoryId(call.parameters["repoId"]!!) + val branch = repositoryId.getBranchReference(call.parameters["branch"]!!) + call.checkPermission(ModelServerPermissionSchema.branch(branch).write) + val params = call.receiveParameters() + val fromVersion = params["from"]!! + val toVersion = params["to"]!! + val user = getUserName() + revert(branch, fromVersion, toVersion, user) + call.respondRedirect(".") + } } // post("/history/{repoId}/{branch}/delete") { // val repositoryId = call.parameters["repoId"]!! diff --git a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/RepositoryOverview.kt b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/RepositoryOverview.kt index 80593fef74..0bce40a287 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/RepositoryOverview.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/handlers/ui/RepositoryOverview.kt @@ -26,6 +26,7 @@ import kotlinx.html.title import kotlinx.html.tr import kotlinx.html.unsafe import org.modelix.authorization.hasPermission +import org.modelix.authorization.requiresLogin import org.modelix.model.lazy.RepositoryId import org.modelix.model.server.ModelServerPermissionSchema import org.modelix.model.server.handlers.IRepositoriesManager @@ -35,13 +36,14 @@ class RepositoryOverview(private val repoManager: IRepositoriesManager) { fun init(application: Application) { application.routing { - get("/repos") { - call.respondHtmlTemplate(PageWithMenuBar("repos/", "..")) { - headContent { - title("Repositories") - script(type = "text/javascript") { - unsafe { - +""" + requiresLogin { + get("/repos") { + call.respondHtmlTemplate(PageWithMenuBar("repos/", "..")) { + headContent { + title("Repositories") + script(type = "text/javascript") { + unsafe { + +""" function removeBranch(repository, branch) { if (confirm('Are you sure you want to delete the branch ' + branch + ' of repository ' +repository + '?')) { fetch('../v2/repositories/' + repository + '/branches/' + branch, { method: 'DELETE'}) @@ -55,11 +57,12 @@ class RepositoryOverview(private val repoManager: IRepositoriesManager) { .then( _ => location.reload()) } } - """.trimIndent() + """.trimIndent() + } } } + bodyContent { buildMainPage(call) } } - bodyContent { buildMainPage(call) } } } } diff --git a/model-server/src/test/kotlin/org/modelix/model/server/LazyLoadingTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/LazyLoadingTest.kt index d513fc1c16..92884b5cd2 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/LazyLoadingTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/LazyLoadingTest.kt @@ -2,7 +2,6 @@ package org.modelix.model.server import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication -import org.modelix.authorization.installAuthentication import org.modelix.model.api.INode import org.modelix.model.api.NullChildLink import org.modelix.model.api.PBranch @@ -39,7 +38,6 @@ class LazyLoadingTest { private fun runTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() val store = InMemoryStoreClient() val repoManager = RepositoriesManager(store) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/ModelClientTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/ModelClientTest.kt index 16a26f6737..ec0e9706a6 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/ModelClientTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/ModelClientTest.kt @@ -1,13 +1,9 @@ package org.modelix.model.server -import io.ktor.server.application.install -import io.ktor.server.resources.Resources -import io.ktor.server.routing.IgnoreTrailingSlash import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout -import org.modelix.authorization.installAuthentication import org.modelix.model.IKeyListener import org.modelix.model.client.RestWebModelClient import org.modelix.model.server.handlers.KeyValueLikeModelServer @@ -24,9 +20,7 @@ class ModelClientTest { private fun runTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { application { - installAuthentication(unitTestMode = true) - install(Resources) - install(IgnoreTrailingSlash) + installDefaultServerPlugins() KeyValueLikeModelServer(RepositoriesManager(InMemoryStoreClient())).init(this) } block() diff --git a/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt b/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt index 01136d5bb3..ac09c332a5 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/ModelServerTestUtil.kt @@ -13,7 +13,6 @@ import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.websocket.WebSockets import kotlinx.coroutines.runBlocking import org.modelix.authorization.ModelixAuthorization -import org.modelix.authorization.installAuthentication import org.modelix.model.client2.ModelClientV2 import org.modelix.model.server.Main.installStatusPages import org.modelix.model.server.handlers.Paths.registerJsonTypes @@ -55,7 +54,6 @@ fun runWithNettyServer( testBlock: suspend (server: NettyApplicationEngine) -> Unit, ) { val nettyServer: NettyApplicationEngine = io.ktor.server.engine.embeddedServer(Netty, port = 0) { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() setupBlock(this) } diff --git a/model-server/src/test/kotlin/org/modelix/model/server/PermissionsTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/PermissionsTest.kt index 77f9e7e62d..65b7e79560 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/PermissionsTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/PermissionsTest.kt @@ -1,6 +1,6 @@ package org.modelix.model.server -import com.auth0.jwt.JWTCreator +import com.nimbusds.jwt.JWTClaimsSet import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens @@ -19,6 +19,7 @@ import org.modelix.model.server.handlers.ModelReplicationServer import org.modelix.model.server.handlers.RepositoriesManager import org.modelix.model.server.store.InMemoryStoreClient import java.time.Instant +import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails @@ -62,7 +63,7 @@ class PermissionsTest { return createModelClient(grantedPermissions = grantedPermissions.toList()) } - suspend fun ApplicationTestBuilder.createModelClient(grantedPermissions: List, hmac512key: String = jwtSignatureKey, tokenModifier: (JWTCreator.Builder) -> Unit = {}): ModelClientV2 { + suspend fun ApplicationTestBuilder.createModelClient(grantedPermissions: List, hmac512key: String = jwtSignatureKey, tokenModifier: (JWTClaimsSet.Builder) -> Unit = {}): ModelClientV2 { val url = "http://localhost/v2" return ModelClientV2.builder().url(url).client( client.config { @@ -323,7 +324,7 @@ class PermissionsTest { fun `cannot create repository with expired token`() = runTest { val repoId = RepositoryId("repo1") val client = createModelClient(grantedPermissions = listOf(ModelServerPermissionSchema.repository(repoId).write)) { jwt -> - jwt.withExpiresAt(Instant.now().minusSeconds(300)) + jwt.expirationTime(Date(Instant.now().minusSeconds(300).toEpochMilli())) } assertUnauthorized { @@ -345,7 +346,7 @@ class PermissionsTest { fun `can create repository with non-expired token`() = runTest { val repoId = RepositoryId("repo1") val client = createModelClient(grantedPermissions = listOf(ModelServerPermissionSchema.repository(repoId).write)) { jwt -> - jwt.withExpiresAt(Instant.now().plusSeconds(10)) + jwt.expirationTime(Date(Instant.now().plusSeconds(10).toEpochMilli())) } client.initRepository(repoId) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/PullPerformanceTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/PullPerformanceTest.kt index 1035fddb7b..502434a48f 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/PullPerformanceTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/PullPerformanceTest.kt @@ -3,7 +3,6 @@ package org.modelix.model.server import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication import kotlinx.coroutines.coroutineScope -import org.modelix.authorization.installAuthentication import org.modelix.model.api.IChildLink import org.modelix.model.api.IConceptReference import org.modelix.model.api.INode @@ -25,7 +24,6 @@ class PullPerformanceTest { val storeClientWithStatistics = StoreClientWithStatistics(InMemoryStoreClient()) val repositoriesManager = RepositoriesManager(storeClientWithStatistics) application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() ModelReplicationServer(repositoriesManager).init(this) KeyValueLikeModelServer(repositoriesManager).init(this) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/ReplicatedModelTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/ReplicatedModelTest.kt index 8ec240b2a3..4402fd0d93 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/ReplicatedModelTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/ReplicatedModelTest.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.junit.Assert.assertFalse -import org.modelix.authorization.installAuthentication import org.modelix.model.api.ChildLinkFromName import org.modelix.model.api.ConceptReference import org.modelix.model.api.IBranch @@ -117,7 +116,6 @@ class ReplicatedModelTest { private fun runTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() val repoManager = RepositoriesManager(InMemoryStoreClient()) ModelReplicationServer(repoManager).init(this) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/ReplicatedRepositoryTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/ReplicatedRepositoryTest.kt index 99f295387e..46ccde30aa 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/ReplicatedRepositoryTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/ReplicatedRepositoryTest.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.junit.jupiter.api.RepeatedTest import org.junit.jupiter.api.RepetitionInfo -import org.modelix.authorization.installAuthentication import org.modelix.model.ModelFacade import org.modelix.model.VersionMerger import org.modelix.model.api.IBranch @@ -51,7 +50,6 @@ class ReplicatedRepositoryTest { private fun runTest(block: suspend ApplicationTestBuilder.(scope: CoroutineScope) -> Unit) = testApplication { application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() val storeClient = InMemoryStoreClient() val repositoriesManager = RepositoriesManager(storeClient) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/V1ApiTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/V1ApiTest.kt index 5bbbacd0fa..99c8fd7524 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/V1ApiTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/V1ApiTest.kt @@ -14,7 +14,6 @@ import io.ktor.server.testing.testApplication import kotlinx.coroutines.async import kotlinx.coroutines.sync.Mutex import org.junit.jupiter.api.Test -import org.modelix.authorization.installAuthentication import org.modelix.model.server.handlers.KeyValueLikeModelServer import org.modelix.model.server.handlers.RepositoriesManager import org.modelix.model.server.store.InMemoryStoreClient @@ -27,7 +26,6 @@ class V1ApiTest { val repositoriesManager = RepositoriesManager(InMemoryStoreClient()) application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() KeyValueLikeModelServer(repositoriesManager).init(this) } diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/HealthApiTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/HealthApiTest.kt index 7bf50f8d5a..eb7a6742b0 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/handlers/HealthApiTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/HealthApiTest.kt @@ -11,7 +11,6 @@ import io.mockk.every import io.mockk.spyk import kotlinx.serialization.json.Json import org.junit.jupiter.api.Test -import org.modelix.authorization.installAuthentication import org.modelix.model.server.installDefaultServerPlugins import org.modelix.model.server.store.InMemoryStoreClient import kotlin.test.AfterTest @@ -24,7 +23,6 @@ class HealthApiTest { private fun runApiTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() routing { healthApiSpy.installRoutes(this) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/KeyValueLikeModelServerTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/KeyValueLikeModelServerTest.kt index fa1789ca15..101cada62b 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/handlers/KeyValueLikeModelServerTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/KeyValueLikeModelServerTest.kt @@ -10,7 +10,6 @@ import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -import org.modelix.authorization.installAuthentication import org.modelix.model.client.RestWebModelClient import org.modelix.model.client2.runWrite import org.modelix.model.lazy.CLVersion @@ -31,7 +30,6 @@ class KeyValueLikeModelServerTest { val repositoriesManager = RepositoriesManager(store) application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() KeyValueLikeModelServer(repositoriesManager).init(this) ModelReplicationServer(repositoriesManager).init(this) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerBackwardsCompatibilityTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerBackwardsCompatibilityTest.kt index fcb11a351f..7071161ef4 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerBackwardsCompatibilityTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerBackwardsCompatibilityTest.kt @@ -4,7 +4,6 @@ import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope -import org.modelix.authorization.installAuthentication import org.modelix.model.client.RestWebModelClient import org.modelix.model.client2.ModelClientV2 import org.modelix.model.lazy.RepositoryId @@ -24,7 +23,6 @@ class ModelReplicationServerBackwardsCompatibilityTest { val modelReplicationServer = ModelReplicationServer(repositoriesManager) val keyValueLikeModelServer = KeyValueLikeModelServer(repositoriesManager) application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() modelReplicationServer.init(this) keyValueLikeModelServer.init(this) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerTest.kt index 74ad071fed..357ae98750 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ModelReplicationServerTest.kt @@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout -import org.modelix.authorization.installAuthentication import org.modelix.model.api.IConceptReference import org.modelix.model.client2.ModelClientV2 import org.modelix.model.client2.readVersionDelta @@ -82,7 +81,6 @@ class ModelReplicationServerTest { block: suspend ApplicationTestBuilder.(scope: CoroutineScope, fixture: Fixture) -> Unit, ) = testApplication { application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() fixture.modelReplicationServer.init(this) IdsApiImpl(fixture.repositoriesManager).init(this) diff --git a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/IndexPageTest.kt b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/IndexPageTest.kt index d5fa91374f..e93720c1eb 100644 --- a/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/IndexPageTest.kt +++ b/model-server/src/test/kotlin/org/modelix/model/server/handlers/ui/IndexPageTest.kt @@ -4,7 +4,6 @@ import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication -import org.modelix.authorization.installAuthentication import org.modelix.model.client.successful import org.modelix.model.server.installDefaultServerPlugins import kotlin.test.Test @@ -14,7 +13,6 @@ class IndexPageTest { private fun runTest(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { application { - installAuthentication(unitTestMode = true) installDefaultServerPlugins() IndexPage().init(this) } diff --git a/model-server/src/test/kotlin/permissions/AdminPermissionOnServerTest.kt b/model-server/src/test/kotlin/permissions/AdminPermissionOnServerTest.kt index 88530f7f22..858c71fa53 100644 --- a/model-server/src/test/kotlin/permissions/AdminPermissionOnServerTest.kt +++ b/model-server/src/test/kotlin/permissions/AdminPermissionOnServerTest.kt @@ -17,8 +17,6 @@ class AdminPermissionOnServerTest : PermissionTestBase(listOf(ModelServerPermiss "legacy-user-defined-entries/read", "legacy-user-defined-entries/write", "model-server/admin", - "permission-schema/read", - "permission-schema/write", "repository/my-repo/admin", "repository/my-repo/branch/my-branch/admin", "repository/my-repo/branch/my-branch/create", diff --git a/model-server/src/test/kotlin/permissions/PermissionTestBase.kt b/model-server/src/test/kotlin/permissions/PermissionTestBase.kt index e48cad27ff..a7c4702b54 100644 --- a/model-server/src/test/kotlin/permissions/PermissionTestBase.kt +++ b/model-server/src/test/kotlin/permissions/PermissionTestBase.kt @@ -1,24 +1,12 @@ package permissions -import com.auth0.jwt.JWT -import com.auth0.jwt.algorithms.Algorithm -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject import org.modelix.authorization.permissions.PermissionEvaluator import org.modelix.authorization.permissions.PermissionParts import org.modelix.authorization.permissions.Schema import org.modelix.authorization.permissions.SchemaInstance import org.modelix.model.server.ModelServerPermissionSchema -import java.nio.charset.StandardCharsets -import java.util.Base64 abstract class PermissionTestBase(private val explicitlyGrantedPermissions: List, val schema: Schema = ModelServerPermissionSchema.SCHEMA) { - val token = JWT.create() - .withClaim("permissions", explicitlyGrantedPermissions.map { it.toString() }) - .sign(Algorithm.HMAC256("my-secret-key-8774567")) - .let { JWT.decode(it) } - val payloadJson = String(Base64.getUrlDecoder().decode(token.payload), StandardCharsets.UTF_8) - .let { Json.parseToJsonElement(it).jsonObject } val evaluator = PermissionEvaluator(SchemaInstance(schema)).also { evaluator -> explicitlyGrantedPermissions.forEach { evaluator.grantPermission(it) } } diff --git a/model-server/src/test/kotlin/permissions/UnknownPermissionGrantTest.kt b/model-server/src/test/kotlin/permissions/UnknownPermissionGrantTest.kt new file mode 100644 index 0000000000..7ec6869a0d --- /dev/null +++ b/model-server/src/test/kotlin/permissions/UnknownPermissionGrantTest.kt @@ -0,0 +1,20 @@ +package permissions + +import org.modelix.authorization.permissions.PermissionEvaluator +import org.modelix.authorization.permissions.PermissionParts +import org.modelix.authorization.permissions.SchemaInstance +import org.modelix.model.server.ModelServerPermissionSchema +import kotlin.test.Test + +class UnknownPermissionGrantTest { + /** + * A token may contain granted permission of other services. They should not result in an exception. + */ + @Test + fun `unknown permission in token is ignored`() { + val evaluator = PermissionEvaluator(SchemaInstance(ModelServerPermissionSchema.SCHEMA)) + for (i in 0..5) { + evaluator.grantPermission(PermissionParts(ModelServerPermissionSchema.repository("myFirstRepo").branch("main").push.parts.take(i)) + "some-non-existent-permission") + } + } +}