Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for deletedAt and NGSI-LD Null #1281

Open
wants to merge 32 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5306ba3
feat: bootstrap support for NGSI-LD Null
bobeal Nov 30, 2024
caf6b29
feat(wip): add support for deletedAt temporal property
bobeal Dec 1, 2024
ea54f7f
feat: update payload in tea when an attribute is deleted
bobeal Dec 4, 2024
4514778
feat: permanently delete attributes when deleted from temporal endpoint
bobeal Dec 4, 2024
a7f6f36
feat(discovery): filter deleted attributes and entities
bobeal Dec 5, 2024
679b251
chore: fix detekt error
bobeal Dec 5, 2024
3fba74f
feat(core): excluded deleted attributes when building current state o…
bobeal Dec 6, 2024
3adba2c
feat(core): handle previous deleted attributes when creating a new one
bobeal Dec 7, 2024
73760d3
feat: support for delete and temporal delete
bobeal Dec 8, 2024
458e1be
test: add some tests for TemporalService
bobeal Dec 8, 2024
bb3a29a
add test for permanent deletion of an entity
bobeal Dec 8, 2024
346fb1a
add test for permanent deletion of attributes
bobeal Dec 9, 2024
fe270a0
fix(temporal): do not filter deleted attributes / entities in tempora…
bobeal Dec 11, 2024
d204a3d
fix(temporal): simplified temporal representation
bobeal Dec 11, 2024
4b04af1
feat(core): add support for NGSI-LD Null in Merge Entity operation
bobeal Dec 14, 2024
5237ef7
fix(core): id can optionally be included in Entity Fragments
bobeal Dec 14, 2024
6d45d12
feat(core): add support for NGSI-LD Null in Update Attributes operation
bobeal Dec 15, 2024
f666b24
feat(core): add support for NGSI-LD Null in Partial Attribute Update …
bobeal Dec 15, 2024
8e6b801
feat: add support for Deleted events in event service
bobeal Dec 15, 2024
36bbc96
feat: align scope history with new history management
bobeal Dec 17, 2024
109ddff
feat(gdpr): permanently delete entities when cascading deletion of an…
bobeal Dec 19, 2024
420dae3
fix: do not try to delete attributes instances if no attributes
bobeal Dec 19, 2024
21e0f76
chore: misc refactoring
bobeal Dec 19, 2024
c166925
refactor: getEntityState -> isMarkedAsDeleted
bobeal Dec 20, 2024
92b600e
feat(authz): add option to include deleted entities in get authorized…
bobeal Dec 21, 2024
f736e27
feat(core): add the deleted representation when deleting an entity
bobeal Dec 21, 2024
6123e50
chore: rename migration to next free number
bobeal Dec 21, 2024
c0fb710
refactor: clarify hasNgsiLdNull function
bobeal Dec 21, 2024
b8b4c1d
try fix flaky test
bobeal Dec 22, 2024
e55f767
fix(core): send deleted payload, not payload before the deletion
bobeal Dec 23, 2024
d522201
fix(text): better time comparison
bobeal Dec 23, 2024
8f70cea
feat: redo not totally working Testcontainers config for Kafka
bobeal Dec 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 1 addition & 1 deletion search-service/config/detekt/baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
<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: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:EntityEventService.kt$EntityEventService$private fun publishAttributeChangeEvent( 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>LongMethod:LinkedEntityServiceTests.kt$LinkedEntityServiceTests$@Test fun `it should inline entities up to the asked 2nd level`()</ID>
<ID>LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun mergePatchProvider(): Stream&lt;Arguments&gt;</ID>
<ID>LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun partialUpdatePatchProvider(): Stream&lt;Arguments&gt;</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 @@ -19,6 +19,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 +71,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 +93,7 @@ class EntityAccessControlHandler(

val (count, entities) = authorizationService.getAuthorizedEntities(
entitiesQuery,
includeDeleted,
contexts,
sub
).bind()
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class EntityTypeService(
"""
SELECT DISTINCT(unnest(types)) as type
FROM entity_payload
WHERE deleted_at IS NULL
ORDER BY type
""".trimIndent()
).allToMappedList { rowToType(it) }
Expand All @@ -41,7 +42,10 @@ class EntityTypeService(
"""
SELECT unnest(types) as type, 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 type
""".trimIndent()
).allToMappedList { rowToEntityType(it) }.groupBy({ it.first }, { it.second }).toList()
Expand All @@ -65,10 +69,12 @@ class EntityTypeService(
SELECT entity_id
FROM entity_payload
WHERE :type_name = any (types)
AND deleted_at IS NULL
)
SELECT attribute_name, attribute_type, (select count(entity_id) from entities) as entity_count
FROM temporal_entity_attribute
WHERE entity_id IN (SELECT entity_id FROM entities)
AND deleted_at IS NULL
GROUP BY attribute_name, attribute_type
""".trimIndent()
)
Expand Down
Loading
Loading