Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MODELIX-1042 authorization for workspaces #1190

Merged
merged 20 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8179c75
fix(authorization): MODELIX_PERMISSION_CHECKS_ENABLED wasn't applied …
slisson Nov 13, 2024
9d9a8bd
feat(authorization): support for RSA keys
slisson Nov 14, 2024
e4b5415
fix(authorization): ignore unknown granted permissions
slisson Nov 16, 2024
31c2c16
feat(authorization): support for identity tokens
slisson Nov 20, 2024
165d453
fix(authorization): trust own tokens (also add public key when adding…
slisson Nov 20, 2024
be8bd81
fix(authorization): publish sources jar
slisson Nov 20, 2024
35a5518
fix(authorization)!: remove unused keycloak based authorization
slisson Nov 20, 2024
16a31f3
feat(authorization): option to install status pages
slisson Nov 20, 2024
5aeb53b
feat(authorization): built-in permission management
slisson Nov 21, 2024
693f3bd
fix(model-server): /history /content /diff and /repos always returned…
slisson Nov 25, 2024
ff08972
fix(model-server): RestWebModelClient couldn't request a clientId
slisson Nov 25, 2024
71dc994
fix(authorization)!: remove unused keycloak based authorization
slisson Nov 25, 2024
dd1fe1d
fix(model-server): make modelix-admin also model-server admin
slisson Nov 25, 2024
01812f5
fix(model-datastructure): implement IKVEntryReference.toString
slisson Nov 25, 2024
ce06948
fix(authorization): also install status page handler for Throwable
slisson Nov 27, 2024
a6bf536
docs(authorization): some more documentation after review
slisson Dec 5, 2024
2201fa0
chore(authorization): deduplicate constant strings
slisson Dec 11, 2024
64053df
fix(authorization): cache remote keys
slisson Dec 11, 2024
11c6d46
chore(authorization): move extractUserId to companion object
slisson Dec 11, 2024
817f454
fix(authorization): allow only resource owners/admins to manage permi…
slisson Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions authorization/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<MavenPublication>("maven") {
from(components["kotlin"])
from(components["java"])
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
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)

Check warning

Code scanning / detekt

The function getUserName is missing documentation. Warning

The function getUserName is missing documentation.

override fun equals(other: Any?): Boolean {
if (other !is AccessTokenPrincipal) return false
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { }

Expand All @@ -36,6 +36,16 @@
*/
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
odzhychko marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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
Expand All @@ -57,42 +67,65 @@
*/
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?
odzhychko marked this conversation as resolved.
Show resolved Hide resolved

/**
* In addition to JWKS URLs you can directly provide keys for verification of tokens sent in requests to
* this server.
*/
fun addForeignPublicKey(key: JWK)
odzhychko marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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?

/**
* Defines the available permissions and their relations.
*/
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
languitar marked this conversation as resolved.
Show resolved Hide resolved
* to write changes to disk.
*/
var accessControlPersistence: IAccessControlPersistence

/**
* Generates fake tokens and allows all requests.
*/
fun configureForUnitTests()
}

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<JWK>()
odzhychko marked this conversation as resolved.
Show resolved Hide resolved
override var jwkUri: URI? = null
odzhychko marked this conversation as resolved.
Show resolved Hide resolved
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")
languitar marked this conversation as resolved.
Show resolved Hide resolved
?.let { path -> FileSystemAccessControlPersistence(File(path)) }
?: InMemoryAccessControlPersistence()

private val hmac512KeyFromEnv by lazy {
System.getenv("MODELIX_JWT_SIGNATURE_HMAC512_KEY")
Expand All @@ -107,58 +140,35 @@
?: 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 {

Check warning

Code scanning / detekt

The property jwtUtil is missing documentation. Warning

The property jwtUtil is missing documentation.
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<Pair<String, JWSAlgorithm>>(
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? {
Expand All @@ -178,17 +188,21 @@
*
* 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")
}

Check warning

Code scanning / detekt

Companion is missing required documentation. Warning

Companion is missing required documentation.
}

fun Application.getModelixAuthorizationConfig(): ModelixAuthorizationConfig {
Expand All @@ -203,6 +217,37 @@
}
}

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 {

Check warning

Code scanning / detekt

The function ensureMinSecretLength is missing documentation. Warning

The function ensureMinSecretLength is missing documentation.
val secret = this
when (algorithm) {
JWSAlgorithm.HS512 -> {
if (secret.size < 512) {

Check warning

Code scanning / detekt

This expression contains a magic number. Consider defining it to a well named constant. Warning

This expression contains a magic number. Consider defining it to a well named constant.
val digest = MessageDigest.getInstance("SHA-512")
digest.update(secret)
return digest.digest()
}
}
JWSAlgorithm.HS384 -> {
if (secret.size < 384) {

Check warning

Code scanning / detekt

This expression contains a magic number. Consider defining it to a well named constant. Warning

This expression contains a magic number. Consider defining it to a well named constant.
val digest = MessageDigest.getInstance("SHA-384")
digest.update(secret)
return digest.digest()
}
}
JWSAlgorithm.HS256 -> {
if (secret.size < 256) {

Check warning

Code scanning / detekt

This expression contains a magic number. Consider defining it to a well named constant. Warning

This expression contains a magic number. Consider defining it to a well named constant.
val digest = MessageDigest.getInstance("SHA-256")
digest.update(secret)
return digest.digest()
}
}
}
return secret
}
Loading
Loading