Skip to content

Commit 177800b

Browse files
committed
Merge branch 'permissions' into main
2 parents cf54fe8 + c1eed68 commit 177800b

File tree

7 files changed

+145
-38
lines changed

7 files changed

+145
-38
lines changed

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

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import com.auth0.jwt.interfaces.DecodedJWT
2020
import com.google.common.cache.CacheBuilder
2121
import org.keycloak.authorization.client.AuthzClient
2222
import org.keycloak.authorization.client.Configuration
23+
import org.keycloak.authorization.client.resource.ProtectedResource
2324
import org.keycloak.representations.idm.authorization.AuthorizationRequest
2425
import org.keycloak.representations.idm.authorization.Permission
2526
import org.keycloak.representations.idm.authorization.PermissionRequest
2627
import org.keycloak.representations.idm.authorization.ResourceRepresentation
28+
import org.keycloak.representations.idm.authorization.ScopeRepresentation
2729
import java.net.URL
2830
import java.util.concurrent.TimeUnit
2931

@@ -52,9 +54,11 @@ object KeycloakUtils {
5254
}
5355

5456
private val permissionCache = CacheBuilder.newBuilder()
55-
.expireAfterWrite(1, TimeUnit.MINUTES)
57+
.expireAfterWrite(30, TimeUnit.SECONDS)
5658
.build<String, List<Permission>>()
57-
private val existingResources = HashSet<String>()
59+
private val existingResources = CacheBuilder.newBuilder()
60+
.expireAfterWrite(3, TimeUnit.MINUTES)
61+
.build<String, ResourceRepresentation>()
5862

5963
private fun patchUrls(c: AuthzClient): AuthzClient {
6064
patchObject(c.serverConfiguration)
@@ -105,43 +109,115 @@ object KeycloakUtils {
105109
}
106110

107111
@Synchronized
108-
fun hasPermission(accessToken: DecodedJWT, resourceName: String, scope: String): Boolean {
109-
ensureResourcesExists(resourceName)
112+
fun hasPermission(accessToken: DecodedJWT, resourceSpec: KeycloakResource, scope: KeycloakScope): Boolean {
113+
ensureResourcesExists(resourceSpec, accessToken)
110114
val allPermissions = getPermissions(accessToken)
111-
val forResource = allPermissions.filter { it.resourceName == resourceName }
115+
val forResource = allPermissions.filter { it.resourceName == resourceSpec.name }
112116
if (forResource.isEmpty()) return false
113117
val scopes: Set<String> = forResource.mapNotNull { it.scopes }.flatten().toSet()
114118
if (scopes.isEmpty()) {
115119
// If the permissions are not restricted to any scope we assume they are valid for all scopes.
116120
return true
117121
}
118-
return scopes.contains(scope)
122+
return scopes.contains(scope.name)
119123
}
120124

121-
private fun hasPermissions(accessToken: DecodedJWT, resourceNames: List<String>): Boolean {
122-
try {
123-
val ticket = authzClient.protection(accessToken.token).permission().create(resourceNames.map { PermissionRequest(it) }).ticket
124-
val rpt = authzClient.authorization(accessToken.token).authorize(AuthorizationRequest(ticket)).token
125-
val introspect = authzClient.protection().introspectRequestingPartyToken(rpt)
126-
return introspect.active
127-
} catch (e: Exception) {
128-
throw RuntimeException("Can't get permissions for token: ${accessToken.token}", e)
129-
}
130-
}
125+
@Synchronized
126+
fun grantPermission(user: DecodedJWT, resourceSpec: KeycloakResource, scopes: Set<KeycloakScope>): Boolean {
127+
val resource = ensureResourcesExists(resourceSpec, user)
128+
val ticketResponse = authzClient.protection(user.token).permission()
129+
.create(PermissionRequest(resource.id, *scopes.map { it.name }.toTypedArray()))
131130

132-
private fun createPermission(ownerToken: DecodedJWT?, resourceName: String): String {
133-
val protection = if (ownerToken == null) authzClient.protection() else authzClient.protection(ownerToken.token)
134-
val newResource = protection.resource().create(ResourceRepresentation(resourceName))
135-
return newResource.id
131+
val authResponse = authzClient.authorization(/* service account */)
132+
.authorize(AuthorizationRequest(ticketResponse.ticket))
133+
134+
return authResponse.isUpgraded
136135
}
137136

138137
@Synchronized
139-
fun ensureResourcesExists(resourceName: String) {
140-
if (existingResources.contains(resourceName)) return
141-
if (authzClient.protection().resource().findByName(resourceName) == null) {
142-
authzClient.protection().resource().create(ResourceRepresentation(resourceName))
138+
fun ensureResourcesExists(
139+
resourceSpec: KeycloakResource,
140+
owner: DecodedJWT? = null
141+
): ResourceRepresentation {
142+
return existingResources.get(resourceSpec.name) {
143+
var resource = authzClient.protection().resource().findByNameAnyOwner(resourceSpec.name)
144+
if (resource != null) return@get resource
145+
val protection = (
146+
if (resourceSpec.type.ownerManaged) {
147+
owner?.let { authzClient.protection(owner.token) }
148+
} else {
149+
null
150+
}
151+
) ?: authzClient.protection()
152+
resource = ResourceRepresentation().apply {
153+
name = resourceSpec.name
154+
scopes = resourceSpec.type.scopes.map { ScopeRepresentation(it.name) }.toSet()
155+
type = resourceSpec.type.name
156+
if (resourceSpec.type.ownerManaged) ownerManagedAccess = true
157+
}
158+
resource = protection.resource().create(resource)
143159
permissionCache.invalidateAll()
160+
return@get resource
144161
}
145-
existingResources += resourceName
162+
163+
}
164+
}
165+
166+
data class KeycloakScope(val name: String) {
167+
operator fun plus(other: KeycloakScope): Set<KeycloakScope> = setOf(this, other)
168+
fun toSet() = setOf(this)
169+
170+
companion object {
171+
val ADD = KeycloakScope("add") // the user can add a new item, but not remove other items in a list
172+
val LIST = KeycloakScope("list") // the user can see that an item exists, but not read the contents
173+
val READ = KeycloakScope("read")
174+
val WRITE = KeycloakScope("write")
175+
val DELETE = KeycloakScope("delete")
176+
val READ_WRITE_DELETE = setOf(READ, WRITE, DELETE)
177+
val READ_WRITE_DELETE_LIST = setOf(READ, WRITE, DELETE, LIST)
178+
val READ_WRITE = setOf(READ, WRITE)
179+
val READ_WRITE_LIST = setOf(READ, WRITE, LIST)
180+
val READ_ONLY = setOf(READ)
181+
val READ_LIST = setOf(READ, LIST)
182+
val ALL_SCOPES = READ_WRITE_DELETE_LIST
183+
}
184+
}
185+
fun EPermissionType.toKeycloakScope(): KeycloakScope = when (this) {
186+
EPermissionType.READ -> KeycloakScope.READ
187+
EPermissionType.WRITE -> KeycloakScope.WRITE
188+
}
189+
190+
data class KeycloakResource(val name: String, val type: KeycloakResourceType) {
191+
192+
}
193+
194+
data class KeycloakResourceType(val name: String, val scopes: Set<KeycloakScope>, val ownerManaged: Boolean = false) {
195+
fun createInstance(resourceName: String) = KeycloakResource(this.name + "/" + resourceName, this)
196+
197+
companion object {
198+
val DEFAULT_TYPE = KeycloakResourceType("default", KeycloakScope.READ_WRITE)
199+
val MODEL_SERVER_ENTRY = KeycloakResourceType("model-server-entry", KeycloakScope.READ_WRITE_DELETE)
200+
val REPOSITORY = KeycloakResourceType("repository", KeycloakScope.READ_WRITE_DELETE_LIST)
201+
val WORKSPACE = KeycloakResourceType("workspace", KeycloakScope.READ_WRITE_DELETE_LIST, ownerManaged = true)
146202
}
203+
}
204+
205+
fun String.asResource() = KeycloakResourceType.DEFAULT_TYPE.createInstance(this)
206+
207+
private fun ProtectedResource.findByNameAnyOwner(name: String): ResourceRepresentation? {
208+
val resources: List<ResourceRepresentation> = org.modelix.authorization.KeycloakUtils.authzClient.protection().resource()
209+
.find(
210+
null,
211+
name,
212+
null,
213+
null,
214+
null,
215+
null,
216+
false,
217+
true,
218+
true,
219+
null,
220+
null
221+
)
222+
return resources.firstOrNull()
147223
}

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

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,20 +125,42 @@ fun Application.installAuthentication(unitTestMode: Boolean = false) {
125125

126126
}
127127

128-
fun Route.requiresPermission(resourceName: String, scope: String, body: Route.()->Unit) {
128+
fun Route.requiresPermission(resource: KeycloakResource, permissionType: EPermissionType, body: Route.()->Unit) {
129+
requiresPermission(resource, permissionType.toKeycloakScope(), body)
130+
}
131+
132+
fun Route.requiresRead(resource: KeycloakResource, body: Route.()->Unit) {
133+
requiresPermission(resource, KeycloakScope.READ, body)
134+
}
135+
136+
fun Route.requiresWrite(resource: KeycloakResource, body: Route.()->Unit) {
137+
requiresPermission(resource, KeycloakScope.WRITE, body)
138+
}
139+
140+
fun Route.requiresDelete(resource: KeycloakResource, body: Route.()->Unit) {
141+
requiresPermission(resource, KeycloakScope.DELETE, body)
142+
}
143+
144+
fun Route.requiresPermission(resource: KeycloakResource, scope: KeycloakScope, body: Route.()->Unit) {
129145
authenticate(jwtAuth) {
130146
intercept(ApplicationCallPipeline.Call) {
131-
call.checkPermission(resourceName, scope)
147+
call.checkPermission(resource, scope)
132148
}
133149
body()
134150
}
135151
}
136152

137-
fun ApplicationCall.checkPermission(resourceName: String, scope: String) {
153+
fun Route.requiresLogin(body: Route.()->Unit) {
154+
authenticate(jwtAuth) {
155+
body()
156+
}
157+
}
158+
159+
fun ApplicationCall.checkPermission(resource: KeycloakResource, scope: KeycloakScope) {
138160
if (attributes.getOrNull(UNIT_TEST_MODE_KEY) == true) return
139161
val principal = principal<AccessTokenPrincipal>() ?: throw NotLoggedInException()
140-
if (!KeycloakUtils.hasPermission(principal.jwt, resourceName, scope)) {
141-
throw NoPermissionException(principal, resourceName, scope)
162+
if (!KeycloakUtils.hasPermission(principal.jwt, resource, scope)) {
163+
throw NoPermissionException(principal, resource.name, scope.name)
142164
}
143165
}
144166

@@ -161,6 +183,8 @@ fun ApplicationCall.jwtFromHeaders(): DecodedJWT? {
161183
return (request.header("X-Forwarded-Access-Token") ?: getBearerToken())?.let { JWT.decode(it) }
162184
}
163185

186+
fun ApplicationCall.jwt() = principal<AccessTokenPrincipal>()?.jwt ?: jwtFromHeaders()
187+
164188
fun PipelineContext<Unit, ApplicationCall>.getUserName(): String? {
165189
return call.getUserName()
166190
}

model-server/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ FROM openjdk:11
22
WORKDIR /usr/modelix-model
33
EXPOSE 28101
44
COPY run-model-server.sh /usr/modelix-model/
5-
COPY build/ /usr/modelix-model/model-server/build/
5+
COPY build/libs/ /usr/modelix-model/model-server/build/libs/
66
CMD ["./run-model-server.sh"]

model-server/src/main/kotlin/org/modelix/model/server/HistoryHandler.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import io.ktor.server.request.*
77
import io.ktor.server.response.*
88
import io.ktor.server.routing.*
99
import org.apache.commons.lang3.StringEscapeUtils
10+
import org.modelix.authorization.KeycloakScope
11+
import org.modelix.authorization.asResource
1012
import org.modelix.authorization.getUserName
1113
import org.modelix.authorization.requiresPermission
1214
import org.modelix.model.LinearHistory
@@ -47,7 +49,7 @@ class HistoryHandler(private val client: IModelClient) {
4749
buildRepositoryPage(PrintWriter(this), RepositoryAndBranch(repositoryId, branch), params["head"], skip, limit)
4850
}
4951
}
50-
requiresPermission("history", "WRITE") {
52+
requiresPermission("history".asResource(), KeycloakScope.WRITE) {
5153
post("/history/{repoId}/{branch}/revert") {
5254
val repositoryId = call.parameters["repoId"]!!
5355
val branch = call.parameters["branch"]!!

model-server/src/main/kotlin/org/modelix/model/server/JsonModelServer.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import kotlinx.html.td
3030
import kotlinx.html.tr
3131
import org.json.JSONArray
3232
import org.json.JSONObject
33+
import org.modelix.authorization.KeycloakScope
34+
import org.modelix.authorization.asResource
3335
import org.modelix.authorization.getUserName
3436
import org.modelix.authorization.requiresPermission
3537
import org.modelix.model.IKeyListener
@@ -50,7 +52,7 @@ class JsonModelServer(val client: LocalModelClient) {
5052
fun init(application: Application) {
5153
application.apply {
5254
routing {
53-
requiresPermission("model-json-api", "read") {
55+
requiresPermission("model-json-api".asResource(), KeycloakScope.READ) {
5456
route("/json") {
5557
initRouting()
5658
}

model-server/src/main/kotlin/org/modelix/model/server/JsonModelServer2.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import kotlinx.coroutines.channels.Channel
2323
import kotlinx.coroutines.launch
2424
import kotlinx.coroutines.sync.Mutex
2525
import kotlinx.coroutines.sync.withLock
26+
import org.modelix.authorization.KeycloakScope
27+
import org.modelix.authorization.asResource
2628
import org.modelix.authorization.getUserName
2729
import org.modelix.authorization.requiresPermission
2830
import org.modelix.model.VersionMerger
@@ -77,7 +79,7 @@ class JsonModelServer2(val client: LocalModelClient) {
7779
fun init(application: Application) {
7880
application.apply {
7981
routing {
80-
requiresPermission("model-json-api", "read") {
82+
requiresPermission("model-json-api".asResource(), KeycloakScope.READ) {
8183
route("/json/v2") {
8284
initRouting()
8385
}

model-server/src/main/kotlin/org/modelix/model/server/KtorModelServer.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ import java.net.UnknownHostException
3434
import java.util.*
3535
import java.util.regex.Pattern
3636

37-
val PERMISSION_MODEL_SERVER = "model-server"
37+
val PERMISSION_MODEL_SERVER = "model-server".asResource()
38+
val MODEL_SERVER_ENTRY = KeycloakResourceType("model-server-entry", KeycloakScope.READ_WRITE_DELETE)
3839

3940
private fun toLong(value: String?): Long {
4041
return if (value == null || value.isEmpty()) 0 else value.toLong()
@@ -84,7 +85,7 @@ class KtorModelServer(val storeClient: IStoreClient) {
8485
val headers = call.request.headers.entries().flatMap { e -> e.value.map { e.key to it } }
8586
call.respondText(headers.joinToString("\n") { "${it.first}: ${it.second}" })
8687
}
87-
requiresPermission(PERMISSION_MODEL_SERVER, EPermissionType.READ.name) {
88+
requiresPermission(PERMISSION_MODEL_SERVER, EPermissionType.READ) {
8889
get("/get/{key}") {
8990
val key = call.parameters["key"]!!
9091
checkKeyPermission(key, EPermissionType.READ)
@@ -169,7 +170,7 @@ class KtorModelServer(val storeClient: IStoreClient) {
169170
call.respondText(respJson.toString(), contentType = ContentType.Application.Json)
170171
}
171172
}
172-
requiresPermission(PERMISSION_MODEL_SERVER, "write") {
173+
requiresPermission(PERMISSION_MODEL_SERVER, EPermissionType.WRITE) {
173174

174175
}
175176
}
@@ -295,7 +296,7 @@ class KtorModelServer(val storeClient: IStoreClient) {
295296
// A permission check has happened somewhere earlier.
296297
return
297298
}
298-
call.checkPermission("model-server-entry/$key", type.name)
299+
call.checkPermission(MODEL_SERVER_ENTRY.createInstance(key), type.toKeycloakScope())
299300
}
300301

301302

0 commit comments

Comments
 (0)