Skip to content

Commit

Permalink
feat(authz): introduce an owner relationship when creating an entity (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
bobeal authored May 16, 2024
1 parent 0023f9f commit 59aa1b6
Show file tree
Hide file tree
Showing 23 changed files with 610 additions and 359 deletions.
1 change: 1 addition & 0 deletions search-service/config/detekt/baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<ID>ComplexCondition:EntityPayloadService.kt$EntityPayloadService$it &amp;&amp; !inverse || !it &amp;&amp; inverse</ID>
<ID>Filename:V0_29__JsonLd_migration.kt$db.migration.V0_29__JsonLd_migration.kt</ID>
<ID>LongMethod:AttributeInstanceService.kt$AttributeInstanceService$@Transactional suspend fun create(attributeInstance: AttributeInstance): Either&lt;APIException, Unit&gt;</ID>
<ID>LongMethod:EnabledAuthorizationServiceTests.kt$EnabledAuthorizationServiceTests$@Test fun `it should return serialized access control entities with other rigths if user is owner`()</ID>
<ID>LongMethod:EntityAccessControlHandler.kt$EntityAccessControlHandler$@PostMapping("/{subjectId}/attrs", consumes = [MediaType.APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE]) suspend fun addRightsOnEntities( @RequestHeader httpHeaders: HttpHeaders, @PathVariable subjectId: String, @RequestBody requestBody: Mono&lt;String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>LongMethod:EntityOperationHandlerTests.kt$EntityOperationHandlerTests$@Test fun `create batch entity should return a 207 when some entities already exist`()</ID>
<ID>LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun mergePatchProvider(): Stream&lt;Arguments&gt;</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ interface AuthorizationService {
suspend fun userCanUpdateEntity(entityId: URI, sub: Option<Sub>): Either<APIException, Unit>
suspend fun userCanAdminEntity(entityId: URI, sub: Option<Sub>): Either<APIException, Unit>

suspend fun createAdminRight(entityId: URI, sub: Option<Sub>): Either<APIException, Unit>
suspend fun createAdminRights(entitiesId: List<URI>, sub: Option<Sub>): Either<APIException, Unit>
suspend fun createOwnerRight(entityId: URI, sub: Option<Sub>): Either<APIException, Unit>
suspend fun createOwnerRights(entitiesId: List<URI>, sub: Option<Sub>): Either<APIException, Unit>
suspend fun removeRightsOnEntity(entityId: URI): Either<APIException, Unit>

suspend fun getAuthorizedEntities(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ class DisabledAuthorizationService : AuthorizationService {
override suspend fun userCanAdminEntity(entityId: URI, sub: Option<Sub>): Either<APIException, Unit> =
Unit.right()

override suspend fun createAdminRight(entityId: URI, sub: Option<Sub>): Either<APIException, Unit> =
override suspend fun createOwnerRight(entityId: URI, sub: Option<Sub>): Either<APIException, Unit> =
Unit.right()

override suspend fun createAdminRights(
override suspend fun createOwnerRights(
entitiesId: List<URI>,
sub: Option<Sub>
): Either<APIException, Unit> = Unit.right()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,23 @@ class EnabledAuthorizationService(
override suspend fun userCanReadEntity(entityId: URI, sub: Option<Sub>): Either<APIException, Unit> =
userHasOneOfGivenRightsOnEntity(
entityId,
listOf(AccessRight.R_CAN_ADMIN, AccessRight.R_CAN_WRITE, AccessRight.R_CAN_READ),
listOf(AccessRight.IS_OWNER, AccessRight.CAN_ADMIN, AccessRight.CAN_WRITE, AccessRight.CAN_READ),
listOf(SpecificAccessPolicy.AUTH_WRITE, SpecificAccessPolicy.AUTH_READ),
sub
).toAccessDecision(ENTITIY_READ_FORBIDDEN_MESSAGE)

override suspend fun userCanUpdateEntity(entityId: URI, sub: Option<Sub>): Either<APIException, Unit> =
userHasOneOfGivenRightsOnEntity(
entityId,
listOf(AccessRight.R_CAN_ADMIN, AccessRight.R_CAN_WRITE),
listOf(AccessRight.IS_OWNER, AccessRight.CAN_ADMIN, AccessRight.CAN_WRITE),
listOf(SpecificAccessPolicy.AUTH_WRITE),
sub
).toAccessDecision(ENTITY_UPDATE_FORBIDDEN_MESSAGE)

override suspend fun userCanAdminEntity(entityId: URI, sub: Option<Sub>): Either<APIException, Unit> =
userHasOneOfGivenRightsOnEntity(
entityId,
listOf(AccessRight.R_CAN_ADMIN),
listOf(AccessRight.IS_OWNER, AccessRight.CAN_ADMIN),
emptyList(),
sub
).toAccessDecision(ENTITY_ADMIN_FORBIDDEN_MESSAGE)
Expand All @@ -80,13 +80,13 @@ class EnabledAuthorizationService(
rights
)

override suspend fun createAdminRight(entityId: URI, sub: Option<Sub>): Either<APIException, Unit> =
createAdminRights(listOf(entityId), sub)
override suspend fun createOwnerRight(entityId: URI, sub: Option<Sub>): Either<APIException, Unit> =
createOwnerRights(listOf(entityId), sub)

override suspend fun createAdminRights(entitiesId: List<URI>, sub: Option<Sub>): Either<APIException, Unit> =
override suspend fun createOwnerRights(entitiesId: List<URI>, sub: Option<Sub>): Either<APIException, Unit> =
either {
entitiesId.parMap {
entityAccessRightsService.setAdminRoleOnEntity((sub as Some).value, it).bind()
entityAccessRightsService.setOwnerRoleOnEntity((sub as Some).value, it).bind()
}
}.map { it.first() }

Expand All @@ -99,33 +99,34 @@ class EnabledAuthorizationService(
sub: Option<Sub>
): Either<APIException, Pair<Int, List<ExpandedEntity>>> = either {
val accessRights = entitiesQuery.attrs.mapNotNull { AccessRight.forExpandedAttributeName(it).getOrNull() }
val entitiesAccessControl = entityAccessRightsService.getSubjectAccessRights(
val entitiesAccessRights = entityAccessRightsService.getSubjectAccessRights(
sub,
accessRights,
entitiesQuery.typeSelection,
entitiesQuery.ids,
entitiesQuery.paginationQuery
).bind()

// for each entity user is admin of, retrieve the full details of rights other users have on it
// for each entity user is admin or creator of, retrieve the full details of rights other users have on it

val entitiesWithAdminRight = entitiesAccessControl.filter {
it.right == AccessRight.R_CAN_ADMIN
val entitiesWithAdminRight = entitiesAccessRights.filter {
listOf(AccessRight.CAN_ADMIN, AccessRight.IS_OWNER).contains(it.right)
}.map { it.id }

val rightsForEntities =
val rightsForAdminEntities =
entityAccessRightsService.getAccessRightsForEntities(sub, entitiesWithAdminRight).bind()

val entitiesAccessControlWithSubjectRights = entitiesAccessControl
.map { entityAccessControl ->
if (rightsForEntities.containsKey(entityAccessControl.id)) {
val rightsForEntity = rightsForEntities[entityAccessControl.id]!!
entityAccessControl.copy(
rCanReadUsers = rightsForEntity[AccessRight.R_CAN_READ],
rCanWriteUsers = rightsForEntity[AccessRight.R_CAN_WRITE],
rCanAdminUsers = rightsForEntity[AccessRight.R_CAN_ADMIN]
val entitiesAccessControlWithSubjectRights = entitiesAccessRights
.map { entityAccessRight ->
if (rightsForAdminEntities.containsKey(entityAccessRight.id)) {
val rightsForEntity = rightsForAdminEntities[entityAccessRight.id]!!
entityAccessRight.copy(
canRead = rightsForEntity[AccessRight.CAN_READ],
canWrite = rightsForEntity[AccessRight.CAN_WRITE],
canAdmin = rightsForEntity[AccessRight.CAN_ADMIN],
owner = rightsForEntity[AccessRight.IS_OWNER]?.get(0)
)
} else entityAccessControl
} else entityAccessRight
}
.map { it.serializeProperties(contexts) }
.map { ExpandedEntity(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_SUBJECT_INFO
import com.egm.stellio.shared.util.AuthContextModel.AUTH_REL_CAN_ADMIN
import com.egm.stellio.shared.util.AuthContextModel.AUTH_REL_CAN_READ
import com.egm.stellio.shared.util.AuthContextModel.AUTH_REL_CAN_WRITE
import com.egm.stellio.shared.util.AuthContextModel.AUTH_REL_IS_OWNER
import com.egm.stellio.shared.util.AuthContextModel.DATASET_ID_PREFIX
import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_ID
import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE
Expand All @@ -29,9 +30,10 @@ data class EntityAccessRights(
// right the current user has on the entity
val right: AccessRight,
val specificAccessPolicy: AuthContextModel.SpecificAccessPolicy? = null,
val rCanAdminUsers: List<SubjectRightInfo>? = null,
val rCanWriteUsers: List<SubjectRightInfo>? = null,
val rCanReadUsers: List<SubjectRightInfo>? = null
val canAdmin: List<SubjectRightInfo>? = null,
val canWrite: List<SubjectRightInfo>? = null,
val canRead: List<SubjectRightInfo>? = null,
val owner: SubjectRightInfo? = null
) {
data class SubjectRightInfo(
val uri: URI,
Expand Down Expand Up @@ -59,21 +61,25 @@ data class EntityAccessRights(
resultEntity[AUTH_PROP_SAP] = buildExpandedPropertyValue(this)
}

rCanAdminUsers?.run {
canAdmin?.run {
resultEntity[AUTH_REL_CAN_ADMIN] = this.map {
it.serializeProperties(contexts)
}.flatten()
}
rCanWriteUsers?.run {
canWrite?.run {
resultEntity[AUTH_REL_CAN_WRITE] = this.map {
it.serializeProperties(contexts)
}.flatten()
}
rCanReadUsers?.run {
canRead?.run {
resultEntity[AUTH_REL_CAN_READ] = this.map {
it.serializeProperties(contexts)
}.flatten()
}
owner?.run {
resultEntity[AUTH_REL_IS_OWNER] = this.serializeProperties(contexts)
}

return resultEntity
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ class EntityAccessRightsService(
) {
@Transactional
suspend fun setReadRoleOnEntity(sub: Sub, entityId: URI): Either<APIException, Unit> =
setRoleOnEntity(sub, entityId, R_CAN_READ)
setRoleOnEntity(sub, entityId, CAN_READ)

@Transactional
suspend fun setWriteRoleOnEntity(sub: Sub, entityId: URI): Either<APIException, Unit> =
setRoleOnEntity(sub, entityId, R_CAN_WRITE)
setRoleOnEntity(sub, entityId, CAN_WRITE)

@Transactional
suspend fun setAdminRoleOnEntity(sub: Sub, entityId: URI): Either<APIException, Unit> =
setRoleOnEntity(sub, entityId, R_CAN_ADMIN)
suspend fun setOwnerRoleOnEntity(sub: Sub, entityId: URI): Either<APIException, Unit> =
setRoleOnEntity(sub, entityId, IS_OWNER)

@Transactional
suspend fun setRoleOnEntity(sub: Sub, entityId: URI, accessRight: AccessRight): Either<APIException, Unit> =
Expand Down Expand Up @@ -96,7 +96,7 @@ class EntityAccessRightsService(
sub,
entityId,
listOf(SpecificAccessPolicy.AUTH_READ, SpecificAccessPolicy.AUTH_WRITE),
listOf(R_CAN_READ, R_CAN_WRITE, R_CAN_ADMIN)
listOf(CAN_READ, CAN_WRITE, CAN_ADMIN, IS_OWNER)
).flatMap {
if (!it)
AccessDeniedException("User forbidden read access to entity $entityId").left()
Expand All @@ -108,13 +108,27 @@ class EntityAccessRightsService(
sub,
entityId,
listOf(SpecificAccessPolicy.AUTH_WRITE),
listOf(R_CAN_WRITE, R_CAN_ADMIN)
listOf(CAN_WRITE, CAN_ADMIN, IS_OWNER)
).flatMap {
if (!it)
AccessDeniedException("User forbidden write access to entity $entityId").left()
else Unit.right()
}

suspend fun isOwnerOfEntity(subjectId: Sub, entityId: URI): Either<APIException, Boolean> =
databaseClient
.sql(
"""
SELECT access_right
FROM entity_access_rights
WHERE subject_id = :sub
AND entity_id = :entity_id
""".trimIndent()
)
.bind("sub", subjectId)
.bind("entity_id", entityId)
.oneToResult { it["access_right"] as String == IS_OWNER.attributeName }

internal suspend fun checkHasRightOnEntity(
sub: Option<Sub>,
entityId: URI,
Expand Down Expand Up @@ -204,12 +218,12 @@ class EntityAccessRightsService(
.groupBy { it.id }
// a user may have multiple rights on a given entity (e.g., through groups memberships)
// retain the one with the "higher" right
.mapValues {
val ear = it.value.first()
.mapValues { (_, entityAccessRights) ->
val ear = entityAccessRights.first()
EntityAccessRights(
ear.id,
ear.types,
it.value.maxOf { it.right },
entityAccessRights.maxOf { it.right },
ear.specificAccessPolicy
)
}.values.toList()
Expand Down Expand Up @@ -321,7 +335,7 @@ class EntityAccessRightsService(

private fun rowToEntityAccessControl(row: Map<String, Any>, isStellioAdmin: Boolean): EntityAccessRights {
val accessRight =
if (isStellioAdmin) R_CAN_ADMIN
if (isStellioAdmin) CAN_ADMIN
else (row["access_right"] as String).let { AccessRight.forAttributeName(it) }.getOrNull()!!

return EntityAccessRights(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.egm.stellio.search.util.composeEntitiesQuery
import com.egm.stellio.shared.config.ApplicationProperties
import com.egm.stellio.shared.model.*
import com.egm.stellio.shared.util.*
import com.egm.stellio.shared.util.AuthContextModel.ALL_ASSIGNABLE_IAM_RIGHTS
import com.egm.stellio.shared.util.AuthContextModel.ALL_IAM_RIGHTS
import com.egm.stellio.shared.util.AuthContextModel.ALL_IAM_RIGHTS_TERMS
import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_SAP
Expand Down Expand Up @@ -193,7 +194,7 @@ class EntityAccessControlHandler(
// ensure payload contains only relationships and that they are of a known type
val (validAttributes, invalidAttributes) = ngsiLdAttributes.partition {
it is NgsiLdRelationship &&
ALL_IAM_RIGHTS.contains(it.name)
ALL_ASSIGNABLE_IAM_RIGHTS.contains(it.name)
}
val invalidAttributesDetails = invalidAttributes.map {
NotUpdatedDetails(it.name, "Not a relationship or not an authorized relationship name")
Expand Down Expand Up @@ -264,9 +265,14 @@ class EntityAccessControlHandler(

authorizationService.userCanAdminEntity(entityId, sub).bind()

entityAccessRightsService.removeRoleOnEntity(subjectId, entityId).bind()

ResponseEntity.status(HttpStatus.NO_CONTENT).build<String>()
val isOwnerOfEntity = entityAccessRightsService.isOwnerOfEntity(subjectId, entityId).bind()
if (!isOwnerOfEntity) {
entityAccessRightsService.removeRoleOnEntity(subjectId, entityId).bind()
ResponseEntity.status(HttpStatus.NO_CONTENT).build<String>()
} else {
AccessDeniedException(ENTITY_REMOVE_OWNERSHIP_FORBIDDEN_MESSAGE)
.left().bind<ResponseEntity<*>>()
}
}.fold(
{ it.toErrorResponse() },
{ it }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class EntityHandler(
expandedEntity,
sub.getOrNull()
).bind()
authorizationService.createAdminRight(ngsiLdEntity.id, sub).bind()
authorizationService.createOwnerRight(ngsiLdEntity.id, sub).bind()

entityEventService.publishEntityCreateEvent(
sub.getOrNull(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ class EntityOperationHandler(
) {
if (entitiesToCreate.isNotEmpty()) {
val createOperationResult = entityOperationService.create(entitiesToCreate, sub.getOrNull())
authorizationService.createAdminRights(createOperationResult.getSuccessfulEntitiesIds(), sub)
authorizationService.createOwnerRights(createOperationResult.getSuccessfulEntitiesIds(), sub)
entitiesToCreate
.filter { it.second.id in createOperationResult.getSuccessfulEntitiesIds() }
.forEach {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class TemporalEntityHandler(
sortedJsonLdInstances.removeFirstInstances(),
sub.getOrNull()
).bind()
authorizationService.createAdminRight(entityUri, sub).bind()
authorizationService.createOwnerRight(entityUri, sub).bind()

ResponseEntity.status(HttpStatus.CREATED)
.location(URI("/ngsi-ld/v1/temporal/entities/$entityUri"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
-- rename exiting authz rights
UPDATE entity_access_rights
SET access_right =
CASE
WHEN access_right = 'rCanAdmin' THEN 'canAdmin'
WHEN access_right = 'rCanWrite' THEN 'canWrite'
WHEN access_right = 'rCanReadm' THEN 'canRead'
END;

WITH entities AS (
SELECT entity_id, count(*) as admin_right_count
FROM entity_access_rights
WHERE access_right = 'canAdmin'
GROUP BY entity_id
)
UPDATE entity_access_rights
SET access_right = 'isOwner'
WHERE entity_id IN (select entity_id from entities where admin_right_count = 1)
AND access_right = 'canAdmin';

-- set isOwner for entities with more than admin right
WITH entities AS (
SELECT entity_id, count(*) as admin_right_count
FROM entity_access_rights
WHERE access_right = 'canAdmin'
GROUP BY entity_id
), entities_more_than_one_admin AS (
SELECT entity_id
FROM entities
WHERE admin_right_count > 1
), entities_with_oldest_date AS (
SELECT entity_id, min(created_at) as created_at
FROM temporal_entity_attribute
WHERE entity_id IN (select entity_id from entities_more_than_one_admin)
GROUP BY entity_id
), entities_with_oldest_sub AS (
select distinct tea.entity_id, sub
from temporal_entity_attribute tea, entities_with_oldest_date
inner join lateral (
select sub
from attribute_instance_audit
where temporal_entity_attribute = tea.id
and time_property = 'CREATED_AT'
and sub is not null
) l on true
where tea.entity_id = entities_with_oldest_date.entity_id
and tea.created_at = entities_with_oldest_date.created_at
)
update entity_access_rights
set access_right = 'isOwner',
subject_id = entities_with_oldest_sub.sub
from entities_with_oldest_sub
where entity_access_rights.entity_id = entities_with_oldest_sub.entity_id
and entity_access_rights.access_right = 'canAdmin';
Loading

0 comments on commit 59aa1b6

Please sign in to comment.