Skip to content

Commit 5aeb53b

Browse files
committed
feat(authorization): built-in permission management
At `/permissions/manage` users can grant permissions to other users based on the user ID from the JWT identity token. The data is persisted to the file specified in the environment variable `MODELIX_ACCESS_CONTROL_FILE`.
1 parent 16a31f3 commit 5aeb53b

File tree

9 files changed

+558
-10
lines changed

9 files changed

+558
-10
lines changed

authorization/src/main/kotlin/org/modelix/authorization/AuthorizationConfig.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import com.nimbusds.jose.JWSAlgorithm
55
import com.nimbusds.jose.jwk.JWK
66
import io.ktor.server.application.Application
77
import io.ktor.server.application.plugin
8+
import org.modelix.authorization.permissions.FileSystemAccessControlPersistence
9+
import org.modelix.authorization.permissions.IAccessControlPersistence
10+
import org.modelix.authorization.permissions.InMemoryAccessControlPersistence
811
import org.modelix.authorization.permissions.Schema
912
import org.modelix.authorization.permissions.buildPermissionSchema
1013
import java.io.File
@@ -33,6 +36,11 @@ interface IModelixAuthorizationConfig {
3336
*/
3437
var debugEndpointsEnabled: Boolean
3538

39+
/**
40+
* At /permissions/manage users can grant permissions to identity tokens.
41+
*/
42+
var permissionManagementEnabled: Boolean
43+
3644
/**
3745
* NotLoggedInException and NoPermissionException will be turned into HTTP status codes 401 and 403
3846
*/
@@ -87,7 +95,7 @@ interface IModelixAuthorizationConfig {
8795
*/
8896
var permissionSchema: Schema
8997

90-
var accessControlDataProvider: IAccessControlDataProvider
98+
var accessControlPersistence: IAccessControlPersistence
9199

92100
/**
93101
* Generates fake tokens and allows all requests.
@@ -99,6 +107,7 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig {
99107
override var permissionChecksEnabled: Boolean? = PERMISSION_CHECKS_ENABLED
100108
override var generateFakeTokens: Boolean? = getBooleanFromEnv("MODELIX_GENERATE_FAKE_JWT")
101109
override var debugEndpointsEnabled: Boolean = true
110+
override var permissionManagementEnabled: Boolean = true
102111
override var installStatusPages: Boolean = false
103112
override var hmac512Key: String? = null
104113
override var hmac384Key: String? = null
@@ -113,7 +122,9 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig {
113122
}
114123
override var jwkKeyId: String? = System.getenv("MODELIX_JWK_KEY_ID")
115124
override var permissionSchema: Schema = buildPermissionSchema { }
116-
override var accessControlDataProvider: IAccessControlDataProvider = EmptyAccessControlDataProvider()
125+
override var accessControlPersistence: IAccessControlPersistence = System.getenv("MODELIX_ACCESS_CONTROL_FILE")
126+
?.let { path -> FileSystemAccessControlPersistence(File(path)) }
127+
?: InMemoryAccessControlPersistence()
117128

118129
private val hmac512KeyFromEnv by lazy {
119130
System.getenv("MODELIX_JWT_SIGNATURE_HMAC512_KEY")
@@ -131,7 +142,7 @@ class ModelixAuthorizationConfig : IModelixAuthorizationConfig {
131142
val jwtUtil: ModelixJWTUtil by lazy {
132143
val util = ModelixJWTUtil()
133144

134-
util.accessControlDataProvider = accessControlDataProvider
145+
util.accessControlDataProvider = accessControlPersistence
135146
util.loadKeysFromEnvironment()
136147

137148
listOfNotNull<Pair<String, JWSAlgorithm>>(

authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import io.ktor.util.AttributeKey
3434
import org.modelix.authorization.permissions.PermissionEvaluator
3535
import org.modelix.authorization.permissions.PermissionParts
3636
import org.modelix.authorization.permissions.SchemaInstance
37+
import java.nio.charset.StandardCharsets
38+
import java.util.Base64
39+
import java.util.Collections
3740
import java.util.concurrent.TimeUnit
3841

3942
private val LOG = mu.KotlinLogging.logger { }
@@ -110,10 +113,10 @@ object ModelixAuthorization : BaseRouteScopedPlugin<IModelixAuthorizationConfig,
110113
}
111114
}
112115

113-
if (config.debugEndpointsEnabled) {
114-
application.routing {
115-
authenticate(MODELIX_JWT_AUTH) {
116-
(installedIntoRoute ?: this).apply {
116+
application.routing {
117+
authenticate(MODELIX_JWT_AUTH) {
118+
(installedIntoRoute ?: this).apply {
119+
if (config.debugEndpointsEnabled) {
117120
get("/user") {
118121
val jwt = call.principal<AccessTokenPrincipal>()?.jwt ?: call.jwtFromHeaders()
119122
if (jwt == null) {
@@ -144,9 +147,13 @@ object ModelixAuthorization : BaseRouteScopedPlugin<IModelixAuthorizationConfig,
144147
}
145148
}
146149
}
150+
if (config.permissionManagementEnabled) {
151+
installPermissionManagementHandlers()
152+
}
147153
}
148154
}
149155
}
156+
150157
val pluginInstance = ModelixAuthorizationPluginInstance(config)
151158
return pluginInstance
152159
}
@@ -155,16 +162,36 @@ object ModelixAuthorization : BaseRouteScopedPlugin<IModelixAuthorizationConfig,
155162
}
156163

157164
class ModelixAuthorizationPluginInstance(val config: ModelixAuthorizationConfig) {
165+
166+
private val deniedPermissionRequests: MutableSet<DeniedPermissionRequest> = Collections.synchronizedSet(LinkedHashSet())
158167
private val permissionCache = CacheBuilder.newBuilder()
159168
.expireAfterWrite(5, TimeUnit.SECONDS)
160169
.build<Pair<AccessTokenPrincipal, PermissionParts>, Boolean>()
161170

171+
fun getDeniedPermissions(): Set<DeniedPermissionRequest> = deniedPermissionRequests.toSet()
172+
162173
fun hasPermission(call: ApplicationCall, permissionToCheck: PermissionParts): Boolean {
163174
if (!config.permissionCheckingEnabled()) return true
164175

165176
val principal = call.principal<AccessTokenPrincipal>() ?: throw NotLoggedInException()
166177
return permissionCache.get(principal to permissionToCheck) {
167-
getPermissionEvaluator(principal).hasPermission(permissionToCheck)
178+
getPermissionEvaluator(principal).hasPermission(permissionToCheck).also { granted ->
179+
if (!granted) {
180+
val userId = principal.getUserName()
181+
if (userId != null) {
182+
synchronized(deniedPermissionRequests) {
183+
deniedPermissionRequests += DeniedPermissionRequest(
184+
permissionId = permissionToCheck,
185+
userId = userId,
186+
jwtPayload = principal.jwt.payload,
187+
)
188+
while (deniedPermissionRequests.size >= 100) {
189+
deniedPermissionRequests.iterator().also { it.next() }.remove()
190+
}
191+
}
192+
}
193+
}
194+
}
168195
}
169196
}
170197

@@ -191,6 +218,14 @@ class ModelixAuthorizationPluginInstance(val config: ModelixAuthorizationConfig)
191218
}
192219
}
193220

221+
data class DeniedPermissionRequest(
222+
val permissionId: PermissionParts,
223+
val userId: String,
224+
val jwtPayload: String,
225+
) {
226+
fun jwtPayloadJson() = String(Base64.getUrlDecoder().decode(jwtPayload), StandardCharsets.UTF_8)
227+
}
228+
194229
/**
195230
* Returns an [JWTVerifier] that wraps our common authorization logic,
196231
* so that it can be configured in the verification with Ktor's JWT authorization.

authorization/src/main/kotlin/org/modelix/authorization/ModelixJWTUtil.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ class ModelixJWTUtil {
149149
return JWSObject(header, payload).also { it.sign(signer) }.serialize()
150150
}
151151

152+
fun isAccessToken(token: DecodedJWT): Boolean {
153+
return extractPermissions(token) != null
154+
}
155+
156+
fun isIdentityToken(token: DecodedJWT): Boolean {
157+
return !isAccessToken(token)
158+
}
159+
152160
fun createPermissionEvaluator(token: DecodedJWT, schema: Schema): PermissionEvaluator {
153161
return createPermissionEvaluator(token, SchemaInstance(schema))
154162
}
@@ -157,8 +165,12 @@ class ModelixJWTUtil {
157165
return PermissionEvaluator(schema).also { loadGrantedPermissions(token, it) }
158166
}
159167

168+
fun extractPermissions(token: DecodedJWT): List<String>? {
169+
return token.claims["permissions"]?.asList(String::class.java)
170+
}
171+
160172
fun loadGrantedPermissions(token: DecodedJWT, evaluator: PermissionEvaluator) {
161-
val permissions = token.claims["permissions"]?.asList(String::class.java)
173+
val permissions = extractPermissions(token)
162174

163175
// There is a difference between access tokens and identity tokens.
164176
// An identity token just contains the user ID and the service has to know the granted permissions.

0 commit comments

Comments
 (0)