Skip to content

Commit

Permalink
feat: introduce a rights management API (#520)
Browse files Browse the repository at this point in the history
* feat(api-gateway): add route for access control API
* feat(api-gateway): add route for IAM sync endpoint
* feat(common): add a WithKafkaContainer config / handle groups and stellio-admin access checks
* feat(entity): add endpoints to add / remove rights on entities for an user
* feat(entity): propagate changes of access rights on entities to Kafka
* feat(entity): implement iam and rights synchronizer endpoint (needs stellio-admin role)
* feat(search): add support for subjects access rights
- add model and service to manage subjects access rights
- parse of events from cim.iam topic
* feat(search): add listener for events related to access rights on entities
* feat(search): add access control when getting temporal evolution of an entity
* feat(search): add support for attribute append and delete events from cim.iam
* feat(search): implement entities filtering according to user's access rights
* refactor(shared): put common sample tests payloads in shared testFixtures
* refactor(shared): move security specific constants into AuthUtils
  • Loading branch information
bobeal authored Jan 3, 2022
1 parent 2ac48fb commit fc6e536
Show file tree
Hide file tree
Showing 87 changed files with 2,875 additions and 455 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,35 +22,23 @@ class ApiGatewayApplication {
fun myRoutes(builder: RouteLocatorBuilder): RouteLocator {
return builder.routes()
.route { p ->
p.path("/ngsi-ld/v1/entities/**")
p.path(
"/ngsi-ld/v1/entities/**",
"/ngsi-ld/v1/entityOperations/**",
"/ngsi-ld/v1/entityAccessControl/**",
"/ngsi-ld/v1/types/**",
"/entity/admin/**"
)
.filters {
it.tokenRelay()
}
.uri("http://$entityServiceUrl:8082")
}
.route { p ->
p.path("/ngsi-ld/v1/entityOperations/**")
.filters {
it.tokenRelay()
}
.uri("http://$entityServiceUrl:8082")
}
.route { p ->
p.path("/ngsi-ld/v1/types/**")
.filters {
it.tokenRelay()
}
.uri("http://$entityServiceUrl:8082")
}
.route { p ->
p.path("/ngsi-ld/v1/temporal/entities/**")
.filters {
it.tokenRelay()
}
.uri("http://$searchServiceUrl:8083")
}
.route { p ->
p.path("/ngsi-ld/v1/temporal/entityOperations/**")
p.path(
"/ngsi-ld/v1/temporal/entities/**",
"/ngsi-ld/v1/temporal/entityOperations/**"
)
.filters {
it.tokenRelay()
}
Expand Down
2 changes: 0 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ subprojects {
testImplementation("io.mockk:mockk:1.12.2")
testImplementation("io.projectreactor:reactor-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.testcontainers:testcontainers")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
}

Expand Down
5 changes: 2 additions & 3 deletions entity-service/config/detekt/baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<ID>LargeClass:EntityHandlerTests.kt$EntityHandlerTests</ID>
<ID>LargeClass:EntityOperationHandlerTests.kt$EntityOperationHandlerTests</ID>
<ID>LargeClass:EntityServiceTests.kt$EntityServiceTests</ID>
<ID>LargeClass:Neo4jRepositoryTests.kt$Neo4jRepositoryTests : WithNeo4jContainer</ID>
<ID>LargeClass:StandaloneNeo4jSearchRepositoryTests.kt$StandaloneNeo4jSearchRepositoryTests : WithNeo4jContainer</ID>
<ID>LargeClass:Neo4jRepositoryTests.kt$Neo4jRepositoryTests : WithNeo4jContainerWithKafkaContainer</ID>
<ID>LargeClass:StandaloneNeo4jSearchRepositoryTests.kt$StandaloneNeo4jSearchRepositoryTests : WithNeo4jContainerWithKafkaContainer</ID>
<ID>LongMethod:EntityEventServiceTests.kt$EntityEventServiceTests$@Test fun `it should publish ATTRIBUTE_APPEND and ATTRIBUTE_REPLACE events if attributes were appended and replaced`()</ID>
<ID>LongMethod:EntityHandler.kt$EntityHandler$ @GetMapping(produces = [MediaType.APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE]) suspend fun getEntities( @RequestHeader httpHeaders: HttpHeaders, @RequestParam params: MultiValueMap&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>LongMethod:EntityServiceTests.kt$EntityServiceTests$@Test fun `it should create a new multi attribute property`()</ID>
Expand All @@ -16,7 +16,6 @@
<ID>LongParameterList:EntityEventService.kt$EntityEventService$( entityId: URI, entityType: String, attributeName: String, datasetId: URI? = null, overwrite: Boolean, operationPayload: String, updateOperationResult: UpdateOperationResult, contexts: List&lt;String&gt; )</ID>
<ID>LongParameterList:EntityService.kt$EntityService$( queryParams: QueryParams, userSub: String, offset: Int, limit: Int, contextLink: String, includeSysAttrs: Boolean )</ID>
<ID>LongParameterList:EntityService.kt$EntityService$( queryParams: QueryParams, userSub: String, offset: Int, limit: Int, contexts: List&lt;String&gt;, includeSysAttrs: Boolean )</ID>
<ID>MaxLineLength:EntityHandlerTests.kt$EntityHandlerTests$ </ID>
<ID>MaxLineLength:Neo4jRepository.kt$Neo4jRepository$ MATCH (a:</ID>
<ID>MaxLineLength:Neo4jRepository.kt$Neo4jRepository$ MATCH (entity:</ID>
<ID>ReturnCount:EntityHandler.kt$EntityHandler$ @GetMapping(produces = [MediaType.APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE]) suspend fun getEntities( @RequestHeader httpHeaders: HttpHeaders, @RequestParam params: MultiValueMap&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,6 @@ package com.egm.stellio.entity.authorization
import java.net.URI

interface AuthorizationService {

companion object {
const val USER_PREFIX: String = "urn:ngsi-ld:User:"
const val AUTHORIZATION_ONTOLOGY = "https://ontology.eglobalmark.com/authorization#"
const val USER_LABEL = AUTHORIZATION_ONTOLOGY + "User"
const val CLIENT_LABEL = AUTHORIZATION_ONTOLOGY + "Client"
const val EGM_ROLES = AUTHORIZATION_ONTOLOGY + "roles"
const val R_CAN_READ = AUTHORIZATION_ONTOLOGY + "rCanRead"
const val R_CAN_WRITE = AUTHORIZATION_ONTOLOGY + "rCanWrite"
const val R_CAN_ADMIN = AUTHORIZATION_ONTOLOGY + "rCanAdmin"
const val R_IS_MEMBER_OF = AUTHORIZATION_ONTOLOGY + "isMemberOf"
const val SERVICE_ACCOUNT_ID = AUTHORIZATION_ONTOLOGY + "serviceAccountId"
const val ADMIN_ROLE_LABEL = "stellio-admin"
const val CREATION_ROLE_LABEL = "stellio-creator"
val ADMIN_ROLES: Set<String> = setOf(ADMIN_ROLE_LABEL)
val CREATION_ROLES: Set<String> = setOf(CREATION_ROLE_LABEL).plus(ADMIN_ROLES)
val ADMIN_RIGHT: Set<String> = setOf(R_CAN_ADMIN)
val WRITE_RIGHT: Set<String> = setOf(R_CAN_WRITE).plus(ADMIN_RIGHT)
val READ_RIGHT: Set<String> = setOf(R_CAN_READ).plus(WRITE_RIGHT)
}

enum class SpecificAccessPolicy {
AUTH_READ,
AUTH_WRITE
}

fun userIsAdmin(userSub: String): Boolean
fun userCanCreateEntities(userSub: String): Boolean
fun filterEntitiesUserCanRead(entitiesId: List<URI>, userSub: String): List<URI>
Expand All @@ -40,4 +14,5 @@ interface AuthorizationService {
fun userIsAdminOfEntity(entityId: URI, userSub: String): Boolean
fun createAdminLink(entityId: URI, userSub: String)
fun createAdminLinks(entitiesId: List<URI>, userSub: String)
fun removeUserRightsOnEntity(entityId: URI, subjectId: URI): Int
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.egm.stellio.entity.authorization

import com.egm.stellio.entity.authorization.AuthorizationService.Companion.CLIENT_LABEL
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.EGM_ROLES
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.R_CAN_ADMIN
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.SERVICE_ACCOUNT_ID
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.USER_LABEL
import com.egm.stellio.entity.model.Relationship
import com.egm.stellio.shared.util.JsonLdUtils.EGM_SPECIFIC_ACCESS_POLICY
import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_ROLES
import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_SAP
import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_SID
import com.egm.stellio.shared.util.AuthContextModel.AUTH_REL_CAN_ADMIN
import com.egm.stellio.shared.util.AuthContextModel.CLIENT_TYPE
import com.egm.stellio.shared.util.AuthContextModel.USER_TYPE
import com.egm.stellio.shared.util.toListOfString
import com.egm.stellio.shared.util.toUri
import org.springframework.data.neo4j.core.Neo4jClient
Expand All @@ -27,7 +27,7 @@ class Neo4jAuthorizationRepository(
"""
MATCH (userEntity:Entity)
WHERE (userEntity.id = ${'$'}userId
OR (userEntity)-[:HAS_VALUE]->(:Property { name: "$SERVICE_ACCOUNT_ID", value: ${'$'}userId }))
OR (userEntity)-[:HAS_VALUE]->(:Property { name: "$AUTH_PROP_SID", value: ${'$'}userId }))
WITH userEntity
MATCH (entity:Entity)
WHERE entity.id IN ${'$'}entitiesId
Expand Down Expand Up @@ -61,7 +61,7 @@ class Neo4jAuthorizationRepository(
"""
MATCH (entity:Entity)
WHERE entity.id IN ${'$'}entitiesId
MATCH (entity)-[:HAS_VALUE]->(p:Property { name: "$EGM_SPECIFIC_ACCESS_POLICY" })
MATCH (entity)-[:HAS_VALUE]->(p:Property { name: "$AUTH_PROP_SAP" })
WHERE p.value IN ${'$'}specificAccessPolicies
RETURN entity.id as id
""".trimIndent()
Expand All @@ -80,15 +80,15 @@ class Neo4jAuthorizationRepository(
val query =
"""
MATCH (userEntity:Entity { id: ${'$'}userId })
OPTIONAL MATCH (userEntity)-[:HAS_VALUE]->(p:Property { name:"$EGM_ROLES" })
OPTIONAL MATCH (userEntity)-[:HAS_VALUE]->(p:Property { name:"$AUTH_PROP_ROLES" })
OPTIONAL MATCH (userEntity)-[:HAS_OBJECT]-(r:Attribute:Relationship)-
[:isMemberOf]->(group:Entity)-[:HAS_VALUE]->(pgroup:Property { name: "$EGM_ROLES" })
[:isMemberOf]->(group:Entity)-[:HAS_VALUE]->(pgroup:Property { name: "$AUTH_PROP_ROLES" })
RETURN apoc.coll.union(collect(p.value), collect(pgroup.value)) as roles
UNION
MATCH (client:Entity)-[:HAS_VALUE]->(sid:Property { name: "$SERVICE_ACCOUNT_ID", value: ${'$'}userId })
OPTIONAL MATCH (client)-[:HAS_VALUE]->(p:Property { name:"$EGM_ROLES" })
MATCH (client:Entity)-[:HAS_VALUE]->(sid:Property { name: "$AUTH_PROP_SID", value: ${'$'}userId })
OPTIONAL MATCH (client)-[:HAS_VALUE]->(p:Property { name:"$AUTH_PROP_ROLES" })
OPTIONAL MATCH (client)-[:HAS_OBJECT]-(r:Attribute:Relationship)-
[:isMemberOf]->(group:Entity)-[:HAS_VALUE]->(pgroup:Property { name: "$EGM_ROLES" })
[:isMemberOf]->(group:Entity)-[:HAS_VALUE]->(pgroup:Property { name: "$AUTH_PROP_ROLES" })
RETURN apoc.coll.union(collect(p.value), collect(pgroup.value)) as roles
""".trimIndent()

Expand Down Expand Up @@ -116,18 +116,18 @@ class Neo4jAuthorizationRepository(
val query =
"""
CALL {
MATCH (user:Entity:`$USER_LABEL`)
MATCH (user:Entity:`$USER_TYPE`)
WHERE user.id = ${'$'}userId
RETURN user
UNION
MATCH (user:Entity:`$CLIENT_LABEL`)
WHERE (user)-[:HAS_VALUE]->(:Property { name: "$SERVICE_ACCOUNT_ID", value: ${'$'}userId })
MATCH (user:Entity:`$CLIENT_TYPE`)
WHERE (user)-[:HAS_VALUE]->(:Property { name: "$AUTH_PROP_SID", value: ${'$'}userId })
RETURN user
}
WITH user
UNWIND ${'$'}relPropsAndTargets AS relPropAndTarget
MATCH (target:Entity { id: relPropAndTarget.targetEntityId })
CREATE (user)-[:HAS_OBJECT]->(r:Attribute:Relationship:`$R_CAN_ADMIN`)-[:rCanAdmin]->(target)
CREATE (user)-[:HAS_OBJECT]->(r:Attribute:Relationship:`$AUTH_REL_CAN_ADMIN`)-[:rCanAdmin]->(target)
SET r = relPropAndTarget.props
RETURN r.id as id
"""
Expand All @@ -146,4 +146,23 @@ class Neo4jAuthorizationRepository(
.fetch().all()
.map { (it["id"] as String).toUri() }
}

fun removeUserRightsOnEntity(
subjectId: URI,
targetId: URI
): Int {
val matchQuery =
"""
MATCH (subject:Entity { id: ${'$'}subjectId })-[:HAS_OBJECT]-(relNode)
-[]->(target:Entity { id: ${'$'}targetId })
DETACH DELETE relNode
""".trimIndent()

val parameters = mapOf(
"subjectId" to subjectId.toString(),
"targetId" to targetId.toString()
)

return neo4jClient.query(matchQuery).bindAll(parameters).run().counters().nodesDeleted()
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package com.egm.stellio.entity.authorization

import com.egm.stellio.entity.authorization.AuthorizationService.Companion.ADMIN_RIGHT
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.ADMIN_ROLES
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.CREATION_ROLES
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.READ_RIGHT
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.R_CAN_ADMIN
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.USER_PREFIX
import com.egm.stellio.entity.authorization.AuthorizationService.Companion.WRITE_RIGHT
import com.egm.stellio.entity.authorization.AuthorizationService.SpecificAccessPolicy
import arrow.core.flattenOption
import com.egm.stellio.entity.model.Relationship
import com.egm.stellio.shared.util.ADMIN_ROLES
import com.egm.stellio.shared.util.AuthContextModel.ADMIN_RIGHTS
import com.egm.stellio.shared.util.AuthContextModel.AUTH_REL_CAN_ADMIN
import com.egm.stellio.shared.util.AuthContextModel.READ_RIGHTS
import com.egm.stellio.shared.util.AuthContextModel.SpecificAccessPolicy
import com.egm.stellio.shared.util.AuthContextModel.USER_PREFIX
import com.egm.stellio.shared.util.AuthContextModel.WRITE_RIGHTS
import com.egm.stellio.shared.util.CREATION_ROLES
import com.egm.stellio.shared.util.GlobalRole
import com.egm.stellio.shared.util.toUri
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component
Expand All @@ -24,8 +26,10 @@ class Neo4jAuthorizationService(

override fun userCanCreateEntities(userSub: String): Boolean = userIsOneOfGivenRoles(CREATION_ROLES, userSub)

private fun userIsOneOfGivenRoles(roles: Set<String>, userSub: String): Boolean =
private fun userIsOneOfGivenRoles(roles: Set<GlobalRole>, userSub: String): Boolean =
neo4jAuthorizationRepository.getUserRoles((USER_PREFIX + userSub).toUri())
.map { GlobalRole.forKey(it) }
.flattenOption()
.intersect(roles)
.isNotEmpty()

Expand All @@ -35,10 +39,10 @@ class Neo4jAuthorizationService(
entitiesId,
listOf(SpecificAccessPolicy.AUTH_WRITE, SpecificAccessPolicy.AUTH_READ)
)
// remove the already authorized entities from the list to avoid double checking them
// remove the already authorized entities from the list to avoid double-checking them
val grantedEntities = filterEntitiesUserHaveOneOfGivenRights(
entitiesId.minus(authorizedBySpecificPolicyEntities),
READ_RIGHT,
entitiesId.minus(authorizedBySpecificPolicyEntities.toSet()),
READ_RIGHTS,
userSub
)
return authorizedBySpecificPolicyEntities.plus(grantedEntities)
Expand All @@ -47,17 +51,17 @@ class Neo4jAuthorizationService(
override fun filterEntitiesUserCanUpdate(entitiesId: List<URI>, userSub: String): List<URI> {
val authorizedBySpecificPolicyEntities =
filterEntitiesWithSpecificAccessPolicy(entitiesId, listOf(SpecificAccessPolicy.AUTH_WRITE))
// remove the already authorized entities from the list to avoid double checking them
// remove the already authorized entities from the list to avoid double-checking them
val grantedEntities = filterEntitiesUserHaveOneOfGivenRights(
entitiesId.minus(authorizedBySpecificPolicyEntities),
WRITE_RIGHT,
entitiesId.minus(authorizedBySpecificPolicyEntities.toSet()),
WRITE_RIGHTS,
userSub
)
return authorizedBySpecificPolicyEntities.plus(grantedEntities)
}

override fun filterEntitiesUserCanAdmin(entitiesId: List<URI>, userSub: String): List<URI> =
filterEntitiesUserHaveOneOfGivenRights(entitiesId, ADMIN_RIGHT, userSub)
filterEntitiesUserHaveOneOfGivenRights(entitiesId, ADMIN_RIGHTS, userSub)

override fun splitEntitiesByUserCanAdmin(
entitiesId: List<URI>,
Expand Down Expand Up @@ -93,18 +97,18 @@ class Neo4jAuthorizationService(
)

override fun userCanReadEntity(entityId: URI, userSub: String): Boolean =
userHasOneOfGivenRightsOnEntity(entityId, READ_RIGHT, userSub) ||
userHasOneOfGivenRightsOnEntity(entityId, READ_RIGHTS, userSub) ||
entityHasSpecificAccessPolicy(
entityId,
listOf(SpecificAccessPolicy.AUTH_WRITE, SpecificAccessPolicy.AUTH_READ)
)

override fun userCanUpdateEntity(entityId: URI, userSub: String): Boolean =
userHasOneOfGivenRightsOnEntity(entityId, WRITE_RIGHT, userSub) ||
userHasOneOfGivenRightsOnEntity(entityId, WRITE_RIGHTS, userSub) ||
entityHasSpecificAccessPolicy(entityId, listOf(SpecificAccessPolicy.AUTH_WRITE))

override fun userIsAdminOfEntity(entityId: URI, userSub: String): Boolean =
userHasOneOfGivenRightsOnEntity(entityId, ADMIN_RIGHT, userSub)
userHasOneOfGivenRightsOnEntity(entityId, ADMIN_RIGHTS, userSub)

private fun userHasOneOfGivenRightsOnEntity(
entityId: URI,
Expand Down Expand Up @@ -136,7 +140,7 @@ class Neo4jAuthorizationService(
val relationships = entitiesId.map {
Relationship(
objectId = it,
type = listOf(R_CAN_ADMIN),
type = listOf(AUTH_REL_CAN_ADMIN),
datasetId = "urn:ngsi-ld:Dataset:rCanAdmin:$it".toUri()
)
}
Expand All @@ -146,4 +150,7 @@ class Neo4jAuthorizationService(
entitiesId
)
}

override fun removeUserRightsOnEntity(entityId: URI, subjectId: URI) =
neo4jAuthorizationRepository.removeUserRightsOnEntity(subjectId, entityId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,6 @@ class StandaloneAuthorizationService : AuthorizationService {
override fun createAdminLink(entityId: URI, userSub: String) {}

override fun createAdminLinks(entitiesId: List<URI>, userSub: String) {}

override fun removeUserRightsOnEntity(entityId: URI, subjectId: URI): Int = 1
}
Loading

0 comments on commit fc6e536

Please sign in to comment.