Skip to content

Commit

Permalink
Merge pull request #1281 from stellio-hub/feature/919-add-support-for…
Browse files Browse the repository at this point in the history
…-ngsild-null

feat: add support for NGSI-LD Null and deletedAt Temporal Property
  • Loading branch information
bobeal authored Jan 6, 2025
2 parents 5a9b0ba + 7095c3b commit b9bca70
Show file tree
Hide file tree
Showing 80 changed files with 2,289 additions and 1,213 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ subprojects {
runtimeOnly("io.micrometer:micrometer-registry-prometheus")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("io.projectreactor:reactor-test")
testImplementation("com.ninja-squad:springmockk:4.0.2")
testImplementation("org.springframework.security:spring-security-test")
Expand Down
3 changes: 1 addition & 2 deletions search-service/config/detekt/baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
<ID>ClassNaming:V0_29_JsonLd_migrationTests.kt$V0_29_JsonLd_migrationTests</ID>
<ID>ClassNaming:V0_29__JsonLd_migration.kt$V0_29__JsonLd_migration : BaseJavaMigration</ID>
<ID>ComplexCondition:EntitiesQueryUtils.kt$geoQuery == null &amp;&amp; q.isNullOrEmpty() &amp;&amp; typeSelection.isNullOrEmpty() &amp;&amp; attrs.isEmpty()</ID>
<ID>ComplexCondition:EntityQueryService.kt$EntityQueryService$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;, @AllowedParameters @RequestParam queryParams: MultiValueMap&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>LongMethod:EntityEventService.kt$EntityEventService$private fun publishAttributeChangeEvent( sub: String?, tenantName: String, entityId: URI, entityTypesAndPayload: Pair&lt;List&lt;ExpandedTerm&gt;, String&gt;, attributeOperationResult: SucceededAttributeOperationResult )</ID>
<ID>LongMethod:EntityHandler.kt$EntityHandler$@GetMapping("/{entityId}", produces = [APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE, GEO_JSON_CONTENT_TYPE]) suspend fun getByURI( @RequestHeader httpHeaders: HttpHeaders, @PathVariable entityId: URI, @AllowedParameters( implemented = [ QP.OPTIONS, QP.TYPE, QP.ATTRS, QP.GEOMETRY_PROPERTY, QP.LANG, QP.CONTAINED_BY, QP.JOIN, QP.JOIN_LEVEL, QP.DATASET_ID, ], notImplemented = [QP.FORMAT, QP.PICK, QP.OMIT, QP.ENTITY_MAP, QP.LOCAL, QP.VIA] ) @RequestParam queryParams: MultiValueMap&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>LongMethod:EntityHandler.kt$EntityHandler$@GetMapping(produces = [APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE, GEO_JSON_CONTENT_TYPE]) suspend fun getEntities( @RequestHeader httpHeaders: HttpHeaders, @AllowedParameters( implemented = [ QP.OPTIONS, QP.COUNT, QP.OFFSET, QP.LIMIT, QP.ID, QP.TYPE, QP.ID_PATTERN, QP.ATTRS, QP.Q, QP.GEOMETRY, QP.GEOREL, QP.COORDINATES, QP.GEOPROPERTY, QP.GEOMETRY_PROPERTY, QP.LANG, QP.SCOPEQ, QP.CONTAINED_BY, QP.JOIN, QP.JOIN_LEVEL, QP.DATASET_ID, ], notImplemented = [QP.FORMAT, QP.PICK, QP.OMIT, QP.EXPAND_VALUES, QP.CSF, QP.ENTITY_MAP, QP.LOCAL, QP.VIA] ) @RequestParam queryParams: MultiValueMap&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>LongMethod:LinkedEntityServiceTests.kt$LinkedEntityServiceTests$@Test fun `it should inline entities up to the asked 2nd level`()</ID>
Expand All @@ -28,7 +28,6 @@
<ID>LongParameterList:EntityAttributeService.kt$EntityAttributeService$( entityId: URI, attributeName: ExpandedTerm, attributeMetadata: AttributeMetadata, createdAt: ZonedDateTime, attributePayload: ExpandedAttributeInstance, sub: Sub? )</ID>
<ID>LongParameterList:EntityAttributeService.kt$EntityAttributeService$( entityUri: URI, ngsiLdAttributes: List&lt;NgsiLdAttribute&gt;, expandedAttributes: ExpandedAttributes, createdAt: ZonedDateTime, observedAt: ZonedDateTime?, sub: Sub? )</ID>
<ID>LongParameterList:EntityAttributeService.kt$EntityAttributeService$( entityUri: URI, ngsiLdAttributes: List&lt;NgsiLdAttribute&gt;, expandedAttributes: ExpandedAttributes, disallowOverwrite: Boolean, createdAt: ZonedDateTime, sub: Sub? )</ID>
<ID>LongParameterList:EntityEventService.kt$EntityEventService$( updatedDetails: UpdatedDetails, sub: String?, tenantName: String, entityId: URI, entityTypesAndPayload: Pair&lt;List&lt;ExpandedTerm&gt;, String&gt;, serializedAttribute: Pair&lt;ExpandedTerm, String&gt;, overwrite: Boolean )</ID>
<ID>LongParameterList:TemporalEntityHandler.kt$TemporalEntityHandler$( @RequestHeader httpHeaders: HttpHeaders, @PathVariable entityId: URI, @PathVariable attrId: String, @PathVariable instanceId: URI, @RequestBody requestBody: Mono&lt;String&gt;, @AllowedParameters(notImplemented = [QP.LOCAL, QP.VIA]) @RequestParam queryParams: MultiValueMap&lt;String, String&gt; )</ID>
<ID>LongParameterList:V0_29__JsonLd_migration.kt$V0_29__JsonLd_migration$( entityId: URI, attributeName: ExpandedTerm, datasetId: URI?, attributePayload: ExpandedAttributeInstance, ngsiLdAttributeInstance: NgsiLdAttributeInstance, defaultCreatedAt: ZonedDateTime )</ID>
<ID>NestedBlockDepth:V0_29__JsonLd_migration.kt$V0_29__JsonLd_migration$override fun migrate(context: Context)</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ class IAMListener(
// (if it no longer exists, it fails because of access rights checks)
if (searchProperties.onOwnerDeleteCascadeEntities && subjectType == SubjectType.USER) {
entityAccessRightsService.getEntitiesIdsOwnedBySubject(sub).getOrNull()?.forEach { entityId ->
entityService.deleteEntity(entityId, sub)
entityService.permanentlyDeleteEntity(entityId, sub)
}
Unit.right()
} else Unit.right()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.egm.stellio.shared.model.addNonReifiedProperty
import com.egm.stellio.shared.model.addSubAttribute
import com.egm.stellio.shared.util.AccessRight
import com.egm.stellio.shared.util.AuthContextModel
import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_IS_DELETED
import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_RIGHT
import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_SAP
import com.egm.stellio.shared.util.AuthContextModel.AUTH_PROP_SUBJECT_INFO
Expand All @@ -27,6 +28,7 @@ import java.net.URI
data class EntityAccessRights(
val id: URI,
val types: List<ExpandedTerm>,
val isDeleted: Boolean = false,
// right the current user has on the entity
val right: AccessRight,
val specificAccessPolicy: AuthContextModel.SpecificAccessPolicy? = null,
Expand Down Expand Up @@ -55,6 +57,8 @@ data class EntityAccessRights(

resultEntity[JSONLD_ID] = id.toString()
resultEntity[JSONLD_TYPE] = types
if (isDeleted)
resultEntity[AUTH_PROP_IS_DELETED] = buildExpandedPropertyValue(true)
resultEntity[AUTH_PROP_RIGHT] = buildExpandedPropertyValue(right.attributeName)

specificAccessPolicy?.run {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface AuthorizationService {

suspend fun getAuthorizedEntities(
entitiesQuery: EntitiesQueryFromGet,
includeDeleted: Boolean,
contexts: List<String>,
sub: Option<Sub>
): Either<APIException, Pair<Int, List<ExpandedEntity>>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class DisabledAuthorizationService : AuthorizationService {

override suspend fun getAuthorizedEntities(
entitiesQuery: EntitiesQueryFromGet,
includeDeleted: Boolean,
contexts: List<String>,
sub: Option<Sub>
): Either<APIException, Pair<Int, List<ExpandedEntity>>> = Pair(-1, emptyList<ExpandedEntity>()).right()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,16 @@ class EnabledAuthorizationService(

override suspend fun getAuthorizedEntities(
entitiesQuery: EntitiesQueryFromGet,
includeDeleted: Boolean,
contexts: List<String>,
sub: Option<Sub>
): Either<APIException, Pair<Int, List<ExpandedEntity>>> = either {
val accessRights = entitiesQuery.attrs.mapNotNull { AccessRight.forExpandedAttributeName(it).getOrNull() }
val entitiesAccessRights = entityAccessRightsService.getSubjectAccessRights(
sub,
accessRights,
entitiesQuery.typeSelection,
entitiesQuery.ids,
entitiesQuery.paginationQuery
entitiesQuery,
includeDeleted
).bind()

// for each entity user is admin or creator of, retrieve the full details of rights other users have on it
Expand Down Expand Up @@ -148,7 +148,8 @@ class EnabledAuthorizationService(
sub,
accessRights,
entitiesQuery.typeSelection,
entitiesQuery.ids
entitiesQuery.ids,
includeDeleted
).bind()

Pair(count, entitiesAccessControlWithSubjectRights)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import com.egm.stellio.search.common.util.toJsonString
import com.egm.stellio.search.common.util.toList
import com.egm.stellio.search.common.util.toOptionalEnum
import com.egm.stellio.search.common.util.toUri
import com.egm.stellio.search.entity.model.EntitiesQueryFromGet
import com.egm.stellio.shared.config.ApplicationProperties
import com.egm.stellio.shared.model.APIException
import com.egm.stellio.shared.model.AccessDeniedException
import com.egm.stellio.shared.model.EntityTypeSelection
import com.egm.stellio.shared.model.NgsiLdAttribute
import com.egm.stellio.shared.model.ResourceNotFoundException
import com.egm.stellio.shared.queryparameter.PaginationQuery
import com.egm.stellio.shared.util.AccessRight
import com.egm.stellio.shared.util.AccessRight.CAN_ADMIN
import com.egm.stellio.shared.util.AccessRight.CAN_READ
Expand Down Expand Up @@ -220,30 +220,32 @@ class EntityAccessRightsService(
suspend fun getSubjectAccessRights(
sub: Option<Sub>,
accessRights: List<AccessRight>,
type: EntityTypeSelection? = null,
ids: Set<URI>? = null,
paginationQuery: PaginationQuery,
entitiesQuery: EntitiesQueryFromGet,
includeDeleted: Boolean = false
): Either<APIException, List<EntityAccessRights>> = either {
val ids = entitiesQuery.ids
val typeSelection = entitiesQuery.typeSelection
val subjectUuids = subjectReferentialService.getSubjectAndGroupsUUID(sub).bind()
val isStellioAdmin = subjectReferentialService.hasStellioAdminRole(subjectUuids).bind()

databaseClient
.sql(
"""
SELECT ep.entity_id, ep.types, ear.access_right, ep.specific_access_policy
SELECT ep.entity_id, ep.types, ear.access_right, ep.specific_access_policy, ep.deleted_at
FROM entity_access_rights ear
LEFT JOIN entity_payload ep ON ear.entity_id = ep.entity_id
WHERE ${if (isStellioAdmin) "1 = 1" else "subject_id IN (:subject_uuids)" }
${if (accessRights.isNotEmpty()) " AND access_right IN (:access_rights)" else ""}
${if (!type.isNullOrEmpty()) " AND (${buildTypeQuery(type)})" else ""}
${if (!ids.isNullOrEmpty()) " AND ear.entity_id IN (:entities_ids)" else ""}
${if (!typeSelection.isNullOrEmpty()) " AND (${buildTypeQuery(typeSelection)})" else ""}
${if (ids.isNotEmpty()) " AND ear.entity_id IN (:entities_ids)" else ""}
${if (!includeDeleted) " AND deleted_at IS NULL" else ""}
ORDER BY entity_id
LIMIT :limit
OFFSET :offset;
""".trimIndent()
)
.bind("limit", paginationQuery.limit)
.bind("offset", paginationQuery.offset)
.bind("limit", entitiesQuery.paginationQuery.limit)
.bind("offset", entitiesQuery.paginationQuery.offset)
.let {
if (!isStellioAdmin)
it.bind("subject_uuids", subjectUuids)
Expand All @@ -255,7 +257,7 @@ class EntityAccessRightsService(
else it
}
.let {
if (!ids.isNullOrEmpty())
if (ids.isNotEmpty())
it.bind("entities_ids", ids)
else it
}
Expand All @@ -268,6 +270,7 @@ class EntityAccessRightsService(
EntityAccessRights(
ear.id,
ear.types,
ear.isDeleted,
entityAccessRights.maxOf { it.right },
ear.specificAccessPolicy
)
Expand All @@ -278,7 +281,8 @@ class EntityAccessRightsService(
sub: Option<Sub>,
accessRights: List<AccessRight>,
type: EntityTypeSelection? = null,
ids: Set<URI>? = null
ids: Set<URI>? = null,
includeDeleted: Boolean = false
): Either<APIException, Int> = either {
val subjectUuids = subjectReferentialService.getSubjectAndGroupsUUID(sub).bind()
val isStellioAdmin = subjectReferentialService.hasStellioAdminRole(subjectUuids).bind()
Expand All @@ -293,6 +297,7 @@ class EntityAccessRightsService(
${if (accessRights.isNotEmpty()) " AND access_right IN (:access_rights)" else ""}
${if (!type.isNullOrEmpty()) " AND (${buildTypeQuery(type)})" else ""}
${if (!ids.isNullOrEmpty()) " AND ear.entity_id IN (:entities_ids)" else ""}
${if (!includeDeleted) " AND deleted_at IS NULL" else ""}
""".trimIndent()
)
.let {
Expand Down Expand Up @@ -443,6 +448,7 @@ class EntityAccessRightsService(
return EntityAccessRights(
id = toUri(row["entity_id"]),
types = toList(row["types"]),
isDeleted = row["deleted_at"] != null,
right = accessRight,
specificAccessPolicy = toOptionalEnum<SpecificAccessPolicy>(row["specific_access_policy"])
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import arrow.core.left
import arrow.core.raise.either
import com.egm.stellio.search.authorization.service.AuthorizationService
import com.egm.stellio.search.authorization.service.EntityAccessRightsService
import com.egm.stellio.search.entity.model.FailedAttributeOperationResult
import com.egm.stellio.search.entity.model.NotUpdatedDetails
import com.egm.stellio.search.entity.model.UpdateAttributeResult
import com.egm.stellio.search.entity.model.UpdateOperationResult
import com.egm.stellio.search.entity.model.updateResultFromDetailedResult
import com.egm.stellio.search.entity.model.OperationStatus
import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult
import com.egm.stellio.search.entity.model.UpdateResult
import com.egm.stellio.search.entity.util.composeEntitiesQueryFromGet
import com.egm.stellio.shared.config.ApplicationProperties
import com.egm.stellio.shared.model.AccessDeniedException
Expand All @@ -19,6 +20,7 @@ import com.egm.stellio.shared.model.toNgsiLdAttribute
import com.egm.stellio.shared.model.toNgsiLdAttributes
import com.egm.stellio.shared.queryparameter.AllowedParameters
import com.egm.stellio.shared.queryparameter.QP
import com.egm.stellio.shared.queryparameter.QueryParameter
import com.egm.stellio.shared.util.AccessRight
import com.egm.stellio.shared.util.AuthContextModel.ALL_ASSIGNABLE_IAM_RIGHTS
import com.egm.stellio.shared.util.AuthContextModel.ALL_IAM_RIGHTS
Expand Down Expand Up @@ -70,10 +72,11 @@ class EntityAccessControlHandler(
@GetMapping("/entities", produces = [MediaType.APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE])
suspend fun getAuthorizedEntities(
@RequestHeader httpHeaders: HttpHeaders,
@AllowedParameters(implemented = [QP.ID, QP.TYPE, QP.ATTRS, QP.COUNT, QP.OFFSET, QP.LIMIT])
@AllowedParameters(implemented = [QP.ID, QP.TYPE, QP.ATTRS, QP.COUNT, QP.OFFSET, QP.LIMIT, QP.INCLUDE_DELETED])
@RequestParam queryParams: MultiValueMap<String, String>
): ResponseEntity<*> = either {
val sub = getSubFromSecurityContext()
val includeDeleted = queryParams.getFirst(QueryParameter.INCLUDE_DELETED.key)?.toBoolean() == true

val contexts = getAuthzContextFromLinkHeaderOrDefault(httpHeaders, applicationProperties.contexts).bind()
val mediaType = getApplicableMediaType(httpHeaders).bind()
Expand All @@ -91,6 +94,7 @@ class EntityAccessControlHandler(

val (count, entities) = authorizationService.getAuthorizedEntities(
entitiesQuery,
includeDeleted,
contexts,
sub
).bind()
Expand Down Expand Up @@ -254,24 +258,24 @@ class EntityAccessControlHandler(
AccessRight.forAttributeName(ngsiLdRel.name).getOrNull()!!
).fold(
ifLeft = { apiException ->
UpdateAttributeResult(
FailedAttributeOperationResult(
ngsiLdRel.name,
ngsiLdRelInstance.datasetId,
UpdateOperationResult.FAILED,
OperationStatus.FAILED,
apiException.message
)
},
ifRight = {
UpdateAttributeResult(
SucceededAttributeOperationResult(
ngsiLdRel.name,
ngsiLdRelInstance.datasetId,
UpdateOperationResult.APPENDED,
null
OperationStatus.APPENDED,
emptyMap()
)
}
)
}
val appendResult = updateResultFromDetailedResult(results)
val appendResult = UpdateResult(results)

if (invalidAttributes.isEmpty() && unauthorizedInstances.isEmpty())
ResponseEntity.status(HttpStatus.NO_CONTENT).build<String>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class AttributeService(
"""
SELECT DISTINCT(attribute_name)
FROM temporal_entity_attribute
WHERE deleted_at IS NULL
ORDER BY attribute_name
""".trimIndent()
).allToMappedList { rowToAttributeNames(it) }
Expand All @@ -42,7 +43,10 @@ class AttributeService(
"""
SELECT types, attribute_name
FROM entity_payload
JOIN temporal_entity_attribute ON entity_payload.entity_id = temporal_entity_attribute.entity_id
JOIN temporal_entity_attribute
ON entity_payload.entity_id = temporal_entity_attribute.entity_id
AND temporal_entity_attribute.deleted_at IS NULL
WHERE entity_payload.deleted_at IS NULL
ORDER BY attribute_name
""".trimIndent()
).allToMappedList { rowToAttributeDetails(it) }.flatten().groupBy({ it.second }, { it.first }).toList()
Expand All @@ -65,11 +69,14 @@ class AttributeService(
WITH entities AS (
SELECT entity_id, attribute_name, attribute_type
FROM temporal_entity_attribute
WHERE attribute_name = :attribute_name
WHERE attribute_name = :attribute_name
AND deleted_at IS NULL
)
SELECT attribute_name, attribute_type, types, count(distinct(attribute_name)) as attribute_count
FROM entity_payload
JOIN entities ON entity_payload.entity_id = entities.entity_id
JOIN entities
ON entity_payload.entity_id = entities.entity_id
AND entity_payload.deleted_at IS NULL
GROUP BY types, attribute_name, attribute_type
""".trimIndent()
)
Expand Down
Loading

0 comments on commit b9bca70

Please sign in to comment.