diff --git a/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt b/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt index a040fa2a82..eb96c26252 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/AuthorizationPlugin.kt @@ -15,13 +15,13 @@ import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.BaseRouteScopedPlugin import io.ktor.server.application.call import io.ktor.server.application.install +import io.ktor.server.application.plugin import io.ktor.server.auth.Authentication import io.ktor.server.auth.AuthenticationContext import io.ktor.server.auth.AuthenticationProvider import io.ktor.server.auth.authenticate 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 @@ -31,14 +31,15 @@ import io.ktor.server.routing.application import io.ktor.server.routing.get import io.ktor.server.routing.routing import io.ktor.util.AttributeKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch 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 org.modelix.authorization.permissions.recordKnownRoles +import org.modelix.authorization.permissions.recordKnownUser import java.util.concurrent.TimeUnit private val LOG = mu.KotlinLogging.logger { } @@ -85,7 +86,18 @@ object ModelixAuthorization : BaseRouteScopedPlugin + application.launch(Dispatchers.IO) { + val accessControlPersistence = authConfig.accessControlPersistence + accessControlPersistence.recordKnownUser(authConfig.jwtUtil.extractUserId(jwt)) + accessControlPersistence.recordKnownRoles(authConfig.jwtUtil.extractUserRoles(jwt)) + } + } + ?.let(::AccessTokenPrincipal) } catch (e: Exception) { LOG.warn(e) { "Failed to read JWT token" } null @@ -146,11 +158,6 @@ object ModelixAuthorization : BaseRouteScopedPlugin = Collections.synchronizedSet(LinkedHashSet()) private val permissionCache = CacheBuilder.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .build, Boolean>() - fun getDeniedPermissions(): Set = deniedPermissionRequests.toSet() - fun hasPermission(call: ApplicationCall, permissionToCheck: PermissionParts): Boolean { return hasPermission(call, PermissionParser(config.permissionSchema).parse(permissionToCheck)) } @@ -184,23 +188,7 @@ class ModelixAuthorizationPluginInstance(val config: ModelixAuthorizationConfig) val principal = call.principal() ?: throw NotLoggedInException() return permissionCache.get(principal to 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() - } - } - } - } - } + getPermissionEvaluator(principal).hasPermission(permissionToCheck) } } @@ -227,14 +215,6 @@ class ModelixAuthorizationPluginInstance(val config: ModelixAuthorizationConfig) } } -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/PermissionManagementPage.kt b/authorization/src/main/kotlin/org/modelix/authorization/PermissionManagementPage.kt index 7a7ce86898..005577db69 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/PermissionManagementPage.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/PermissionManagementPage.kt @@ -1,5 +1,6 @@ package org.modelix.authorization +import io.ktor.http.encodeURLPathPart import io.ktor.server.application.ApplicationCall import io.ktor.server.application.application import io.ktor.server.application.call @@ -8,17 +9,25 @@ 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.response.respondRedirect 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 io.ktor.util.pipeline.PipelineContext +import kotlinx.html.FlowContent +import kotlinx.html.a import kotlinx.html.body import kotlinx.html.br +import kotlinx.html.dataList import kotlinx.html.div import kotlinx.html.h1 +import kotlinx.html.h2 import kotlinx.html.head import kotlinx.html.hiddenInput +import kotlinx.html.id +import kotlinx.html.li +import kotlinx.html.option import kotlinx.html.postForm import kotlinx.html.style import kotlinx.html.submitInput @@ -27,222 +36,299 @@ import kotlinx.html.td import kotlinx.html.textInput import kotlinx.html.th import kotlinx.html.tr +import kotlinx.html.ul +import kotlinx.html.unsafe import org.modelix.authorization.permissions.PermissionInstanceReference import org.modelix.authorization.permissions.PermissionParser +import org.modelix.authorization.permissions.PermissionParts +import org.modelix.authorization.permissions.PermissionSchemaBase +import org.modelix.authorization.permissions.ResourceInstanceReference +import org.modelix.authorization.permissions.SchemaInstance fun Route.installPermissionManagementHandlers() { route("permissions") { - get("manage") { + get("/") { call.respondHtml { - buildPermissionManagementPage(call, application.plugin(ModelixAuthorization)) + body { + ul { + li { a(href = "schema") { +"Schema" } } + li { a(href = "resources/") { +"Resources" } } + } + } + } + } + get("schema") { + call.respondHtml { + buildSchemaPage(call.application.plugin(ModelixAuthorization).config.permissionSchema) + } + } + route("resources") { + get("/") { + val schemaInstance = call.application.plugin(ModelixAuthorization).createSchemaInstance() + val rootResources = schemaInstance.resources.values.filter { it.parent == null } + call.respondHtml { + body { + h1 { +"Resources" } + ul { + for (resourceInstance in rootResources) { + val resourceId = resourceInstance.reference.toPermissionParts().fullId + li { + a(href = "$resourceId/") { + +resourceId + } + } + } + } + } + } + } + route("{resourceId}") { + fun PipelineContext<*, ApplicationCall>.resourceId(): String = call.parameters["resourceId"]!! + get("/") { + val resourceId = resourceId() + val plugin = call.application.plugin(ModelixAuthorization) + val schemaInstance = plugin.createSchemaInstance() + val childResources = schemaInstance.resources.values.filter { it.parent?.reference?.toPermissionParts()?.fullId == resourceId } + val resourceRef = requireNotNull(PermissionParser(schemaInstance.schema).parseResource(PermissionParts.fromString(resourceId))) { "Unknown resource: $resourceId" } + val parentResourceRef = resourceRef.parent + val permissions = schemaInstance.instantiateResource(resourceRef).permissions.values + .map { it to it.transitiveIncludes().size } + .sortedByDescending { it.second } + .map { it.first } + val accessControlData = plugin.config.accessControlPersistence.read() + val permissionToUsers = accessControlData.grantsToUsers.inverse() + val permissionToRoles = accessControlData.grantsToRoles.inverse() + call.respondHtml { + head { + style { + unsafe { + //language=CSS + +""" + table { + border: 1px solid #ccc; + border-collapse: collapse; + } + td, th { + border: 1px solid #ccc; + padding: 3px 12px; + } + """.trimIndent() + } + } + } + body { + dataList { + id = "knownUsers" + for (userId in plugin.config.accessControlPersistence.read().knownUsers.sorted()) { + option { value = userId } + } + } + dataList { + id = "knownRoles" + for (roleId in plugin.config.accessControlPersistence.read().knownRoles.sorted()) { + option { value = roleId } + } + } + + div { + a(href = "../") { +"Root Resources" } + } + + h1 { +"Resource $resourceId" } + + if (parentResourceRef != null) { + div { + val parentId = parentResourceRef.toPermissionParts().fullId + +"Parent: " + a(href = "../${parentId.encodeURLPathPart()}/") { +parentId } + } + } + + h2 { +"Child Resources" } + ul { + for (resourceInstance in childResources) { + val resourceId = resourceInstance.reference.toPermissionParts().fullId + li { + a(href = "../${resourceId.encodeURLPathPart()}/") { + +resourceId + } + } + } + } + + val canManagePermissions = call.canManagePermissions(resourceRef) + h2 { +"Permissions" } + table { + tr { + th { +"Permission" } + th { +"Description" } + th { +"Includes" } + th { +"Included In" } + if (canManagePermissions) { + th { +"Existing Grants" } + th { +"New Grant" } + } + } + for (permission in permissions) { + tr { + td { +permission.ref.permissionName } + td { +permission.permissionSchema.description.orEmpty() } + td { buildPermissionIncludesList(resourceRef, permission.includes) } + td { buildPermissionIncludesList(resourceRef, permission.includedIn) } + if (canManagePermissions) { + td { + val users = (permissionToUsers[permission.ref.fullId] ?: emptySet()).sorted() + val roles = (permissionToRoles[permission.ref.fullId] ?: emptySet()).sorted() + for (user in users) { + div { + postForm(action = "permissions/${permission.ref.permissionName.encodeURLPathPart()}/remove-grant") { + hiddenInput { + name = "userId" + value = user + } + submitInput { + value = "Remove grant to user $user" + } + } + } + } + for (role in roles) { + div { + postForm(action = "permissions/${permission.ref.permissionName.encodeURLPathPart()}/remove-grant") { + hiddenInput { + name = "roleId" + value = role + } + submitInput { + value = "Remove grant to role $role" + } + } + } + } + } + td { + postForm(action = "permissions/${permission.ref.permissionName.encodeURLPathPart()}/grant") { + +"User: " + textInput { + name = "userId" + list = "knownUsers" + } + +" " + submitInput { + value = "Grant" + } + } + br() + postForm(action = "permissions/${permission.ref.permissionName.encodeURLPathPart()}/grant") { + +"Role: " + textInput { + name = "roleId" + list = "knownRoles" + } + +" " + submitInput { + value = "Grant" + } + } + } + } + } + } + } + } + } + } + route("permissions") { + route("{permissionName}") { + fun PipelineContext<*, ApplicationCall>.permissionName(): String = call.parameters["permissionName"]!! + fun PipelineContext<*, ApplicationCall>.permissionId(): String = "${resourceId()}/${permissionName()}" + post("grant") { + val formParameters = call.receiveParameters() + grant(formParameters["userId"], formParameters["roleId"], permissionId()) + call.respondRedirect("../../") + } + post("remove-grant") { + val formParameters = call.receiveParameters() + removeGrant(formParameters["userId"], formParameters["roleId"], permissionId()) + call.respondRedirect("../../") + } + } + } } } + 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) - } - } + grant(userId, 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) - } - } + removeGrant(userId, 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" +fun FlowContent.buildPermissionIncludesList(currentResource: ResourceInstanceReference, permissionList: Set) { + val items = permissionList.map { + if (it.ref.resource == currentResource) { + null to it.ref.permissionName + } else { + it.ref.resource.fullId to it.ref.fullId } - - 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" - } - } + }.sortedWith(compareBy> { it.first }.thenComparing { it.second }) + ul { + for (item in items) { + li { + val resourceId = item.first + if (resourceId == null) { + +item.second + } else { + a(href = "../${resourceId.encodeURLPathPart()}/") { + +item.second } } } } + } +} - 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 +private fun PipelineContext<*, ApplicationCall>.grant(userId: String?, roleId: String?, permissionId: String) { + val userId = userId?.takeIf { it.isNotBlank() } + val roleId = roleId?.takeIf { it.isNotBlank() } + require(userId != null || roleId != null) { "userId or roleId required" } + call.checkCanGranPermission(permissionId) - tr { - td { - +roleId - } - td { - +permission - } - td { - postForm(action = "remove-grant") { - hiddenInput { - name = "roleId" - value = roleId - } - hiddenInput { - name = "permissionId" - value = permission - } - submitInput { - value = "Remove" - } - } - } - } - } + if (userId != null) { + application.plugin(ModelixAuthorization).config.accessControlPersistence.update { + it.withGrantToUser(userId, permissionId) } - - h1 { - +"Denied Permissions" + } + if (roleId != null) { + application.plugin(ModelixAuthorization).config.accessControlPersistence.update { + it.withGrantToRole(roleId, permissionId) } + } +} - 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() - } - } - } - } - } - } - } +private fun PipelineContext<*, ApplicationCall>.removeGrant(userId: String?, roleId: String?, permissionId: String) { + require(userId != null || roleId != null) { "userId or roleId required" } + 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) } } } @@ -252,9 +338,14 @@ fun ApplicationCall.canGrantPermission(permissionId: String): Boolean { } fun ApplicationCall.canGrantPermission(permissionRef: PermissionInstanceReference): Boolean { + return canManagePermissions(permissionRef.resource) +} + +fun ApplicationCall.canManagePermissions(resourceRef: ResourceInstanceReference): Boolean { val plugin = application.plugin(ModelixAuthorization) + if (plugin.hasPermission(this, PermissionSchemaBase.permissionData.write)) return true val schema = plugin.config.permissionSchema - val resources = generateSequence(permissionRef.resource) { it.parent } + val resources = generateSequence(resourceRef) { 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( @@ -275,3 +366,10 @@ fun ApplicationCall.checkCanGranPermission(id: String) { fun ApplicationCall.parsePermission(id: String): PermissionInstanceReference { return application.plugin(ModelixAuthorization).config.permissionSchema.let { PermissionParser(it) }.parse(id) } + +private fun Map>.inverse(): Map> { + return entries.asSequence() + .flatMap { entry -> entry.value.map { it to entry.key } } + .groupBy { it.first } + .mapValues { it.value.asSequence().map { it.second }.toSet() } +} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/PermissionPage.kt b/authorization/src/main/kotlin/org/modelix/authorization/PermissionSchemaPage.kt similarity index 73% rename from authorization/src/main/kotlin/org/modelix/authorization/PermissionPage.kt rename to authorization/src/main/kotlin/org/modelix/authorization/PermissionSchemaPage.kt index 0541eb2141..606dfe00fd 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/PermissionPage.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/PermissionSchemaPage.kt @@ -15,21 +15,17 @@ import kotlinx.html.td import kotlinx.html.th import kotlinx.html.tr import kotlinx.html.ul -import org.modelix.authorization.permissions.PermissionEvaluator +import kotlinx.html.unsafe import org.modelix.authorization.permissions.Resource -import org.modelix.authorization.permissions.ResourceInstanceReference +import org.modelix.authorization.permissions.Schema import org.modelix.authorization.permissions.SchemaInstance -fun HTML.buildPermissionPage(evaluator: PermissionEvaluator) { - buildPermissionPage(evaluator.schemaInstance) -} - -fun HTML.buildPermissionPage(schemaInstance: SchemaInstance) { - instantiateParameterizedResources(schemaInstance) +fun HTML.buildSchemaPage(schema: Schema) { head { style { - //language=CSS - +""" + unsafe { + //language=CSS + +""" table { border: 1px solid #ccc; border-collapse: collapse; @@ -38,22 +34,15 @@ fun HTML.buildPermissionPage(schemaInstance: SchemaInstance) { border: 1px solid #ccc; padding: 3px 12px; } - """.trimIndent() + """.trimIndent() + } } } body { - h1 { - +"Known Permissions" - } - schemaInstance.resources.values.filter { it.parent == null }.forEachIndexed { index, resourceInstance -> - if (index != 0) br { } - buildResourceInstance(resourceInstance) - } - h1 { +"Permission Schema" } - schemaInstance.schema.resources.values.forEachIndexed { index, resource -> + schema.resources.values.forEachIndexed { index, resource -> if (index != 0) br { } buildResource(resource) } @@ -166,25 +155,3 @@ private fun FlowContent.buildResource(resource: Resource) { } } } - -private fun instantiateParameterizedResources(schemaInstance: SchemaInstance) { - for (resource in schemaInstance.schema.resources.values) { - if (resource.parameters.isEmpty()) continue - val ref = ResourceInstanceReference(resource.name, resource.parameters.map { "<${resource.name}.$it>" }, null) - val instance = schemaInstance.instantiateResource(ref) - instantiateParameterizedResources(schemaInstance, instance, ref) - } -} - -private fun instantiateParameterizedResources( - schemaInstance: SchemaInstance, - parentInstance: SchemaInstance.ResourceInstance, - parentRef: ResourceInstanceReference, -) { - for (resource in parentInstance.resourceSchema.resources.values) { - if (resource.parameters.isEmpty()) continue - val ref = ResourceInstanceReference(resource.name, resource.parameters.map { "<${resource.name}.$it>" }, parentRef) - val instance = schemaInstance.instantiateResource(ref) - instantiateParameterizedResources(schemaInstance, instance, ref) - } -} diff --git a/authorization/src/main/kotlin/org/modelix/authorization/permissions/AccessControlData.kt b/authorization/src/main/kotlin/org/modelix/authorization/permissions/AccessControlData.kt index 03ee4d57d5..1d06e14248 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/permissions/AccessControlData.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/permissions/AccessControlData.kt @@ -20,6 +20,9 @@ data class AccessControlData( * Grants based on user roles extracted from the JWT token. */ val grantsToRoles: Map> = emptyMap(), + + val knownUsers: Set = emptySet(), + val knownRoles: Set = emptySet(), ) { /** @@ -87,6 +90,25 @@ interface IAccessControlPersistence : IAccessControlDataProvider { read().grantsToRoles[role]?.map { PermissionParts.fromString(it) }?.toSet() ?: emptySet() } +fun IAccessControlPersistence.recordKnownUser(user: String?) { + if (user == null) return + recordKnownUsers(listOf(user)) +} + +fun IAccessControlPersistence.recordKnownUsers(users: List) { + if (read().knownUsers.containsAll(users)) return // avoid write lock + update { + it.copy(knownUsers = it.knownUsers + users) + } +} + +fun IAccessControlPersistence.recordKnownRoles(roles: List) { + if (read().knownRoles.containsAll(roles)) return // avoid write lock + update { + it.copy(knownRoles = it.knownRoles + roles) + } +} + class FileSystemAccessControlPersistence(val file: File) : IAccessControlPersistence { private var data: AccessControlData = if (file.exists()) { @@ -101,7 +123,10 @@ class FileSystemAccessControlPersistence(val file: File) : IAccessControlPersist @Synchronized override fun update(updater: (AccessControlData) -> AccessControlData) { - data = updater(data) + val oldData = data + val newData = updater(data) + if (oldData == newData) return + data = newData writeFile() } diff --git a/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionParser.kt b/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionParser.kt index 90b3a1d5f8..fb33535a14 100644 --- a/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionParser.kt +++ b/authorization/src/main/kotlin/org/modelix/authorization/permissions/PermissionParser.kt @@ -35,4 +35,22 @@ class PermissionParser(val schema: Schema) { throw UnknownPermissionException(parts.fullId, parts.current()) } + + fun parseResource(parts: PermissionParts): ResourceInstanceReference? { + val rootResource = schema.resources[parts.current()] + ?: throw UnknownPermissionException(parts.fullId, parts.current()) + return parseResource(parts.next(), rootResource, null) + } + + private fun parseResource(parts: PermissionParts, resource: Resource, parent: ResourceInstanceReference?): ResourceInstanceReference? { + val parameterValues = parts.take(resource.parameters.size) + val instance = ResourceInstanceReference(resource.name, parameterValues, parent) + return parseResource(parts.next(parameterValues.size), resource to instance) + } + + private fun parseResource(parts: PermissionParts, parentResource: Pair): ResourceInstanceReference? { + if (parts.remainingSize() == 0) return parentResource.second + val childResource = parentResource.first.resources[parts.current()] ?: return null + return parseResource(parts.next(), childResource, parentResource.second) + } } 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 50da21a0b2..6738957da6 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,6 @@ package org.modelix.authorization.permissions +import kotlinx.serialization.Serializable import org.modelix.authorization.UnknownPermissionException /** @@ -140,11 +141,14 @@ class SchemaInstance(val schema: Schema) { } } +@Serializable data class ResourceInstanceReference( val name: String, val parameterValues: List, val parent: ResourceInstanceReference?, ) { + val fullId: String get() = toPermissionParts().fullId + override fun toString(): String { return toPermissionParts().toString() } @@ -156,6 +160,7 @@ data class ResourceInstanceReference( data class PermissionInstanceReference(val permissionName: String, val resource: ResourceInstanceReference) { fun toPermissionParts() = resource.toPermissionParts() + permissionName + val fullId: String get() = toPermissionParts().fullId override fun toString(): String { return toPermissionParts().toString() } diff --git a/model-server/Dockerfile b/model-server/Dockerfile index 3e5734ca04..0f9ffc6e5b 100644 --- a/model-server/Dockerfile +++ b/model-server/Dockerfile @@ -1,4 +1,5 @@ FROM registry.access.redhat.com/ubi8/openjdk-11:1.21-1.1733300800 +USER root WORKDIR /usr/modelix-model EXPOSE 28101 HEALTHCHECK CMD curl --fail http://localhost:28101/health || exit 1 diff --git a/model-server/src/main/kotlin/org/modelix/model/server/DBAccessControlPersistence.kt b/model-server/src/main/kotlin/org/modelix/model/server/DBAccessControlPersistence.kt new file mode 100644 index 0000000000..58364ff1ce --- /dev/null +++ b/model-server/src/main/kotlin/org/modelix/model/server/DBAccessControlPersistence.kt @@ -0,0 +1,28 @@ +package org.modelix.model.server + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.modelix.authorization.permissions.AccessControlData +import org.modelix.authorization.permissions.IAccessControlPersistence +import org.modelix.model.server.store.IGenericStoreClient +import org.modelix.model.server.store.RequiresTransaction + +class DBAccessControlPersistence(val store: IGenericStoreClient, val key: E) : IAccessControlPersistence { + private val json = Json { ignoreUnknownKeys } + override fun read(): AccessControlData { + @OptIn(RequiresTransaction::class) + return store.runReadTransaction { + store.get(key)?.let { json.decodeFromString(it) } ?: AccessControlData() + } + } + + override fun update(updater: (AccessControlData) -> AccessControlData) { + @OptIn(RequiresTransaction::class) + return store.runWriteTransaction { + val oldData = read() + val newData = updater(oldData) + if (oldData == newData) return@runWriteTransaction + store.put(key, json.encodeToString(newData)) + } + } +} 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 9b6c9f592c..673c857289 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 @@ -58,6 +58,7 @@ import org.modelix.model.server.handlers.ui.RepositoryOverview import org.modelix.model.server.store.IgniteStoreClient import org.modelix.model.server.store.InMemoryStoreClient import org.modelix.model.server.store.IsolatingStore +import org.modelix.model.server.store.ObjectInRepository import org.modelix.model.server.store.RequiresTransaction import org.modelix.model.server.store.forGlobalRepository import org.modelix.model.server.store.loadDump @@ -181,6 +182,10 @@ object Main { install(ModelixAuthorization) { permissionSchema = ModelServerPermissionSchema.SCHEMA installStatusPages = false + accessControlPersistence = DBAccessControlPersistence( + storeClient, + ObjectInRepository.global(RepositoriesManager.KEY_PREFIX + ":access-control-data"), + ) } install(ForwardedHeaders) install(CallLogging) { 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 972215e340..5072880255 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 @@ -16,6 +16,7 @@ import io.ktor.utils.io.charsets.Charsets import kotlinx.html.FlowContent import kotlinx.html.FlowOrInteractiveOrPhrasingContent import kotlinx.html.HTML +import kotlinx.html.HTMLTag import kotlinx.html.a import kotlinx.html.button import kotlinx.html.h1 @@ -26,6 +27,8 @@ import kotlinx.html.p import kotlinx.html.script import kotlinx.html.span import kotlinx.html.stream.createHTML +import kotlinx.html.style +import kotlinx.html.svg import kotlinx.html.table import kotlinx.html.tbody import kotlinx.html.td @@ -34,6 +37,7 @@ import kotlinx.html.thead import kotlinx.html.title import kotlinx.html.tr import kotlinx.html.unsafe +import kotlinx.html.visit import org.modelix.authorization.hasPermission import org.modelix.authorization.requiresLogin import org.modelix.model.lazy.RepositoryId @@ -112,7 +116,7 @@ class RepositoryOverview(private val repoManager: IRepositoriesManager) { } th { +"Branch" } th { - colSpan = "3" + colSpan = "4" +"Actions" } } @@ -125,6 +129,7 @@ class RepositoryOverview(private val repoManager: IRepositoriesManager) { td { rowSpan = repoRowSpan +repository.id + buildPermissionManagementLink(repository.id, null) } td { rowSpan = repoRowSpan @@ -145,6 +150,7 @@ class RepositoryOverview(private val repoManager: IRepositoriesManager) { span { +branch.branchName } + buildPermissionManagementLink(repository.id, branch.branchName) } td { buildHistoryLink(repository.id, branch.branchName) @@ -177,6 +183,19 @@ internal fun FlowOrInteractiveOrPhrasingContent.buildExploreLatestLink(repositor } } +internal fun FlowOrInteractiveOrPhrasingContent.buildPermissionManagementLink(repositoryId: String, branchName: String?) { + val resourceId = ModelServerPermissionSchema.repository(repositoryId) + .let { if (branchName == null) it.resource else it.branch(branchName).resource } + .fullId + a("../permissions/resources/${resourceId.encodeURLPathPart()}/") { + svg { + style = "color: rgba(0, 0, 0, 0.87); fill: rgba(0, 0, 0, 0.87); width: 20px; height: 20px;" + attributes["viewBox"] = "0 0 24 24" + HTMLTag("path", consumer, mapOf("d" to "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2m-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2m3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1s3.1 1.39 3.1 3.1z"), null, false, false).visit {} + } + } +} + internal fun FlowContent.buildDeleteRepositoryForm(repositoryId: String) { button { name = "delete" diff --git a/model-server/src/main/kotlin/org/modelix/model/server/templates/PageWithMenuBar.kt b/model-server/src/main/kotlin/org/modelix/model/server/templates/PageWithMenuBar.kt index f1b5bc8acd..55f43951b4 100644 --- a/model-server/src/main/kotlin/org/modelix/model/server/templates/PageWithMenuBar.kt +++ b/model-server/src/main/kotlin/org/modelix/model/server/templates/PageWithMenuBar.kt @@ -41,7 +41,7 @@ class PageWithMenuBar(val activePage: String, val baseUrl: String) : Template