From aace8a0b2e0b5d9ac8a7c7d84eb04efa5b30831a Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Wed, 15 Oct 2025 19:33:53 +0300 Subject: [PATCH 01/12] refactor: Move `UserDisplayName` to shared `api-model` `UserDisplayName` can then be used in components. Signed-off-by: Johanna Lamppu --- .../src/commonMain/kotlin/ApiMappings.kt | 4 --- api/v1/model/src/commonMain/kotlin/OrtRun.kt | 2 ++ .../src/commonMain/kotlin/OrtRunSummary.kt | 2 ++ .../main/kotlin/apiDocs/RepositoriesDocs.kt | 2 +- core/src/main/kotlin/apiDocs/RunsDocs.kt | 2 +- .../kotlin/UserDisplayNameMappings.kt | 25 +++++++++++++++++++ .../src/commonMain/kotlin/UserDisplayName.kt | 2 +- 7 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 shared/api-mappings/src/commonMain/kotlin/UserDisplayNameMappings.kt rename {api/v1/model => shared/api-model}/src/commonMain/kotlin/UserDisplayName.kt (96%) diff --git a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt index dbceb671c6..09e9531e40 100644 --- a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt +++ b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt @@ -78,7 +78,6 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.ShortestDependencyPath as Api import org.eclipse.apoapsis.ortserver.api.v1.model.SourceCodeOrigin as ApiSourceCodeOrigin import org.eclipse.apoapsis.ortserver.api.v1.model.SubmoduleFetchStrategy as ApiSubmoduleFetchStrategy import org.eclipse.apoapsis.ortserver.api.v1.model.User as ApiUser -import org.eclipse.apoapsis.ortserver.api.v1.model.UserDisplayName as ApiUserDisplayName import org.eclipse.apoapsis.ortserver.api.v1.model.UserGroup as ApiUserGroup import org.eclipse.apoapsis.ortserver.api.v1.model.VcsInfo as ApiVcsInfo import org.eclipse.apoapsis.ortserver.api.v1.model.VcsInfoCurationData as ApiVcsInfoCurationData @@ -126,7 +125,6 @@ import org.eclipse.apoapsis.ortserver.model.Severity import org.eclipse.apoapsis.ortserver.model.SourceCodeOrigin import org.eclipse.apoapsis.ortserver.model.SubmoduleFetchStrategy import org.eclipse.apoapsis.ortserver.model.User -import org.eclipse.apoapsis.ortserver.model.UserDisplayName import org.eclipse.apoapsis.ortserver.model.UserGroup import org.eclipse.apoapsis.ortserver.model.VulnerabilityFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityForRunsFilters @@ -842,8 +840,6 @@ fun Project.mapToApi() = ApiProject( scopeNames = scopeNames ) -fun UserDisplayName.mapToApi() = ApiUserDisplayName(username = username, fullName = fullName) - fun ContentManagementSection.mapToApi() = ApiContentManagementSection( id = id, isEnabled = isEnabled, diff --git a/api/v1/model/src/commonMain/kotlin/OrtRun.kt b/api/v1/model/src/commonMain/kotlin/OrtRun.kt index 3baa5e2d82..83d01f8bd9 100644 --- a/api/v1/model/src/commonMain/kotlin/OrtRun.kt +++ b/api/v1/model/src/commonMain/kotlin/OrtRun.kt @@ -22,6 +22,8 @@ package org.eclipse.apoapsis.ortserver.api.v1.model import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName + @Serializable data class OrtRun( /** diff --git a/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt b/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt index 11f5370886..c72b84a7fc 100644 --- a/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt +++ b/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt @@ -22,6 +22,8 @@ package org.eclipse.apoapsis.ortserver.api.v1.model import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName + /** * The summary of an ORT run. */ diff --git a/core/src/main/kotlin/apiDocs/RepositoriesDocs.kt b/core/src/main/kotlin/apiDocs/RepositoriesDocs.kt index 869774045a..c94cb745db 100644 --- a/core/src/main/kotlin/apiDocs/RepositoriesDocs.kt +++ b/core/src/main/kotlin/apiDocs/RepositoriesDocs.kt @@ -55,7 +55,6 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.ScannerJob import org.eclipse.apoapsis.ortserver.api.v1.model.ScannerJobConfiguration import org.eclipse.apoapsis.ortserver.api.v1.model.SubmoduleFetchStrategy.FULLY_RECURSIVE import org.eclipse.apoapsis.ortserver.api.v1.model.User -import org.eclipse.apoapsis.ortserver.api.v1.model.UserDisplayName import org.eclipse.apoapsis.ortserver.api.v1.model.UserGroup import org.eclipse.apoapsis.ortserver.api.v1.model.UserWithGroups import org.eclipse.apoapsis.ortserver.api.v1.model.Username @@ -65,6 +64,7 @@ import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagingData import org.eclipse.apoapsis.ortserver.shared.apimodel.SortDirection import org.eclipse.apoapsis.ortserver.shared.apimodel.SortProperty +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody import org.eclipse.apoapsis.ortserver.shared.ktorutils.standardListQueryParameters diff --git a/core/src/main/kotlin/apiDocs/RunsDocs.kt b/core/src/main/kotlin/apiDocs/RunsDocs.kt index d56a467099..ad51047d8d 100644 --- a/core/src/main/kotlin/apiDocs/RunsDocs.kt +++ b/core/src/main/kotlin/apiDocs/RunsDocs.kt @@ -52,7 +52,6 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.RuleViolation import org.eclipse.apoapsis.ortserver.api.v1.model.RuleViolationResolution import org.eclipse.apoapsis.ortserver.api.v1.model.Severity import org.eclipse.apoapsis.ortserver.api.v1.model.ShortestDependencyPath -import org.eclipse.apoapsis.ortserver.api.v1.model.UserDisplayName import org.eclipse.apoapsis.ortserver.api.v1.model.VcsInfo import org.eclipse.apoapsis.ortserver.api.v1.model.Vulnerability import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityRating @@ -64,6 +63,7 @@ import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedSearchResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagingData import org.eclipse.apoapsis.ortserver.shared.apimodel.SortDirection import org.eclipse.apoapsis.ortserver.shared.apimodel.SortProperty +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody import org.eclipse.apoapsis.ortserver.shared.ktorutils.standardListQueryParameters diff --git a/shared/api-mappings/src/commonMain/kotlin/UserDisplayNameMappings.kt b/shared/api-mappings/src/commonMain/kotlin/UserDisplayNameMappings.kt new file mode 100644 index 0000000000..ba84083f7a --- /dev/null +++ b/shared/api-mappings/src/commonMain/kotlin/UserDisplayNameMappings.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimappings + +import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName as ApiUserDisplayName + +fun UserDisplayName.mapToApi() = ApiUserDisplayName(username = username, fullName = fullName) diff --git a/api/v1/model/src/commonMain/kotlin/UserDisplayName.kt b/shared/api-model/src/commonMain/kotlin/UserDisplayName.kt similarity index 96% rename from api/v1/model/src/commonMain/kotlin/UserDisplayName.kt rename to shared/api-model/src/commonMain/kotlin/UserDisplayName.kt index 7ce6175c10..00ccdd8c70 100644 --- a/api/v1/model/src/commonMain/kotlin/UserDisplayName.kt +++ b/shared/api-model/src/commonMain/kotlin/UserDisplayName.kt @@ -17,7 +17,7 @@ * License-Filename: LICENSE */ -package org.eclipse.apoapsis.ortserver.api.v1.model +package org.eclipse.apoapsis.ortserver.shared.apimodel import kotlinx.serialization.Serializable From 96d93e4720ba10f0c16515c4457d6546e5db100b Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Thu, 23 Oct 2025 15:08:53 +0300 Subject: [PATCH 02/12] feat: Allow to mark runs as outdated This is for cases where for example new resolutions have been added for items found in the run, so that the message can be shown in the UI. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- .../src/commonMain/kotlin/ApiMappings.kt | 8 +++-- api/v1/model/src/commonMain/kotlin/OrtRun.kt | 12 ++++++- .../src/commonMain/kotlin/OrtRunSummary.kt | 12 ++++++- .../repositories/ortrun/OrtRunsTable.kt | 6 ++++ .../migration/V120__addOutdatedToOrtRun.sql | 3 ++ model/src/commonMain/kotlin/OrtRun.kt | 12 ++++++- .../ort-run/src/main/kotlin/OrtRunService.kt | 14 +++++++++ .../src/test/kotlin/OrtRunServiceTest.kt | 31 +++++++++++++++++++ 8 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 dao/src/main/resources/db/migration/V120__addOutdatedToOrtRun.sql diff --git a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt index 09e9531e40..a90be5b6bc 100644 --- a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt +++ b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt @@ -391,7 +391,9 @@ fun OrtRun.mapToApi(jobs: ApiJobs) = resolvedJobConfigContext = resolvedJobConfigContext, environmentConfigPath = environmentConfigPath, traceId = traceId, - userDisplayName = userDisplayName?.mapToApi() + userDisplayName = userDisplayName?.mapToApi(), + outdated = outdated, + outdatedMessage = outdatedMessage ) fun OrtRun.mapToApiSummary(jobs: ApiJobSummaries) = @@ -412,7 +414,9 @@ fun OrtRun.mapToApiSummary(jobs: ApiJobSummaries) = jobConfigContext = jobConfigContext, resolvedJobConfigContext = resolvedJobConfigContext, environmentConfigPath = environmentConfigPath, - userDisplayName = userDisplayName?.mapToApi() + userDisplayName = userDisplayName?.mapToApi(), + outdated = outdated, + outdatedMessage = outdatedMessage ) fun OrtRunSummary.mapToApi() = diff --git a/api/v1/model/src/commonMain/kotlin/OrtRun.kt b/api/v1/model/src/commonMain/kotlin/OrtRun.kt index 83d01f8bd9..87d6a5837e 100644 --- a/api/v1/model/src/commonMain/kotlin/OrtRun.kt +++ b/api/v1/model/src/commonMain/kotlin/OrtRun.kt @@ -138,7 +138,17 @@ data class OrtRun( /** * The display name of the user that triggered the scan. */ - val userDisplayName: UserDisplayName? = null + val userDisplayName: UserDisplayName? = null, + + /** + * A flag to indicate if the results of the run are outdated, e.g. because of a new resolution. + */ + val outdated: Boolean = false, + + /** + * A message describing why the results of the run are outdated. + */ + val outdatedMessage: String? = null ) /** diff --git a/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt b/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt index c72b84a7fc..802a6148dc 100644 --- a/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt +++ b/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt @@ -118,7 +118,17 @@ data class OrtRunSummary( /** * The display name of the user that triggered this run. */ - val userDisplayName: UserDisplayName? = null + val userDisplayName: UserDisplayName? = null, + + /** + * A flag to indicate if the results of the run are outdated, e.g. because of a new resolution. + */ + val outdated: Boolean = false, + + /** + * A message describing why the results of the run are outdated. + */ + val outdatedMessage: String? = null ) /** diff --git a/dao/src/main/kotlin/repositories/ortrun/OrtRunsTable.kt b/dao/src/main/kotlin/repositories/ortrun/OrtRunsTable.kt index 6014547360..67706aef66 100644 --- a/dao/src/main/kotlin/repositories/ortrun/OrtRunsTable.kt +++ b/dao/src/main/kotlin/repositories/ortrun/OrtRunsTable.kt @@ -83,6 +83,8 @@ object OrtRunsTable : SortableTable("ort_runs") { val traceId = text("trace_id").nullable() val environmentConfigPath = text("environment_config_path").nullable() val userDisplayName = reference("user_id", UserDisplayNamesTable.id).nullable() + val outdated = bool("outdated").default(false) + val outdatedMessage = text("outdated_message").nullable() /** Get the id of the analyzer run for the given ORT run [id]. Returns `null` if no run is found. */ fun getAnalyzerRunIdById(id: Long): Long? = @@ -117,6 +119,8 @@ class OrtRunDao(id: EntityID) : LongEntity(id) { var vcsProcessedId by OrtRunsTable.vcsProcessedId var environmentConfigPath by OrtRunsTable.environmentConfigPath var userDisplayName by UserDisplayNameDao optionalReferencedOn OrtRunsTable.userDisplayName + var outdated by OrtRunsTable.outdated + var outdatedMessage by OrtRunsTable.outdatedMessage val advisorJob by AdvisorJobDao optionalBackReferencedOn AdvisorJobsTable.ortRunId val analyzerJob by AnalyzerJobDao optionalBackReferencedOn AnalyzerJobsTable.ortRunId @@ -152,6 +156,8 @@ class OrtRunDao(id: EntityID) : LongEntity(id) { traceId = traceId, environmentConfigPath = environmentConfigPath, userDisplayName = userDisplayName?.mapToModel(), + outdated = outdated, + outdatedMessage = outdatedMessage ) /** diff --git a/dao/src/main/resources/db/migration/V120__addOutdatedToOrtRun.sql b/dao/src/main/resources/db/migration/V120__addOutdatedToOrtRun.sql new file mode 100644 index 0000000000..3166e2619d --- /dev/null +++ b/dao/src/main/resources/db/migration/V120__addOutdatedToOrtRun.sql @@ -0,0 +1,3 @@ +ALTER TABLE ort_runs + ADD COLUMN outdated boolean DEFAULT FALSE NOT NULL, + ADD COLUMN outdated_message text NULL; diff --git a/model/src/commonMain/kotlin/OrtRun.kt b/model/src/commonMain/kotlin/OrtRun.kt index 8e4382fb00..f2085b6068 100644 --- a/model/src/commonMain/kotlin/OrtRun.kt +++ b/model/src/commonMain/kotlin/OrtRun.kt @@ -155,7 +155,17 @@ data class OrtRun( /** * Name of the user that triggered this run. */ - val userDisplayName: UserDisplayName? = null + val userDisplayName: UserDisplayName? = null, + + /** + * A flag to indicate if the results of the run are outdated, e.g. because of a new resolution. + */ + val outdated: Boolean = false, + + /** + * A message describing why the results of the run are outdated. + */ + val outdatedMessage: String? = null ) enum class OrtRunStatus( diff --git a/services/ort-run/src/main/kotlin/OrtRunService.kt b/services/ort-run/src/main/kotlin/OrtRunService.kt index 919ad8540a..0cb0498893 100644 --- a/services/ort-run/src/main/kotlin/OrtRunService.kt +++ b/services/ort-run/src/main/kotlin/OrtRunService.kt @@ -25,6 +25,7 @@ import kotlinx.datetime.Instant import org.eclipse.apoapsis.ortserver.dao.blockingQuery import org.eclipse.apoapsis.ortserver.dao.dbQuery import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunDao +import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunsTable import org.eclipse.apoapsis.ortserver.dao.tables.NestedRepositoriesTable import org.eclipse.apoapsis.ortserver.dao.tables.shared.VcsInfoDao import org.eclipse.apoapsis.ortserver.model.AdvisorJob @@ -73,6 +74,7 @@ import org.eclipse.apoapsis.ortserver.services.ResourceNotFoundException import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.update import org.ossreviewtoolkit.model.FileList import org.ossreviewtoolkit.model.OrtResult @@ -438,6 +440,18 @@ class OrtRunService( ) } + /** + * Mark the ORT runs with the given [ortRunIds] as outdated with the provided [outdatedMessage]. + */ + fun markAsOutdated(ortRunIds: List, outdatedMessage: String) { + db.blockingQuery { + OrtRunsTable.update({ OrtRunsTable.id inList ortRunIds }) { + it[OrtRunsTable.outdated] = true + it[OrtRunsTable.outdatedMessage] = outdatedMessage + } + } + } + /** * Start the [AdvisorJob] with the provided [id] and return the updated job or `null` if the job does not exist. */ diff --git a/services/ort-run/src/test/kotlin/OrtRunServiceTest.kt b/services/ort-run/src/test/kotlin/OrtRunServiceTest.kt index 542a037077..66f2cc4cc0 100644 --- a/services/ort-run/src/test/kotlin/OrtRunServiceTest.kt +++ b/services/ort-run/src/test/kotlin/OrtRunServiceTest.kt @@ -1381,6 +1381,37 @@ class OrtRunServiceTest : WordSpec({ } } } + + "markAsOutdated" should { + "mark the provided runs as outdated and save the describing message" { + val run1Id = fixtures.createOrtRun().id + val run2Id = fixtures.createOrtRun().id + val run3Id = fixtures.createOrtRun().id + + val outdatedMsg = "Outdated" + + service.markAsOutdated(listOf(run1Id, run3Id), outdatedMsg) + + val run1 = service.getOrtRun(run1Id) + val run2 = service.getOrtRun(run2Id) + val run3 = service.getOrtRun(run3Id) + + run1 shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe outdatedMessage + } + + run2 shouldNotBeNull { + outdated shouldBe false + outdatedMessage shouldBe null + } + + run3 shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe outdatedMessage + } + } + } }) private fun createOrtRun( From 5d295da3117e4b7804b106b8db599f7397556748 Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Thu, 23 Oct 2025 15:32:18 +0300 Subject: [PATCH 03/12] feat: Add table for saving user-performed change events Add table for storing events regarding items such as resolutions. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- dao/src/main/kotlin/tables/ChangeLogTable.kt | 74 +++++++++++++++++++ .../db/migration/V121__addChangeLogTable.sql | 11 +++ model/src/commonMain/kotlin/ChangeEvent.kt | 60 +++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 dao/src/main/kotlin/tables/ChangeLogTable.kt create mode 100644 dao/src/main/resources/db/migration/V121__addChangeLogTable.sql create mode 100644 model/src/commonMain/kotlin/ChangeEvent.kt diff --git a/dao/src/main/kotlin/tables/ChangeLogTable.kt b/dao/src/main/kotlin/tables/ChangeLogTable.kt new file mode 100644 index 0000000000..fffeba8ab8 --- /dev/null +++ b/dao/src/main/kotlin/tables/ChangeLogTable.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.dao.tables + +import org.eclipse.apoapsis.ortserver.dao.repositories.userDisplayName.UserDisplayNameDao +import org.eclipse.apoapsis.ortserver.model.ChangeEvent +import org.eclipse.apoapsis.ortserver.model.ChangeEventAction +import org.eclipse.apoapsis.ortserver.model.ChangeEventEntityType +import org.eclipse.apoapsis.ortserver.model.UserDisplayName + +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp + +/* + * A table to store change log entries representing user-performed change events. + */ +object ChangeLogTable : Table("change_log") { + val entityType = text("entity_type") + val entityId = text("entity_id") + val userId = text("user_id") + val occurredAt = timestamp("occurred_at") + val action = text("action") + + fun insert( + entityTypeInput: ChangeEventEntityType, + entityIdInput: String, + userIdInput: String, + actionInput: ChangeEventAction + ) { + insert { + it[entityType] = entityTypeInput.name + it[entityId] = entityIdInput + it[userId] = userIdInput + it[action] = actionInput.name + } + } + + fun getAllByEntityTypeAndId( + entityTypeSearch: ChangeEventEntityType, + entityIdSearch: String + ): List { + return select(columns) + .where { (entityType eq entityTypeSearch.name) and (entityId eq entityIdSearch) } + .orderBy(occurredAt, SortOrder.ASC) + .map { row -> + ChangeEvent( + user = UserDisplayNameDao.findById(row[userId])?.mapToModel() + ?: UserDisplayName(row[userId], "Unknown"), + occurredAt = row[occurredAt], + action = ChangeEventAction.valueOf(row[action]) + ) + } + } +} diff --git a/dao/src/main/resources/db/migration/V121__addChangeLogTable.sql b/dao/src/main/resources/db/migration/V121__addChangeLogTable.sql new file mode 100644 index 0000000000..47b2775ebc --- /dev/null +++ b/dao/src/main/resources/db/migration/V121__addChangeLogTable.sql @@ -0,0 +1,11 @@ +CREATE TABLE change_log ( + entity_type text NOT NULL, + entity_id text NOT NULL, + user_id varchar(40) NOT NULL, + occurred_at timestamp DEFAULT NOW() NOT NULL, + action text NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_change_log_entity_type ON change_log(entity_type); + +CREATE INDEX IF NOT EXISTS idx_change_log_entity_id ON change_log(entity_id); diff --git a/model/src/commonMain/kotlin/ChangeEvent.kt b/model/src/commonMain/kotlin/ChangeEvent.kt new file mode 100644 index 0000000000..ca1d1d62dc --- /dev/null +++ b/model/src/commonMain/kotlin/ChangeEvent.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +import kotlinx.datetime.Instant + +/** + * A data class representing a change event performed by a user. + */ +data class ChangeEvent( + /** The user who performed the change. */ + val user: UserDisplayName, + + /** The time the change occurred. */ + val occurredAt: Instant, + + /** The action performed. */ + val action: ChangeEventAction +) + +/** + * An enumeration of the entity types that can be affected by a [ChangeEvent]. + */ +enum class ChangeEventEntityType { + VULNERABILITY_RESOLUTION_DEFINITION +} + +/** + * An enumeration of the actions that can be performed, resulting in a [ChangeEvent]. + */ +enum class ChangeEventAction { + /** The creation of a new entity. */ + CREATE, + + /** The update of an existing entity. */ + UPDATE, + + /** The archival, i.e. soft deletion, of an existing entity. */ + ARCHIVE, + + /** The restoration, i.e. un-archival, of an archived entity. */ + RESTORE +} From c6219be117bd7bef83e0545efd770a446a78d668 Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Thu, 30 Oct 2025 08:48:13 +0200 Subject: [PATCH 04/12] feat: Add table for storing vulnerability resolution definitions The vulnerability resolution definitions will be used to allow users to make vulnerability resolutions on the server, after which they will be injected into repository configuration on new runs. In the first implementation, the scope of the resolutions will be the repository. The definition allows to add multiple ID matchers for cases when the same vulnerability is found with different external IDs from different advisors. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- ...VulnerabilityResolutionDefinitionsTable.kt | 106 ++++++++++++++++++ ...ulnerabilityResolutionDefinitionsTable.sql | 16 +++ .../VulnerabilityResolutionDefinition.kt | 52 +++++++++ .../kotlin/VulnerabilityResolutionReason.kt | 67 +++++++++++ 4 files changed, 241 insertions(+) create mode 100644 dao/src/main/kotlin/tables/VulnerabilityResolutionDefinitionsTable.kt create mode 100644 dao/src/main/resources/db/migration/V122__addVulnerabilityResolutionDefinitionsTable.sql create mode 100644 model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt create mode 100644 model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt diff --git a/dao/src/main/kotlin/tables/VulnerabilityResolutionDefinitionsTable.kt b/dao/src/main/kotlin/tables/VulnerabilityResolutionDefinitionsTable.kt new file mode 100644 index 0000000000..674e6090b8 --- /dev/null +++ b/dao/src/main/kotlin/tables/VulnerabilityResolutionDefinitionsTable.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.dao.tables + +import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable +import org.eclipse.apoapsis.ortserver.dao.utils.jsonb +import org.eclipse.apoapsis.ortserver.model.ChangeEventEntityType +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.model.util.OptionalValue + +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.update + +/** + * A table to store definitions of vulnerability resolutions. + */ +object VulnerabilityResolutionDefinitionsTable : LongIdTable("vulnerability_resolution_definitions") { + val repositoryId = reference("repository_id", RepositoriesTable) + + val contextRunId = long("context_run_id") + val idMatchers = jsonb>("id_matchers") + val reason = text("reason") + val comment = text("comment") + val archived = bool("archived").default(false) + + fun insert( + hierarchyId: RepositoryId, + runIdInput: Long, + idMatchersInput: List, + reasonInput: VulnerabilityResolutionReason, + commentInput: String + ): Long { + return insertAndGetId { + it[repositoryId] = hierarchyId.value + it[contextRunId] = runIdInput + it[idMatchers] = idMatchersInput + it[reason] = reasonInput.name + it[comment] = commentInput + }.value + } + + fun get(definitionId: Long): VulnerabilityResolutionDefinition { + return select(columns) + .where { id eq definitionId } + .single() + .toVulnerabilityResolutionDefinition() + } + + fun getOrNull(definitionId: Long): VulnerabilityResolutionDefinition? { + val row = select(columns) + .where { id eq definitionId } + .singleOrNull() ?: return null + + return row.toVulnerabilityResolutionDefinition() + } + + fun updateDefinition( + definitionId: Long, + idMatchersInput: OptionalValue> = OptionalValue.Absent, + reasonInput: OptionalValue = OptionalValue.Absent, + commentInput: OptionalValue = OptionalValue.Absent, + archivedInput: OptionalValue = OptionalValue.Absent + ) { + update({ id eq definitionId }) { stmt -> + idMatchersInput.ifPresent { stmt[idMatchers] = it } + reasonInput.ifPresent { stmt[reason] = it.name } + commentInput.ifPresent { stmt[comment] = it } + archivedInput.ifPresent { stmt[archived] = it } + } + } + + fun ResultRow.toVulnerabilityResolutionDefinition() = VulnerabilityResolutionDefinition( + this[id].value, + RepositoryId(this[repositoryId].value), + this[contextRunId], + this[idMatchers], + VulnerabilityResolutionReason.valueOf(this[reason]), + this[comment], + this[archived], + ChangeLogTable.getAllByEntityTypeAndId( + ChangeEventEntityType.VULNERABILITY_RESOLUTION_DEFINITION, + this[id].value.toString() + ) + ) +} diff --git a/dao/src/main/resources/db/migration/V122__addVulnerabilityResolutionDefinitionsTable.sql b/dao/src/main/resources/db/migration/V122__addVulnerabilityResolutionDefinitionsTable.sql new file mode 100644 index 0000000000..da7b3a5cdf --- /dev/null +++ b/dao/src/main/resources/db/migration/V122__addVulnerabilityResolutionDefinitionsTable.sql @@ -0,0 +1,16 @@ +CREATE TABLE vulnerability_resolution_definitions +( + id bigserial PRIMARY KEY, + repository_id bigint REFERENCES repositories NOT NULL, + context_run_id bigint NOT NULL, + id_matchers jsonb NOT NULL, + reason text NOT NULL, + comment text NOT NULL, + archived boolean DEFAULT FALSE NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_vulnerability_resolution_definitions_repository_id +ON vulnerability_resolution_definitions(repository_id); + +CREATE INDEX IF NOT EXISTS idx_vulnerability_resolution_definitions_context_run_id +ON vulnerability_resolution_definitions(context_run_id); diff --git a/model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt b/model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt new file mode 100644 index 0000000000..779706160e --- /dev/null +++ b/model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +/* + * A data class representing a vulnerability resolution definition. + */ +data class VulnerabilityResolutionDefinition( + /** The unique identifier of the vulnerability resolution definition. */ + val id: Long, + + /** + * The ID of the hierarchy to which this vulnerability resolution definition is scoped to. In the initial + * implementation this is always the repository level. + */ + val hierarchyId: RepositoryId, + + /** The ID of the run in which context the vulnerability resolution definition was made in. */ + val contextRunId: Long, + + /** The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerability to resolve. */ + val idMatchers: List, + + /** The reason why the vulnerability is resolved. */ + val reason: VulnerabilityResolutionReason, + + /** A comment to further explain why the [reason] is applicable here. */ + val comment: String, + + /** Whether the vulnerability resolution definition is archived. */ + val archived: Boolean, + + /** The list of change events associated with this vulnerability resolution definition. */ + val changes: List +) diff --git a/model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt b/model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt new file mode 100644 index 0000000000..f5cf0ab885 --- /dev/null +++ b/model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +/** + * Possible reasons for resolving an [Vulnerability] using a [VulnerabilityResolution]. + */ +enum class VulnerabilityResolutionReason { + /** + * No remediation is available for this vulnerability, e.g., because it requires a change to be made + * by a third party that is not responsive. + */ + CANT_FIX_VULNERABILITY, + + /** + * The code in which the vulnerability was found is neither invoked in the project's code nor indirectly + * via another open source component. + */ + INEFFECTIVE_VULNERABILITY, + + /** + * The vulnerability is irrelevant due to a tooling or database mismatch, e.g., the package version used + * does not match the version for which the vulnerability provider has reported a vulnerability. + */ + INVALID_MATCH_VULNERABILITY, + + /** + * The vulnerability is valid but has been mitigated, e.g., measures have been taken to ensure + * this vulnerability can not be exploited. + */ + MITIGATED_VULNERABILITY, + + /** + * The vulnerability was reported, and got a CVE assigned. However, the CVE was later deemed to not be a + * vulnerability. + */ + NOT_A_VULNERABILITY, + + /** + * This vulnerability will never be fixed, e.g., because the package which is affected is orphaned, + * declared end-of-life, or otherwise deprecated. + */ + WILL_NOT_FIX_VULNERABILITY, + + /** + * The vulnerability is valid but a temporary workaround has been put in place to avoid exposure + * to the vulnerability. + */ + WORKAROUND_FOR_VULNERABILITY +} From 54670d4e0e805be464f071d0d3e07e7557f0d3d5 Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Thu, 30 Oct 2025 08:53:37 +0200 Subject: [PATCH 05/12] feat: Add component for resolutions Add a new component for handling resolutions. Signed-off-by: Johanna Lamppu --- .../resolutions/api-model/build.gradle.kts | 45 ++++++++++++++ .../resolutions/backend/build.gradle.kts | 59 +++++++++++++++++++ settings.gradle.kts | 2 + 3 files changed, 106 insertions(+) create mode 100644 components/resolutions/api-model/build.gradle.kts create mode 100644 components/resolutions/backend/build.gradle.kts diff --git a/components/resolutions/api-model/build.gradle.kts b/components/resolutions/api-model/build.gradle.kts new file mode 100644 index 0000000000..1779dbd507 --- /dev/null +++ b/components/resolutions/api-model/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +plugins { + id("ort-server-kotlin-multiplatform-conventions") + id("ort-server-publication-conventions") + + // Apply third-party plugins. + alias(libs.plugins.kotlinSerialization) +} + +group = "org.eclipse.apoapsis.ortserver.components.resolutions" + +kotlin { + linuxX64() + macosArm64() + macosX64() + mingwX64() + + sourceSets { + commonMain { + dependencies { + api(projects.shared.apiModel) + + implementation(libs.kotlinxSerializationJson) + } + } + } +} diff --git a/components/resolutions/backend/build.gradle.kts b/components/resolutions/backend/build.gradle.kts new file mode 100644 index 0000000000..5c3d5783b9 --- /dev/null +++ b/components/resolutions/backend/build.gradle.kts @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +plugins { + id("ort-server-kotlin-component-backend-conventions") + id("ort-server-publication-conventions") +} + +group = "org.eclipse.apoapsis.ortserver.components.resolutions" + +dependencies { + api(projects.model) + api(projects.services.ortRunService) + + api(libs.exposedCore) + + implementation(projects.dao) + + routesImplementation(projects.components.authorizationKeycloak.backend) + routesImplementation(projects.components.resolutions.apiModel) + routesImplementation(projects.shared.apiMappings) + routesImplementation(projects.shared.apiModel) + routesImplementation(projects.shared.ktorUtils) + + routesImplementation(ktorLibs.http) + routesImplementation(ktorLibs.server.auth) + routesImplementation(ktorLibs.server.core) + routesImplementation(libs.kotlinxDatetime) + routesImplementation(libs.ktorOpenApi) + + testImplementation(testFixtures(projects.dao)) + testImplementation(testFixtures(projects.shared.ktorUtils)) + + testImplementation(ktorLibs.client.core) + testImplementation(ktorLibs.http) + testImplementation(ktorLibs.server.auth) + testImplementation(ktorLibs.server.core) + testImplementation(ktorLibs.server.testHost) + testImplementation(libs.kotestAssertionsCore) + testImplementation(libs.kotestAssertionsKtor) + testImplementation(libs.kotestFrameworkEngine) + testImplementation(libs.mockk) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 02fc16fe7c..cc06f0b62c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,8 @@ include(":components:infrastructure-services:api-model") include(":components:infrastructure-services:backend") include(":components:plugin-manager:api-model") include(":components:plugin-manager:backend") +include(":components:resolutions:api-model") +include(":components:resolutions:backend") include(":components:secrets:api-model") include(":components:secrets:backend") include(":compositions:secrets-routes") From 0b9c1f34aa63a014eec2bec719724de31beb96fb Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Thu, 30 Oct 2025 09:31:33 +0200 Subject: [PATCH 06/12] feat: Allow to add vulnerability resolutions Add support for creating vulnerability resolutions on the server. In the initial implementation, the resolutions are created in the scope of the repository. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- .../kotlin/PostVulnerabilityResolution.kt | 44 ++++++ ...ulnerabilityResolutionDefinitionService.kt | 78 ++++++++++ .../backend/src/routes/kotlin/Routing.kt | 33 +++++ .../PostVulnerabilityResolution.kt | 126 ++++++++++++++++ .../test/kotlin/ResolutionsIntegrationTest.kt | 123 ++++++++++++++++ ...rabilityResolutionDefinitionServiceTest.kt | 135 ++++++++++++++++++ .../routes/ResolutionsAuthorizationTest.kt | 118 +++++++++++++++ ...tVulnerabilityResolutionIntegrationTest.kt | 77 ++++++++++ core/build.gradle.kts | 6 + core/src/main/kotlin/di/Module.kt | 2 + core/src/main/kotlin/plugins/Routing.kt | 2 + .../ort-run/src/main/kotlin/OrtRunService.kt | 11 ++ .../commonMain/kotlin/ChangeEventMappings.kt | 33 +++++ ...lnerabilityResolutionDefinitionMappings.kt | 33 +++++ .../VulnerabilityResolutionReasonMappings.kt | 27 ++++ shared/api-model/build.gradle.kts | 1 + .../src/commonMain/kotlin/ChangeEvent.kt | 56 ++++++++ .../VulnerabilityResolutionDefinition.kt | 48 +++++++ .../kotlin/VulnerabilityResolutionReason.kt | 70 +++++++++ 19 files changed, 1023 insertions(+) create mode 100644 components/resolutions/api-model/src/commonMain/kotlin/PostVulnerabilityResolution.kt create mode 100644 components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt create mode 100644 components/resolutions/backend/src/routes/kotlin/Routing.kt create mode 100644 components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PostVulnerabilityResolution.kt create mode 100644 components/resolutions/backend/src/test/kotlin/ResolutionsIntegrationTest.kt create mode 100644 components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt create mode 100644 components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt create mode 100644 components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PostVulnerabilityResolutionIntegrationTest.kt create mode 100644 shared/api-mappings/src/commonMain/kotlin/ChangeEventMappings.kt create mode 100644 shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionDefinitionMappings.kt create mode 100644 shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionReasonMappings.kt create mode 100644 shared/api-model/src/commonMain/kotlin/ChangeEvent.kt create mode 100644 shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt create mode 100644 shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt diff --git a/components/resolutions/api-model/src/commonMain/kotlin/PostVulnerabilityResolution.kt b/components/resolutions/api-model/src/commonMain/kotlin/PostVulnerabilityResolution.kt new file mode 100644 index 0000000000..74a356461e --- /dev/null +++ b/components/resolutions/api-model/src/commonMain/kotlin/PostVulnerabilityResolution.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import kotlinx.serialization.Serializable + +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +/** + * The request object for creating a vulnerability resolution. + */ +@Serializable +data class PostVulnerabilityResolution( + /** The ID of the run in which context the vulnerability resolution is made in. */ + val contextRunId: Long, + + /** + * The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerabilities to resolve. + */ + val idMatchers: List, + + /** The reason why the vulnerability is resolved. */ + val reason: VulnerabilityResolutionReason, + + /** A comment to further explain why the [reason] is applicable here. */ + val comment: String +) diff --git a/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt new file mode 100644 index 0000000000..0f8d342828 --- /dev/null +++ b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import org.eclipse.apoapsis.ortserver.dao.dbQuery +import org.eclipse.apoapsis.ortserver.dao.repositories.userDisplayName.UserDisplayNameDao +import org.eclipse.apoapsis.ortserver.dao.tables.ChangeLogTable +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable +import org.eclipse.apoapsis.ortserver.model.ChangeEventAction +import org.eclipse.apoapsis.ortserver.model.ChangeEventEntityType +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService + +import org.jetbrains.exposed.sql.Database + +/** + * Service class for managing vulnerability resolution definitions. + */ +class VulnerabilityResolutionDefinitionService(private val db: Database, private val ortRunService: OrtRunService) { + suspend fun create( + hierarchyId: RepositoryId, + contextRunId: Long, + userDisplayName: UserDisplayName, + idMatchers: List, + reason: VulnerabilityResolutionReason, + comment: String + ): VulnerabilityResolutionDefinition = db.dbQuery { + val id = VulnerabilityResolutionDefinitionsTable.insert( + hierarchyId, + contextRunId, + idMatchers, + reason, + comment + ) + + addChangeLogEvent(id, ChangeEventAction.CREATE, userDisplayName) + + ortRunService.markAsOutdated(listOf(contextRunId), "New vulnerability resolution added.") + + VulnerabilityResolutionDefinitionsTable.get(id) + } + + private fun addChangeLogEvent( + entityId: Long, + action: ChangeEventAction, + userDisplayName: UserDisplayName + ) { + val user = + UserDisplayNameDao.insertOrUpdate(userDisplayName) ?: throw NullPointerException("No user created or found") + + ChangeLogTable.insert( + ChangeEventEntityType.VULNERABILITY_RESOLUTION_DEFINITION, + entityId.toString(), + user.id.value, + action + ) + } +} diff --git a/components/resolutions/backend/src/routes/kotlin/Routing.kt b/components/resolutions/backend/src/routes/kotlin/Routing.kt new file mode 100644 index 0000000000..b9d7226c98 --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/Routing.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import io.ktor.server.routing.Route + +import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.postVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService + +/** Add all resolutions routes. */ +fun Route.resolutionsRoutes( + ortRunService: OrtRunService, + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) { + postVulnerabilityResolution(ortRunService, vulnerabilityResolutionDefinitionService) +} diff --git a/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PostVulnerabilityResolution.kt b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PostVulnerabilityResolution.kt new file mode 100644 index 0000000000..f8783f7c2c --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PostVulnerabilityResolution.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.github.smiley4.ktoropenapi.post + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError + +internal fun Route.postVulnerabilityResolution( + ortRunService: OrtRunService, + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) = post("/resolutions/vulnerabilities", { + operationId = "postVulnerabilityResolution" + summary = "Create a vulnerability resolution" + tags = listOf("Resolutions") + + request { + jsonBody { + example("Create Vulnerability Resolution") { + value = PostVulnerabilityResolution( + contextRunId = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + comment = "Comment" + ) + } + } + } + + response { + HttpStatusCode.Created to { + description = "Success" + jsonBody { + example("Create Vulnerability Resolution") { + value = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + comment = "Comment", + archived = false, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-01T00:00:00Z"), + ChangeEventAction.CREATE + ) + ) + ) + } + } + } + } +}) { + val createResolution = call.receive() + + val repositoryId = ortRunService.getRepositoryIdForOrtRun(createResolution.contextRunId) + ?: throw AuthorizationException() + + requirePermission(RepositoryPermission.WRITE.roleName(repositoryId)) + + // Extract the user information from the principal. + val userDisplayName = call.principal()?.let { principal -> + ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName()) + } + + if (userDisplayName == null) { + call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.") + return@post + } + + val vulnerabilityResolutionDefinition = vulnerabilityResolutionDefinitionService.create( + RepositoryId(repositoryId), + createResolution.contextRunId, + userDisplayName, + createResolution.idMatchers, + createResolution.reason.mapToModel(), + createResolution.comment + ).mapToApi() + + call.respond(HttpStatusCode.Created, vulnerabilityResolutionDefinition) +} diff --git a/components/resolutions/backend/src/test/kotlin/ResolutionsIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/ResolutionsIntegrationTest.kt new file mode 100644 index 0000000000..6dc90c791a --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/ResolutionsIntegrationTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import com.auth0.jwt.JWT + +import io.ktor.client.HttpClient +import io.ktor.server.application.createRouteScopedPlugin +import io.ktor.server.auth.authentication +import io.ktor.server.auth.principal +import io.ktor.server.testing.ApplicationTestBuilder + +import io.mockk.mockk + +import java.util.Base64 + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Superuser +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService +import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest + +import org.jetbrains.exposed.sql.Database + +@Suppress("UnnecessaryAbstractClass") +abstract class ResolutionsIntegrationTest(body: ResolutionsIntegrationTest.() -> Unit) : AbstractIntegrationTest({}) { + lateinit var ortRunService: OrtRunService + lateinit var vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService + + private lateinit var db: Database + private lateinit var fixtures: Fixtures + + init { + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + + ortRunService = OrtRunService( + db, + fixtures.advisorJobRepository, + fixtures.advisorRunRepository, + fixtures.analyzerJobRepository, + fixtures.analyzerRunRepository, + fixtures.evaluatorJobRepository, + fixtures.evaluatorRunRepository, + fixtures.ortRunRepository, + fixtures.reporterJobRepository, + fixtures.reporterRunRepository, + fixtures.notifierJobRepository, + fixtures.notifierRunRepository, + fixtures.repositoryConfigurationRepository, + fixtures.repositoryRepository, + fixtures.resolvedConfigurationRepository, + fixtures.scannerJobRepository, + fixtures.scannerRunRepository, + mockk(), + mockk() + ) + + vulnerabilityResolutionDefinitionService = VulnerabilityResolutionDefinitionService(db, ortRunService) + } + + body() + } + + fun resolutionsTestApplication( + block: suspend ApplicationTestBuilder.(client: HttpClient) -> Unit + ) = integrationTestApplication( + routes = { + // Define a route-scoped plugin that injects a principal for tests + val injectTestPrincipal = createRouteScopedPlugin(name = "InjectTestPrincipal") { + onCall { call -> + if (call.principal() == null) { + val headerJson = """{"alg":"none","typ":"JWT"}""" + val payloadJson = """ + { + "sub": "user-1", + "preferred_username": "test", + "name": "Test User" + } + """.trimIndent() + + fun b64url(s: String) = + Base64.getUrlEncoder().withoutPadding() + .encodeToString(s.toByteArray(Charsets.UTF_8)) + + val token = "${b64url(headerJson)}.${b64url(payloadJson)}." + val decoded = JWT.decode(token) + + val principal = OrtPrincipal( + payload = decoded, + roles = setOf(Superuser.ROLE_NAME) + ) + + call.authentication.principal(principal) + } + } + } + + install(injectTestPrincipal) + + resolutionsRoutes(ortRunService, vulnerabilityResolutionDefinitionService) + }, + block = block + ) +} diff --git a/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt new file mode 100644 index 0000000000..29a66f27b0 --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe + +import io.mockk.mockk + +import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.ChangeEventAction +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService + +import org.jetbrains.exposed.sql.Database + +class VulnerabilityResolutionDefinitionServiceTest : WordSpec({ + val dbExtension = extension(DatabaseTestExtension()) + + var repositoryId = 0L + var runId = 0L + + lateinit var db: Database + lateinit var fixtures: Fixtures + lateinit var ortRunService: OrtRunService + lateinit var definitionService: VulnerabilityResolutionDefinitionService + + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + + repositoryId = fixtures.repository.id + runId = fixtures.ortRun.id + + ortRunService = OrtRunService( + db, + fixtures.advisorJobRepository, + fixtures.advisorRunRepository, + fixtures.analyzerJobRepository, + fixtures.analyzerRunRepository, + fixtures.evaluatorJobRepository, + fixtures.evaluatorRunRepository, + fixtures.ortRunRepository, + fixtures.reporterJobRepository, + fixtures.reporterRunRepository, + fixtures.notifierJobRepository, + fixtures.notifierRunRepository, + fixtures.repositoryConfigurationRepository, + fixtures.repositoryRepository, + fixtures.resolvedConfigurationRepository, + fixtures.scannerJobRepository, + fixtures.scannerRunRepository, + mockk(), + mockk() + ) + + definitionService = VulnerabilityResolutionDefinitionService(db, ortRunService) + } + + "create" should { + "add a new vulnerability resolution definition and an event in the change log" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definition = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + + with(definition) { + idMatchers shouldBe listOf("CVE-2020-15250") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Comment." + archived shouldBe false + changes shouldHaveSize 1 + + with(changes.first()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.CREATE + } + } + } + + "mark the given context run as outdated" { + definitionService.create( + RepositoryId(repositoryId), + runId, + UserDisplayName( + "abc", + "Test", + "Test User" + ), + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + + val run = ortRunService.getOrtRun(runId) + + run shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "New vulnerability resolution added." + } + } + } +}) diff --git a/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt new file mode 100644 index 0000000000..038ac79bba --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes + +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import io.mockk.mockk + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.components.resolutions.resolutionsRoutes +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractAuthorizationTest + +import org.jetbrains.exposed.sql.Database + +class ResolutionsAuthorizationTest : AbstractAuthorizationTest({ + var repositoryId = 0L + var runId = 0L + + val nonExistentRunId = 999L + + lateinit var createBody: PostVulnerabilityResolution + + lateinit var ortRunService: OrtRunService + lateinit var definitionService: VulnerabilityResolutionDefinitionService + + lateinit var db: Database + lateinit var fixtures: Fixtures + + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + + repositoryId = fixtures.repository.id + runId = fixtures.ortRun.id + + authorizationService.ensureSuperuserAndSynchronizeRolesAndPermissions() + + createBody = PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + + ortRunService = OrtRunService( + db, + fixtures.advisorJobRepository, + fixtures.advisorRunRepository, + fixtures.analyzerJobRepository, + fixtures.analyzerRunRepository, + fixtures.evaluatorJobRepository, + fixtures.evaluatorRunRepository, + fixtures.ortRunRepository, + fixtures.reporterJobRepository, + fixtures.reporterRunRepository, + fixtures.notifierJobRepository, + fixtures.notifierRunRepository, + fixtures.repositoryConfigurationRepository, + fixtures.repositoryRepository, + fixtures.resolvedConfigurationRepository, + fixtures.scannerJobRepository, + fixtures.scannerRunRepository, + mockk(), + mockk() + ) + + definitionService = VulnerabilityResolutionDefinitionService(db, ortRunService) + } + + "PostVulnerabilityResolution" should { + "require role RepositoryPermission.WRITE.roleName(repositoryId)" { + requestShouldRequireRole( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + role = RepositoryPermission.WRITE.roleName(repositoryId), + successStatus = HttpStatusCode.Created + ) { + post("/resolutions/vulnerabilities") { + setBody(createBody) + } + } + } + + "respond with 'Forbidden' when repository ID cannot be resolved" { + requestShouldRequireAuthentication( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + successStatus = HttpStatusCode.Forbidden + ) { + post("/resolutions/vulnerabilities") { + setBody(createBody.copy(contextRunId = nonExistentRunId)) + } + } + } + } +}) diff --git a/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PostVulnerabilityResolutionIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PostVulnerabilityResolutionIntegrationTest.kt new file mode 100644 index 0000000000..2fe5a561d3 --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PostVulnerabilityResolutionIntegrationTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +class PostVulnerabilityResolutionIntegrationTest : ResolutionsIntegrationTest({ + var runId = 0L + + lateinit var fixtures: Fixtures + + beforeEach { + fixtures = dbExtension.fixtures + runId = fixtures.ortRun.id + } + + "PostVulnerabilityResolution" should { + "add a new vulnerability resolution definition" { + resolutionsTestApplication { client -> + val response = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + response shouldHaveStatus HttpStatusCode.Created + + with(response.body()) { + idMatchers shouldBe listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Comment." + archived shouldBe false + changes shouldHaveSize 1 + changes.first().user shouldBe UserDisplayName("test", "Test User") + changes.first().action shouldBe ChangeEventAction.CREATE + } + } + } + } +}) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index e46b896c7c..d128aa6b97 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -84,6 +84,12 @@ dependencies { requireCapability("$group:routes:$version") } } + implementation(projects.components.resolutions.backend) + implementation(projects.components.resolutions.backend) { + capabilities { + requireCapability("$group:routes:$version") + } + } implementation(projects.components.secrets.backend) implementation(projects.components.secrets.backend) { capabilities { diff --git a/core/src/main/kotlin/di/Module.kt b/core/src/main/kotlin/di/Module.kt index 6cf8df9d98..69065e7c98 100644 --- a/core/src/main/kotlin/di/Module.kt +++ b/core/src/main/kotlin/di/Module.kt @@ -36,6 +36,7 @@ import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginEventStore import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginService import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginTemplateEventStore import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginTemplateService +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService import org.eclipse.apoapsis.ortserver.components.secrets.SecretService import org.eclipse.apoapsis.ortserver.config.ConfigManager import org.eclipse.apoapsis.ortserver.core.plugins.customSerializersModule @@ -189,6 +190,7 @@ fun ortServerModule(config: ApplicationConfig, db: Database?, authorizationServi singleOf(::RepositoryService) singleOf(::RuleViolationService) singleOf(::SecretService) + singleOf(::VulnerabilityResolutionDefinitionService) singleOf(::VulnerabilityService) if (authorizationService != null) { diff --git a/core/src/main/kotlin/plugins/Routing.kt b/core/src/main/kotlin/plugins/Routing.kt index c4c14b8220..3158dad1cd 100644 --- a/core/src/main/kotlin/plugins/Routing.kt +++ b/core/src/main/kotlin/plugins/Routing.kt @@ -28,6 +28,7 @@ import org.eclipse.apoapsis.ortserver.components.adminconfig.adminConfigRoutes import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.SecurityConfigurations import org.eclipse.apoapsis.ortserver.components.infrastructureservices.infrastructureServicesRoutes import org.eclipse.apoapsis.ortserver.components.pluginmanager.pluginManagerRoutes +import org.eclipse.apoapsis.ortserver.components.resolutions.resolutionsRoutes import org.eclipse.apoapsis.ortserver.components.secrets.secretsRoutes import org.eclipse.apoapsis.ortserver.compositions.secretsroutes.secretsCompositionRoutes import org.eclipse.apoapsis.ortserver.core.api.admin @@ -56,6 +57,7 @@ fun Application.configureRouting() { pluginManagerRoutes(get(), get(), get()) products() repositories() + resolutionsRoutes(get(), get()) runs() secretsCompositionRoutes(get(), get()) secretsRoutes(get(), get()) diff --git a/services/ort-run/src/main/kotlin/OrtRunService.kt b/services/ort-run/src/main/kotlin/OrtRunService.kt index 0cb0498893..20ec4e3592 100644 --- a/services/ort-run/src/main/kotlin/OrtRunService.kt +++ b/services/ort-run/src/main/kotlin/OrtRunService.kt @@ -340,6 +340,17 @@ class OrtRunService( getReporterJobForOrtRun(ortRunId)?.let { reporterRunRepository.getByJobId(it.id) } } + /** + * Return the ID of the repository for the provided [ortRunId] or `null` if the run does not exist. + */ + fun getRepositoryIdForOrtRun(ortRunId: Long) = db.blockingQuery { + OrtRunsTable + .select(OrtRunsTable.repositoryId) + .where { OrtRunsTable.id eq ortRunId } + .singleOrNull() + ?.get(OrtRunsTable.repositoryId)?.value + } + /** * Return the [NotifierJob] for the provided [id] or `null` if the run does not exist. */ diff --git a/shared/api-mappings/src/commonMain/kotlin/ChangeEventMappings.kt b/shared/api-mappings/src/commonMain/kotlin/ChangeEventMappings.kt new file mode 100644 index 0000000000..3b922650a0 --- /dev/null +++ b/shared/api-mappings/src/commonMain/kotlin/ChangeEventMappings.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimappings + +import org.eclipse.apoapsis.ortserver.model.ChangeEvent +import org.eclipse.apoapsis.ortserver.model.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent as ApiChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction as ApiChangeEventAction + +fun ChangeEvent.mapToApi() = ApiChangeEvent( + user = user.mapToApi(), + occurredAt = occurredAt, + action = action.mapToApi() + ) + +fun ChangeEventAction.mapToApi() = ApiChangeEventAction.valueOf(name) diff --git a/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionDefinitionMappings.kt b/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionDefinitionMappings.kt new file mode 100644 index 0000000000..c195402f47 --- /dev/null +++ b/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionDefinitionMappings.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimappings + +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition as ApiVulnerabilityResolutionDefinition + +fun VulnerabilityResolutionDefinition.mapToApi() = + ApiVulnerabilityResolutionDefinition( + id = id, + idMatchers = idMatchers, + reason = reason.mapToApi(), + comment = comment, + archived = archived, + changes = changes.map { it.mapToApi() } + ) diff --git a/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionReasonMappings.kt b/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionReasonMappings.kt new file mode 100644 index 0000000000..589fbf1090 --- /dev/null +++ b/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionReasonMappings.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimappings + +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason as ApiVulnerabilityResolutionReason + +fun VulnerabilityResolutionReason.mapToApi() = ApiVulnerabilityResolutionReason.valueOf(name) + +fun ApiVulnerabilityResolutionReason.mapToModel() = VulnerabilityResolutionReason.valueOf(name) diff --git a/shared/api-model/build.gradle.kts b/shared/api-model/build.gradle.kts index 1947e5ab93..e8f8d43006 100644 --- a/shared/api-model/build.gradle.kts +++ b/shared/api-model/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { sourceSets { commonMain { dependencies { + implementation(libs.kotlinxDatetime) implementation(libs.kotlinxSerializationJson) } } diff --git a/shared/api-model/src/commonMain/kotlin/ChangeEvent.kt b/shared/api-model/src/commonMain/kotlin/ChangeEvent.kt new file mode 100644 index 0000000000..0e781ee92c --- /dev/null +++ b/shared/api-model/src/commonMain/kotlin/ChangeEvent.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimodel + +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +/** + * A change event performed by a user. + */ +@Serializable +data class ChangeEvent( + /** The user who performed the change. */ + val user: UserDisplayName, + + /** The time the change occurred. */ + val occurredAt: Instant, + + /** The action performed. */ + val action: ChangeEventAction +) + +/** + * An enumeration of the actions that can be performed, resulting in a [ChangeEvent]. + */ +@Serializable +enum class ChangeEventAction { + /** The creation of a new entity. */ + CREATE, + + /** The update of an existing entity. */ + UPDATE, + + /** The archival, i.e. soft deletion, of an existing entity. */ + ARCHIVE, + + /** The restoration, i.e. un-archival, of an archived entity. */ + RESTORE +} diff --git a/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt b/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt new file mode 100644 index 0000000000..90e45c6901 --- /dev/null +++ b/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimodel + +import kotlinx.serialization.Serializable + +/* + * The response object for a vulnerability resolution definition. + */ +@Serializable +data class VulnerabilityResolutionDefinition( + /** The unique identifier of the vulnerability resolution definition. */ + val id: Long, + + /** + * The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerabilities to resolve. + */ + val idMatchers: List, + + /** The reason why the vulnerability is resolved. */ + val reason: VulnerabilityResolutionReason, + + /** A comment to further explain why the [reason] is applicable here. */ + val comment: String, + + /** Whether the vulnerability resolution definition is archived. */ + val archived: Boolean, + + /** The list of change events associated with this vulnerability resolution definition. */ + val changes: List +) diff --git a/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt b/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt new file mode 100644 index 0000000000..df69249287 --- /dev/null +++ b/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimodel + +import kotlinx.serialization.Serializable + +/** + * Possible reasons for resolving an [Vulnerability] using a [VulnerabilityResolution]. + */ +@Serializable +enum class VulnerabilityResolutionReason { + /** + * No remediation is available for this vulnerability, e.g., because it requires a change to be made + * by a third party that is not responsive. + */ + CANT_FIX_VULNERABILITY, + + /** + * The code in which the vulnerability was found is neither invoked in the project's code nor indirectly + * via another open source component. + */ + INEFFECTIVE_VULNERABILITY, + + /** + * The vulnerability is irrelevant due to a tooling or database mismatch, e.g., the package version used + * does not match the version for which the vulnerability provider has reported a vulnerability. + */ + INVALID_MATCH_VULNERABILITY, + + /** + * The vulnerability is valid but has been mitigated, e.g., measures have been taken to ensure + * this vulnerability can not be exploited. + */ + MITIGATED_VULNERABILITY, + + /** + * The vulnerability was reported, and got a CVE assigned. However, the CVE was later deemed to not be a + * vulnerability. + */ + NOT_A_VULNERABILITY, + + /** + * This vulnerability will never be fixed, e.g., because the package which is affected is orphaned, + * declared end-of-life, or otherwise deprecated. + */ + WILL_NOT_FIX_VULNERABILITY, + + /** + * The vulnerability is valid but a temporary workaround has been put in place to avoid exposure + * to the vulnerability. + */ + WORKAROUND_FOR_VULNERABILITY +} From 43a7d27cad72f928c97edc60f03b81e40e141d33 Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Wed, 29 Oct 2025 09:38:52 +0200 Subject: [PATCH 07/12] feat(dao): Inject vulnerability resolutions to repository configuration Inject vulnerability resolutions that have been made for the repository on the server into the repository configuration. Also save the connection between the vulnerability resolution definition and the repository configuration in order to be able to connect the applied resolution to the definition in a later phase when returning vulnerabilities for a run. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- .../DaoRepositoryConfigurationRepository.kt | 54 ++++++++++++++++- ...igurationsVulnerabilityResolutionsTable.kt | 16 +++++ ...gurationsVulnerabilityResolutionsTable.sql | 3 + ...aoRepositoryConfigurationRepositoryTest.kt | 60 +++++++++++++++++++ dao/src/testFixtures/kotlin/Fixtures.kt | 21 +++++++ 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 dao/src/main/resources/db/migration/V123__addVulnerabilityResolutionDefinitionIdToRepositoryConfigurationsVulnerabilityResolutionsTable.sql diff --git a/dao/src/main/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepository.kt b/dao/src/main/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepository.kt index 06aa52e0fb..caba47c431 100644 --- a/dao/src/main/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepository.kt +++ b/dao/src/main/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepository.kt @@ -23,6 +23,7 @@ import org.eclipse.apoapsis.ortserver.dao.blockingQuery import org.eclipse.apoapsis.ortserver.dao.entityQuery import org.eclipse.apoapsis.ortserver.dao.mapAndDeduplicate import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunDao +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifierDao import org.eclipse.apoapsis.ortserver.model.repositories.RepositoryConfigurationRepository import org.eclipse.apoapsis.ortserver.model.runs.repository.Curations @@ -36,8 +37,12 @@ import org.eclipse.apoapsis.ortserver.model.runs.repository.ProvenanceSnippetCho import org.eclipse.apoapsis.ortserver.model.runs.repository.RepositoryAnalyzerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.repository.RepositoryConfiguration import org.eclipse.apoapsis.ortserver.model.runs.repository.Resolutions +import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SizedCollection +import org.jetbrains.exposed.sql.SizedIterable +import org.jetbrains.exposed.sql.and /** * An implementation of [RepositoryConfigurationRepository] that stores repository configurations in @@ -55,7 +60,38 @@ class DaoRepositoryConfigurationRepository(private val db: Database) : Repositor licenseChoices: LicenseChoices, provenanceSnippetChoices: List ): RepositoryConfiguration = db.blockingQuery { - RepositoryConfigurationDao.new { + val ortRun = OrtRunDao[ortRunId].mapToModel() + val vulnerabilityResolutions = mapAndDeduplicate( + resolutions.vulnerabilities, + VulnerabilityResolutionDao::getOrPut + ) + + val idToVulnerabilityResolutionDaos = VulnerabilityResolutionDefinitionsTable + .select(VulnerabilityResolutionDefinitionsTable.columns) + .where { + (VulnerabilityResolutionDefinitionsTable.repositoryId eq ortRun.repositoryId) and + (VulnerabilityResolutionDefinitionsTable.archived eq false) + } + .associateBy({ row -> row[VulnerabilityResolutionDefinitionsTable.id].value }, { row -> + row[VulnerabilityResolutionDefinitionsTable.idMatchers].map { idMatcher -> + VulnerabilityResolutionDao.getOrPut( + VulnerabilityResolution( + idMatcher, + row[VulnerabilityResolutionDefinitionsTable.reason], + row[VulnerabilityResolutionDefinitionsTable.comment] + ) + ) + } + }) + + val combinedVulnerabilityResolutions: SizedIterable = SizedCollection( + buildList { + addAll(vulnerabilityResolutions.toList()) + addAll(idToVulnerabilityResolutionDaos.values.flatten()) + }.distinctBy { it.id.value } + ) + + val repositoryConfiguration = RepositoryConfigurationDao.new { this.ortRun = OrtRunDao[ortRunId] this.repositoryAnalyzerConfiguration = analyzerConfig?.let { RepositoryAnalyzerConfigurationDao.getOrPut(it) @@ -66,8 +102,7 @@ class DaoRepositoryConfigurationRepository(private val db: Database) : Repositor this.issueResolutions = mapAndDeduplicate(resolutions.issues, IssueResolutionDao::getOrPut) this.ruleViolationResolutions = mapAndDeduplicate(resolutions.ruleViolations, RuleViolationResolutionDao::getOrPut) - this.vulnerabilityResolutions = - mapAndDeduplicate(resolutions.vulnerabilities, VulnerabilityResolutionDao::getOrPut) + this.vulnerabilityResolutions = combinedVulnerabilityResolutions this.curations = mapAndDeduplicate(curations.packages, ::createPackageCuration) this.licenseFindingCurations = mapAndDeduplicate(curations.licenseFindings, LicenseFindingCurationDao::getOrPut) @@ -78,6 +113,19 @@ class DaoRepositoryConfigurationRepository(private val db: Database) : Repositor mapAndDeduplicate(licenseChoices.packageLicenseChoices, ::createPackageLicenseChoice) this.provenanceSnippetChoices = mapAndDeduplicate(provenanceSnippetChoices, SnippetChoicesDao::getOrPut) }.mapToModel() + + idToVulnerabilityResolutionDaos.forEach { + (vulnerabilityResolutionDefinitionId, vulnerabilityResolutionDaoList) -> + vulnerabilityResolutionDaoList.forEach { + RepositoryConfigurationsVulnerabilityResolutionsTable.addDefinitionId( + repositoryConfiguration.id, + it.id.value, + vulnerabilityResolutionDefinitionId + ) + } + } + + repositoryConfiguration } override fun get(id: Long): RepositoryConfiguration? = db.entityQuery { diff --git a/dao/src/main/kotlin/repositories/repositoryconfiguration/RepositoryConfigurationsVulnerabilityResolutionsTable.kt b/dao/src/main/kotlin/repositories/repositoryconfiguration/RepositoryConfigurationsVulnerabilityResolutionsTable.kt index 6a6ab67387..6ca26ac6b4 100644 --- a/dao/src/main/kotlin/repositories/repositoryconfiguration/RepositoryConfigurationsVulnerabilityResolutionsTable.kt +++ b/dao/src/main/kotlin/repositories/repositoryconfiguration/RepositoryConfigurationsVulnerabilityResolutionsTable.kt @@ -19,7 +19,11 @@ package org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable + import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.update /** * An intermediate table to store references from [RepositoryConfigurationsTable] and [VulnerabilityResolutionsTable]. @@ -28,7 +32,19 @@ object RepositoryConfigurationsVulnerabilityResolutionsTable : Table("repository_configurations_vulnerability_resolutions") { val repositoryConfigurationId = reference("repository_configuration_id", RepositoryConfigurationsTable) val vulnerabilityResolutionId = reference("vulnerability_resolution_id", VulnerabilityResolutionsTable) + val vulnerabilityResolutionDefinitionId = reference( + "vulnerability_resolution_definition_id", + VulnerabilityResolutionDefinitionsTable + ).nullable() override val primaryKey: PrimaryKey get() = PrimaryKey(repositoryConfigurationId, vulnerabilityResolutionId, name = "${tableName}_pkey") + + fun addDefinitionId(repositoryConfigId: Long, vulnerabilityResId: Long, vulnerabilityResolutionDefId: Long) = + update({ + (repositoryConfigurationId eq repositoryConfigId) and + (vulnerabilityResolutionId eq vulnerabilityResId) + }) { stmt -> + stmt[vulnerabilityResolutionDefinitionId] = vulnerabilityResolutionDefId + } } diff --git a/dao/src/main/resources/db/migration/V123__addVulnerabilityResolutionDefinitionIdToRepositoryConfigurationsVulnerabilityResolutionsTable.sql b/dao/src/main/resources/db/migration/V123__addVulnerabilityResolutionDefinitionIdToRepositoryConfigurationsVulnerabilityResolutionsTable.sql new file mode 100644 index 0000000000..60b44f21b5 --- /dev/null +++ b/dao/src/main/resources/db/migration/V123__addVulnerabilityResolutionDefinitionIdToRepositoryConfigurationsVulnerabilityResolutionsTable.sql @@ -0,0 +1,3 @@ +ALTER TABLE repository_configurations_vulnerability_resolutions + ADD COLUMN vulnerability_resolution_definition_id bigint + REFERENCES vulnerability_resolution_definitions NULL; diff --git a/dao/src/test/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepositoryTest.kt b/dao/src/test/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepositoryTest.kt index 91dcd93fa3..73bf14f6e4 100644 --- a/dao/src/test/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepositoryTest.kt +++ b/dao/src/test/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepositoryTest.kt @@ -21,6 +21,8 @@ package org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.collections.containExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.should @@ -28,7 +30,9 @@ import io.kotest.matchers.shouldBe import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.RepositoryType +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.model.runs.Identifier import org.eclipse.apoapsis.ortserver.model.runs.PackageManagerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.RemoteArtifact @@ -141,6 +145,62 @@ class DaoRepositoryConfigurationRepositoryTest : WordSpec({ includes.paths should containExactly(pathInclude) } } + + "inject vulnerability resolutions that have been defined for the repository" { + fixtures.createVulnerabilityResolutionDefinition( + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + ) + + val ortRun2Id = fixtures.createOrtRun().id + + val createdRepositoryConfiguration = repositoryConfigurationRepository.create( + ortRun2Id, repositoryConfig + ) + + val dbEntry = repositoryConfigurationRepository.get(createdRepositoryConfiguration.id) + + dbEntry shouldNotBeNull { + resolutions.vulnerabilities shouldHaveSize 3 + resolutions.vulnerabilities shouldContainExactlyInAnyOrder listOf( + vulnerabilityResolution, + VulnerabilityResolution( + "CVE-2020-15250", + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.name, + "Comment." + ), + VulnerabilityResolution( + "GHSA-269g-pwp5-87pp", + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.name, + "Comment." + ) + ) + } + } + + "not inject vulnerability resolutions made for other repositories" { + fixtures.createVulnerabilityResolutionDefinition( + RepositoryId(fixtures.ortRun.repositoryId), + ortRunId, + listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "comment" + ) + + val repo2Id = fixtures.createRepository(url = "https://example.com/repo2.git").id + val run2Id = fixtures.createOrtRun(repo2Id).id + + val createdRepositoryConfiguration = repositoryConfigurationRepository.create( + run2Id, repositoryConfig + ) + + val dbEntry = repositoryConfigurationRepository.get(createdRepositoryConfiguration.id) + + dbEntry shouldNotBeNull { + resolutions.vulnerabilities shouldHaveSize 1 + resolutions.vulnerabilities.first() shouldBe vulnerabilityResolution + } + } } "get" should { diff --git a/dao/src/testFixtures/kotlin/Fixtures.kt b/dao/src/testFixtures/kotlin/Fixtures.kt index 7b0e674b6b..e6546d49e1 100644 --- a/dao/src/testFixtures/kotlin/Fixtures.kt +++ b/dao/src/testFixtures/kotlin/Fixtures.kt @@ -22,6 +22,7 @@ package org.eclipse.apoapsis.ortserver.dao.test import kotlinx.datetime.Clock import org.eclipse.apoapsis.ortserver.dao.blockingQuery +import org.eclipse.apoapsis.ortserver.dao.dbQuery import org.eclipse.apoapsis.ortserver.dao.repositories.advisorjob.DaoAdvisorJobRepository import org.eclipse.apoapsis.ortserver.dao.repositories.advisorrun.DaoAdvisorRunRepository import org.eclipse.apoapsis.ortserver.dao.repositories.analyzerjob.DaoAnalyzerJobRepository @@ -41,6 +42,7 @@ import org.eclipse.apoapsis.ortserver.dao.repositories.resolvedconfiguration.Dao import org.eclipse.apoapsis.ortserver.dao.repositories.scannerjob.DaoScannerJobRepository import org.eclipse.apoapsis.ortserver.dao.repositories.scannerrun.DaoScannerRunRepository import org.eclipse.apoapsis.ortserver.dao.repositories.secret.DaoSecretRepository +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifierDao import org.eclipse.apoapsis.ortserver.model.AdvisorJobConfiguration import org.eclipse.apoapsis.ortserver.model.AnalyzerJobConfiguration @@ -50,9 +52,11 @@ import org.eclipse.apoapsis.ortserver.model.Jobs import org.eclipse.apoapsis.ortserver.model.NotifierJobConfiguration import org.eclipse.apoapsis.ortserver.model.PluginConfig import org.eclipse.apoapsis.ortserver.model.ReporterJobConfiguration +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.RepositoryType import org.eclipse.apoapsis.ortserver.model.ScannerJobConfiguration import org.eclipse.apoapsis.ortserver.model.Severity +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.model.runs.AnalyzerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.DependencyGraph import org.eclipse.apoapsis.ortserver.model.runs.Environment @@ -74,6 +78,7 @@ import org.jetbrains.exposed.sql.Database * A helper class to manage test fixtures. It provides default instances as well as helper functions to create custom * instances. */ +@Suppress("TooManyFunctions") class Fixtures(private val db: Database) { val advisorJobRepository = DaoAdvisorJobRepository(db) val advisorRunRepository = DaoAdvisorRunRepository(db) @@ -343,4 +348,20 @@ class Fixtures(private val db: Database) { isMetadataOnly = false, isModified = false ) + + suspend fun createVulnerabilityResolutionDefinition( + hierarchyId: RepositoryId = RepositoryId(repository.id), + contextRunId: Long = ortRun.id, + idMatchers: List, + reason: VulnerabilityResolutionReason, + comment: String = "Comment." + ) = db.dbQuery { + VulnerabilityResolutionDefinitionsTable.insert( + hierarchyId, + contextRunId, + idMatchers, + reason, + comment + ) + } } From dbcd7d86178d7ab189016e0360c275253e14ca0b Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Wed, 29 Oct 2025 08:57:25 +0200 Subject: [PATCH 08/12] feat: Edit returning resolutions for vulnerabilities If the vulnerability resolution has been defined in the server, add the definition for the resolution in the vulnerabilities for run query. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- .../src/commonMain/kotlin/ApiMappings.kt | 9 ++ .../kotlin/VulnerabilityResolution.kt | 7 +- core/src/main/kotlin/apiDocs/RunsDocs.kt | 20 +++- .../kotlin/api/RunsRouteIntegrationTest.kt | 64 +++++++++++++ dao/src/testFixtures/kotlin/Fixtures.kt | 25 +++++ .../kotlin/AppliedVulnerabilityResolution.kt | 34 +++++++ .../kotlin/VulnerabilityWithDetails.kt | 3 +- .../src/main/kotlin/VulnerabilityService.kt | 43 ++++++++- .../test/kotlin/VulnerabilityServiceTest.kt | 94 ++++++++++++++++++- 9 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 model/src/commonMain/kotlin/AppliedVulnerabilityResolution.kt diff --git a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt index a90be5b6bc..f9789d929c 100644 --- a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt +++ b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt @@ -92,6 +92,7 @@ import org.eclipse.apoapsis.ortserver.model.AdvisorJob import org.eclipse.apoapsis.ortserver.model.AdvisorJobConfiguration import org.eclipse.apoapsis.ortserver.model.AnalyzerJob import org.eclipse.apoapsis.ortserver.model.AnalyzerJobConfiguration +import org.eclipse.apoapsis.ortserver.model.AppliedVulnerabilityResolution import org.eclipse.apoapsis.ortserver.model.ContentManagementSection import org.eclipse.apoapsis.ortserver.model.EcosystemStats import org.eclipse.apoapsis.ortserver.model.EnvironmentConfig @@ -597,6 +598,14 @@ fun AdvisorDetails.mapToApi() = ApiAdvisorDetails( capabilities = capabilities.map { ApiAdvisorCapability.valueOf(it.name) }.toSet() ) +fun AppliedVulnerabilityResolution.mapToApi() = + ApiVulnerabilityResolution( + externalId = resolution.externalId, + reason = resolution.reason, + comment = resolution.comment, + definition = definition?.mapToApi() + ) + fun VulnerabilityWithDetails.mapToApi() = ApiVulnerabilityWithDetails( vulnerability = vulnerability.mapToApi(), diff --git a/api/v1/model/src/commonMain/kotlin/VulnerabilityResolution.kt b/api/v1/model/src/commonMain/kotlin/VulnerabilityResolution.kt index 21d256d554..3a2f865f6c 100644 --- a/api/v1/model/src/commonMain/kotlin/VulnerabilityResolution.kt +++ b/api/v1/model/src/commonMain/kotlin/VulnerabilityResolution.kt @@ -21,6 +21,8 @@ package org.eclipse.apoapsis.ortserver.api.v1.model import kotlinx.serialization.Serializable +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition + /** * Defines the resolution of a Vulnerability. This can be used to silence false positives, or vulnerabilities that * have been identified as not being relevant. @@ -29,5 +31,8 @@ import kotlinx.serialization.Serializable data class VulnerabilityResolution( val externalId: String, val reason: String, - val comment: String + val comment: String, + + /** The definition of the [VulnerabilityResolution], if available. */ + val definition: VulnerabilityResolutionDefinition? = null ) diff --git a/core/src/main/kotlin/apiDocs/RunsDocs.kt b/core/src/main/kotlin/apiDocs/RunsDocs.kt index ad51047d8d..bd913e17a3 100644 --- a/core/src/main/kotlin/apiDocs/RunsDocs.kt +++ b/core/src/main/kotlin/apiDocs/RunsDocs.kt @@ -58,12 +58,16 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityRating import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityReference import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityResolution import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityWithDetails +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedSearchResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagingData import org.eclipse.apoapsis.ortserver.shared.apimodel.SortDirection import org.eclipse.apoapsis.ortserver.shared.apimodel.SortProperty import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody import org.eclipse.apoapsis.ortserver.shared.ktorutils.standardListQueryParameters @@ -300,7 +304,21 @@ val getRunVulnerabilities: RouteConfig.() -> Unit = { VulnerabilityResolution( externalId = "CVE-2021-1234", reason = "INEFFECTIVE_VULNERABILITY", - comment = "A comment why the vulnerability can be resolved." + comment = "A comment why the vulnerability can be resolved.", + definition = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2021-1234"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + comment = "A comment why the vulnerability can be resolved.", + archived = false, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "test"), + occurredAt = CREATED_AT, + action = ChangeEventAction.CREATE + ) + ) + ) ) ), advisor = AdvisorDetails( diff --git a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt index 125ff30beb..cd20847f37 100644 --- a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt @@ -101,9 +101,11 @@ import org.eclipse.apoapsis.ortserver.model.LogSource import org.eclipse.apoapsis.ortserver.model.OrtRun import org.eclipse.apoapsis.ortserver.model.OrtRunStatus import org.eclipse.apoapsis.ortserver.model.PluginConfig +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.RepositoryType import org.eclipse.apoapsis.ortserver.model.Severity import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.model.repositories.OrtRunRepository import org.eclipse.apoapsis.ortserver.model.runs.AnalyzerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.Environment @@ -131,6 +133,8 @@ import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedSearchResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.SortDirection import org.eclipse.apoapsis.ortserver.shared.apimodel.SortProperty +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason as ApiVulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.shared.ktorutils.shouldHaveBody import org.eclipse.apoapsis.ortserver.storage.Key import org.eclipse.apoapsis.ortserver.storage.Storage @@ -811,6 +815,66 @@ class RunsRouteIntegrationTest : AbstractIntegrationTest({ } } + "return the definition of the vulnerability resolution if it originated from the server" { + integrationTestApplication { + val run1 = dbExtension.fixtures.createOrtRun(repositoryId) + val definitionId = dbExtension.fixtures.createVulnerabilityResolutionDefinition( + RepositoryId(repositoryId), + run1.id, + listOf("CVE-2021-1234"), + VulnerabilityResolutionReason.INVALID_MATCH_VULNERABILITY + ) + + val run2 = dbExtension.fixtures.createOrtRun(repositoryId) + val advisorJobId = dbExtension.fixtures.createAdvisorJob(run2.id).id + dbExtension.fixtures.createAdvisorRun(advisorJobId, generateAdvisorResult()) + + val vulnerabilityResolution = VulnerabilityResolution( + "CVE-2018-14721", + "INEFFECTIVE_VULNERABILITY", + "Comment." + ) + + val repositoryConfiguration = + dbExtension.fixtures.createRepositoryConfiguration(run2.id, listOf(vulnerabilityResolution)) + + dbExtension.fixtures.resolvedConfigurationRepository.addResolutions( + run2.id, + repositoryConfiguration.resolutions + ) + + val response = superuserClient.get("/api/v1/runs/${run2.id}/vulnerabilities") + + response shouldHaveStatus HttpStatusCode.OK + val vulnerabilities = response.body>() + + vulnerabilities.data shouldHaveSize 2 + + with(vulnerabilities.data.first()) { + vulnerability.externalId shouldBe "CVE-2018-14721" + + resolutions.shouldBeSingleton { + it.definition should beNull() + } + } + + with(vulnerabilities.data.last()) { + vulnerability.externalId shouldBe "CVE-2021-1234" + + resolutions.shouldBeSingleton { + it.definition shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + idMatchers = listOf("CVE-2021-1234"), + reason = ApiVulnerabilityResolutionReason.INVALID_MATCH_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + } + } + } + "require RepositoryPermission.READ_ORT_RUNS" { val run = ortRunRepository.create( repositoryId, diff --git a/dao/src/testFixtures/kotlin/Fixtures.kt b/dao/src/testFixtures/kotlin/Fixtures.kt index e6546d49e1..55885a624d 100644 --- a/dao/src/testFixtures/kotlin/Fixtures.kt +++ b/dao/src/testFixtures/kotlin/Fixtures.kt @@ -71,6 +71,12 @@ import org.eclipse.apoapsis.ortserver.model.runs.ShortestDependencyPath import org.eclipse.apoapsis.ortserver.model.runs.VcsInfo import org.eclipse.apoapsis.ortserver.model.runs.advisor.AdvisorConfiguration import org.eclipse.apoapsis.ortserver.model.runs.advisor.AdvisorResult +import org.eclipse.apoapsis.ortserver.model.runs.repository.Curations +import org.eclipse.apoapsis.ortserver.model.runs.repository.Excludes +import org.eclipse.apoapsis.ortserver.model.runs.repository.Includes +import org.eclipse.apoapsis.ortserver.model.runs.repository.LicenseChoices +import org.eclipse.apoapsis.ortserver.model.runs.repository.Resolutions +import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution import org.jetbrains.exposed.sql.Database @@ -299,6 +305,25 @@ class Fixtures(private val db: Database) { results = results ) + fun createRepositoryConfiguration( + runId: Long = ortRun.id, + vulnerabilityResolutions: List = emptyList() + ) = repositoryConfigurationRepository.create( + ortRunId = runId, + analyzerConfig = null, + excludes = Excludes(emptyList(), emptyList()), + includes = Includes(emptyList()), + resolutions = Resolutions( + issues = emptyList(), + ruleViolations = emptyList(), + vulnerabilities = vulnerabilityResolutions + ), + curations = Curations(emptyList(), emptyList()), + packageConfigurations = emptyList(), + licenseChoices = LicenseChoices(emptyList(), emptyList()), + provenanceSnippetChoices = emptyList() + ) + fun generatePackage( identifier: Identifier, authors: Set = emptySet(), diff --git a/model/src/commonMain/kotlin/AppliedVulnerabilityResolution.kt b/model/src/commonMain/kotlin/AppliedVulnerabilityResolution.kt new file mode 100644 index 0000000000..0bb9edc43f --- /dev/null +++ b/model/src/commonMain/kotlin/AppliedVulnerabilityResolution.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution + +/** + * A data class that represents a [VulnerabilityResolution] that has been applied, along with its definition if + * available. + */ +data class AppliedVulnerabilityResolution( + /** The applied [VulnerabilityResolution]. */ + val resolution: VulnerabilityResolution, + + /** The definition of the [VulnerabilityResolution], if available. */ + val definition: VulnerabilityResolutionDefinition? = null +) diff --git a/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt b/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt index c276cabe7a..95f1dbbcf1 100644 --- a/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt +++ b/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt @@ -22,7 +22,6 @@ package org.eclipse.apoapsis.ortserver.model import org.eclipse.apoapsis.ortserver.model.runs.Identifier import org.eclipse.apoapsis.ortserver.model.runs.advisor.AdvisorDetails import org.eclipse.apoapsis.ortserver.model.runs.advisor.Vulnerability -import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution /** * A data class to gather information and related data about a [Vulnerability]. @@ -34,7 +33,7 @@ data class VulnerabilityWithDetails( /** An advisory rating for the [Vulnerability], derived from the individual references of the [Vulnerability]. */ val rating: VulnerabilityRating, - val resolutions: List = emptyList(), + val resolutions: List = emptyList(), /** Details about the used advisor. */ val advisor: AdvisorDetails, diff --git a/services/ort-run/src/main/kotlin/VulnerabilityService.kt b/services/ort-run/src/main/kotlin/VulnerabilityService.kt index 29bb66b614..4975fb3d06 100644 --- a/services/ort-run/src/main/kotlin/VulnerabilityService.kt +++ b/services/ort-run/src/main/kotlin/VulnerabilityService.kt @@ -37,18 +37,25 @@ import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunsTable import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.PackageCurationDataTable import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.PackageCurationsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.RepositoryConfigurationsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.RepositoryConfigurationsVulnerabilityResolutionsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.VulnerabilityResolutionsTable import org.eclipse.apoapsis.ortserver.dao.repositories.resolvedconfiguration.ResolvedConfigurationsTable import org.eclipse.apoapsis.ortserver.dao.repositories.resolvedconfiguration.ResolvedPackageCurationProvidersTable import org.eclipse.apoapsis.ortserver.dao.repositories.resolvedconfiguration.ResolvedPackageCurationsTable +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable.toVulnerabilityResolutionDefinition import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifierDao import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifiersTable import org.eclipse.apoapsis.ortserver.dao.utils.applyILike +import org.eclipse.apoapsis.ortserver.model.AppliedVulnerabilityResolution import org.eclipse.apoapsis.ortserver.model.CountByCategory import org.eclipse.apoapsis.ortserver.model.VulnerabilityFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityForRunsFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityRating import org.eclipse.apoapsis.ortserver.model.VulnerabilityWithAccumulatedData import org.eclipse.apoapsis.ortserver.model.VulnerabilityWithDetails +import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution as ModelVulnerabilityResolution import org.eclipse.apoapsis.ortserver.model.util.ComparisonOperator import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult @@ -85,7 +92,7 @@ import org.ossreviewtoolkit.model.utils.toPurl * A service to interact with vulnerabilities. */ class VulnerabilityService(private val db: Database, private val ortRunService: OrtRunService) { - fun listForOrtRunId( + suspend fun listForOrtRunId( ortRunId: Long, parameters: ListQueryParameters = ListQueryParameters.DEFAULT, vulnerabilityFilters: VulnerabilityFilters = VulnerabilityFilters() @@ -159,11 +166,43 @@ class VulnerabilityService(private val db: Database, private val ortRunService: .drop(parameters.offset?.toInt() ?: 0) .take(parameters.limit ?: ListQueryParameters.DEFAULT_LIMIT) + val vulnerabilityResolutionDefinitions = db.dbQuery { + RepositoryConfigurationsVulnerabilityResolutionsTable + .innerJoin(VulnerabilityResolutionsTable) + .innerJoin(RepositoryConfigurationsTable) + .innerJoin(VulnerabilityResolutionDefinitionsTable) + .select( + VulnerabilityResolutionsTable.columns + VulnerabilityResolutionDefinitionsTable.columns + ) + .where { RepositoryConfigurationsTable.ortRunId eq ortRunId } + .map { row -> + row.toVulnerabilityResolutionDefinition() to ModelVulnerabilityResolution( + row[VulnerabilityResolutionsTable.externalId], + row[VulnerabilityResolutionsTable.reason], + row[VulnerabilityResolutionsTable.comment] + ) + } + } + val vulnerabilitiesWithResolutions = limitedVulnerabilities.map { vulnerabilityWithDetails -> val matchingResolutions = resolutions.filter { it.matches(vulnerabilityWithDetails.vulnerability.mapToOrt()) } - vulnerabilityWithDetails.copy(resolutions = matchingResolutions.map { it.mapToModel() }) + vulnerabilityWithDetails.copy( + resolutions = matchingResolutions.map { + val resolution = it.mapToModel() + + val definition = vulnerabilityResolutionDefinitions + .firstOrNull { (_, value) -> + resolution == value + }?.first + + AppliedVulnerabilityResolution( + resolution, + definition + ) + } + ) } return ListQueryResult( diff --git a/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt b/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt index dc5e9b216e..55c49b2e5b 100644 --- a/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt +++ b/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt @@ -22,8 +22,11 @@ package org.eclipse.apoapsis.ortserver.services.ortrun import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.collections.beEmpty import io.kotest.matchers.collections.containExactlyInAnyOrder +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.beNull import io.kotest.matchers.should import io.kotest.matchers.shouldBe @@ -38,9 +41,12 @@ import org.eclipse.apoapsis.ortserver.model.AdvisorJobConfiguration import org.eclipse.apoapsis.ortserver.model.JobConfigurations import org.eclipse.apoapsis.ortserver.model.OrtRun import org.eclipse.apoapsis.ortserver.model.PluginConfig +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.VulnerabilityFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityForRunsFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityRating +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.model.resolvedconfiguration.PackageCurationProviderConfig import org.eclipse.apoapsis.ortserver.model.resolvedconfiguration.ResolvedPackageCurations import org.eclipse.apoapsis.ortserver.model.runs.Environment @@ -272,7 +278,7 @@ class VulnerabilityServiceTest : WordSpec() { with(resolutions) { this shouldHaveSize 1 - this.first() shouldBe vulnerabilityResolution + this.first().resolution shouldBe vulnerabilityResolution } } } @@ -333,7 +339,7 @@ class VulnerabilityServiceTest : WordSpec() { with(resolutions) { this shouldHaveSize 1 - this.first() shouldBe vulnerabilityResolution + this.first().resolution shouldBe vulnerabilityResolution } } @@ -599,6 +605,90 @@ class VulnerabilityServiceTest : WordSpec() { purl shouldBe pkg1.purl } } + + "return the definition of the vulnerability resolution if it originated from the server" { + val run1Id = fixtures.createOrtRun().id + val definitionId = fixtures.createVulnerabilityResolutionDefinition( + contextRunId = run1Id, + idMatchers = listOf("CVE-2021-45046"), + reason = VulnerabilityResolutionReason.MITIGATED_VULNERABILITY + ) + + val run2 = fixtures.createOrtRun() + val advisorJobId = fixtures.createAdvisorJob(run2.id).id + + val vulnerabilities = createVulnerabilities( + Triple( + Identifier("Maven", "org.apache.logging.log4j", "log4j-core", "2.14.0"), + listOf("CVE-2021-45046"), + listOf(10.0) + ), + Triple( + Identifier("Maven", "com.fasterxml.jackson.core", "jackson-databind", "2.9.6"), + listOf("CVE-2018-14721"), + listOf(4.2) + ), + Triple( + Identifier("Maven", "junit", "junit", "1.0"), + listOf("CVE-2024-24521"), + listOf(5.0) + ) + ) + + fixtures.createAdvisorRun(advisorJobId, createAdvisorResults(vulnerabilities)) + + val vulnerabilityResolution = VulnerabilityResolution( + "CVE-2024-24521", + "INEFFECTIVE_VULNERABILITY", + "Comment." + ) + + val repositoryConfiguration = + fixtures.createRepositoryConfiguration(run2.id, listOf(vulnerabilityResolution)) + + fixtures.resolvedConfigurationRepository.addResolutions(run2.id, repositoryConfiguration.resolutions) + + val results = service.listForOrtRunId(run2.id) + + results.totalCount shouldBe 3 + + with(results.data[0]) { + vulnerability.externalId shouldBe "CVE-2018-14721" + resolutions.shouldBeEmpty() + } + + with(results.data[1]) { + vulnerability.externalId shouldBe "CVE-2021-45046" + + resolutions.shouldBeSingleton { + it.resolution shouldBe VulnerabilityResolution( + "CVE-2021-45046", + "MITIGATED_VULNERABILITY", + "Comment." + ) + + it.definition shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + hierarchyId = RepositoryId(run2.repositoryId), + contextRunId = run1Id, + idMatchers = listOf("CVE-2021-45046"), + reason = VulnerabilityResolutionReason.MITIGATED_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + } + + with(results.data[2]) { + vulnerability.externalId shouldBe "CVE-2024-24521" + + resolutions.shouldBeSingleton { + it.resolution shouldBe vulnerabilityResolution + it.definition should beNull() + } + } + } } "countForOrtRunId" should { From 62cf24949231bab5df9b575ee733eec7b01789b0 Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Thu, 30 Oct 2025 08:06:38 +0200 Subject: [PATCH 09/12] feat: Return new matching vulnerability resolution definitions If there have been new vulnerability resolution definitions made on the server since the run, add the matching definitions to the vulnerabilities response, so that it can be reflected on the UI that with a new run, the particular vulnerability would be resolved. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- .../src/commonMain/kotlin/ApiMappings.kt | 1 + .../kotlin/VulnerabilityWithDetails.kt | 5 ++ .../kotlin/api/RunsRouteIntegrationTest.kt | 44 +++++++++++ .../kotlin/VulnerabilityWithDetails.kt | 3 + .../src/main/kotlin/VulnerabilityService.kt | 24 +++++- .../test/kotlin/VulnerabilityServiceTest.kt | 75 +++++++++++++++++++ 6 files changed, 151 insertions(+), 1 deletion(-) diff --git a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt index f9789d929c..25ed213355 100644 --- a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt +++ b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt @@ -612,6 +612,7 @@ fun VulnerabilityWithDetails.mapToApi() = identifier = identifier.mapToApi(), rating = rating.mapToApi(), resolutions = resolutions.map { it.mapToApi() }, + newMatchingResolutionDefinitions = newMatchingResolutionDefinitions.map { it.mapToApi() }, advisor = advisor.mapToApi(), purl = purl ) diff --git a/api/v1/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt b/api/v1/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt index 588ed05d41..db37330cb9 100644 --- a/api/v1/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt +++ b/api/v1/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt @@ -21,6 +21,8 @@ package org.eclipse.apoapsis.ortserver.api.v1.model import kotlinx.serialization.Serializable +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition + /** * A data class to gather information and related data about a [Vulnerability]. */ @@ -34,6 +36,9 @@ data class VulnerabilityWithDetails( val resolutions: List = emptyList(), + /** The resolution definitions that match this vulnerability but were not yet available during the run. */ + val newMatchingResolutionDefinitions: List = emptyList(), + /** Details of the used advisor. */ val advisor: AdvisorDetails, diff --git a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt index cd20847f37..dd52391d8a 100644 --- a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt @@ -26,6 +26,7 @@ import io.kotest.assertions.ktor.client.haveHeader import io.kotest.assertions.ktor.client.shouldHaveStatus import io.kotest.engine.spec.tempdir import io.kotest.inspectors.forAll +import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize @@ -875,6 +876,49 @@ class RunsRouteIntegrationTest : AbstractIntegrationTest({ } } + "return new matching definitions that have been created after the run" { + integrationTestApplication { + val run = dbExtension.fixtures.createOrtRun(repositoryId) + val advisorJobId = dbExtension.fixtures.createAdvisorJob(run.id).id + dbExtension.fixtures.createAdvisorRun(advisorJobId, generateAdvisorResult()) + + val definitionId = dbExtension.fixtures.createVulnerabilityResolutionDefinition( + RepositoryId(repositoryId), + run.id, + listOf("CVE-2021-1234"), + VulnerabilityResolutionReason.INVALID_MATCH_VULNERABILITY + ) + + val response = superuserClient.get("/api/v1/runs/${run.id}/vulnerabilities") + + response shouldHaveStatus HttpStatusCode.OK + val vulnerabilities = response.body>() + + vulnerabilities.data shouldHaveSize 2 + + with(vulnerabilities.data.first()) { + vulnerability.externalId shouldBe "CVE-2018-14721" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions.shouldBeEmpty() + } + + with(vulnerabilities.data.last()) { + vulnerability.externalId shouldBe "CVE-2021-1234" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions shouldHaveSize 1 + + newMatchingResolutionDefinitions.first() shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + idMatchers = listOf("CVE-2021-1234"), + reason = ApiVulnerabilityResolutionReason.INVALID_MATCH_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + } + } + "require RepositoryPermission.READ_ORT_RUNS" { val run = ortRunRepository.create( repositoryId, diff --git a/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt b/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt index 95f1dbbcf1..dc784844a9 100644 --- a/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt +++ b/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt @@ -35,6 +35,9 @@ data class VulnerabilityWithDetails( val resolutions: List = emptyList(), + /** The resolution definitions that match this vulnerability but were not yet available during the run. */ + val newMatchingResolutionDefinitions: List = emptyList(), + /** Details about the used advisor. */ val advisor: AdvisorDetails, diff --git a/services/ort-run/src/main/kotlin/VulnerabilityService.kt b/services/ort-run/src/main/kotlin/VulnerabilityService.kt index 4975fb3d06..d59bc5f7ce 100644 --- a/services/ort-run/src/main/kotlin/VulnerabilityService.kt +++ b/services/ort-run/src/main/kotlin/VulnerabilityService.kt @@ -86,6 +86,7 @@ import org.jetbrains.exposed.sql.stringLiteral import org.jetbrains.exposed.sql.wrapAsExpression import org.ossreviewtoolkit.model.config.VulnerabilityResolution +import org.ossreviewtoolkit.model.config.VulnerabilityResolutionReason import org.ossreviewtoolkit.model.utils.toPurl /** @@ -184,7 +185,27 @@ class VulnerabilityService(private val db: Database, private val ortRunService: } } + val newVulnerabilityResolutionDefinitions = db.dbQuery { + VulnerabilityResolutionDefinitionsTable + .select(VulnerabilityResolutionDefinitionsTable.columns) + .where { + (VulnerabilityResolutionDefinitionsTable.repositoryId eq ortRun.repositoryId) and + (VulnerabilityResolutionDefinitionsTable.contextRunId greaterEq ortRun.id) and + (VulnerabilityResolutionDefinitionsTable.archived eq false) + } + .map { row -> row.toVulnerabilityResolutionDefinition() } + } + val vulnerabilitiesWithResolutions = limitedVulnerabilities.map { vulnerabilityWithDetails -> + val matchingNewDefinitions = newVulnerabilityResolutionDefinitions.filter { definition -> + definition.idMatchers.any { idMatcher -> + VulnerabilityResolution( + idMatcher, + VulnerabilityResolutionReason.valueOf(definition.reason.name), + definition.comment + ).matches(vulnerabilityWithDetails.vulnerability.mapToOrt()) + } + } val matchingResolutions = resolutions.filter { it.matches(vulnerabilityWithDetails.vulnerability.mapToOrt()) } @@ -201,7 +222,8 @@ class VulnerabilityService(private val db: Database, private val ortRunService: resolution, definition ) - } + }, + newMatchingResolutionDefinitions = matchingNewDefinitions ) } diff --git a/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt b/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt index 55c49b2e5b..f45fc01020 100644 --- a/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt +++ b/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt @@ -689,6 +689,81 @@ class VulnerabilityServiceTest : WordSpec() { } } } + + "return new matching definitions that have been created after the run" { + val run = fixtures.createOrtRun() + val advisorJobId = fixtures.createAdvisorJob(run.id).id + + val vulnerabilities = createVulnerabilities( + Triple( + Identifier("Maven", "org.apache.logging.log4j", "log4j-core", "2.14.0"), + listOf("CVE-2021-45046"), + listOf(10.0) + ), + Triple( + Identifier("Maven", "com.fasterxml.jackson.core", "jackson-databind", "2.9.6"), + listOf("CVE-2018-14721"), + listOf(4.2) + ), + Triple( + Identifier("Maven", "junit", "junit", "1.0"), + listOf("CVE-2024-24521"), + listOf(5.0) + ) + ) + + fixtures.createAdvisorRun(advisorJobId, createAdvisorResults(vulnerabilities)) + + val definitionId = fixtures.createVulnerabilityResolutionDefinition( + contextRunId = run.id, + idMatchers = listOf("CVE-2021-45046", "CVE-2024-24521"), + reason = VulnerabilityResolutionReason.WORKAROUND_FOR_VULNERABILITY + ) + + val results = service.listForOrtRunId(run.id) + + results.totalCount shouldBe 3 + + with(results.data[0]) { + vulnerability.externalId shouldBe "CVE-2018-14721" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions.shouldBeEmpty() + } + + with(results.data[1]) { + vulnerability.externalId shouldBe "CVE-2021-45046" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions shouldHaveSize 1 + + newMatchingResolutionDefinitions.first() shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + hierarchyId = RepositoryId(run.repositoryId), + contextRunId = run.id, + idMatchers = listOf("CVE-2021-45046", "CVE-2024-24521"), + reason = VulnerabilityResolutionReason.WORKAROUND_FOR_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + + with(results.data[2]) { + vulnerability.externalId shouldBe "CVE-2024-24521" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions shouldHaveSize 1 + + newMatchingResolutionDefinitions.first() shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + hierarchyId = RepositoryId(run.repositoryId), + contextRunId = run.id, + idMatchers = listOf("CVE-2021-45046", "CVE-2024-24521"), + reason = VulnerabilityResolutionReason.WORKAROUND_FOR_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + } } "countForOrtRunId" should { From 0bec11a25f6aa5347550a478883b7a4354226683 Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Tue, 28 Oct 2025 17:29:26 +0200 Subject: [PATCH 10/12] feat: Allow to archive vulnerability resolutions Implement deleting a vulnerability resolution by archiving the item, i.e. with soft deletion. The resolution will be marked as archived, which means that on new runs, the resolution won't be injected in the repository configuration anymore. The resolution will still be a part of the results of any run where it was injected earlier, and the information that it originated from a definition on the server is retained. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- ...ulnerabilityResolutionDefinitionService.kt | 39 ++++++ .../backend/src/routes/kotlin/Routing.kt | 2 + .../DeleteVulnerabilityResolution.kt | 121 ++++++++++++++++++ ...rabilityResolutionDefinitionServiceTest.kt | 103 +++++++++++++++ .../routes/ResolutionsAuthorizationTest.kt | 33 +++++ ...eVulnerabilityResolutionIntegrationTest.kt | 105 +++++++++++++++ 6 files changed, 403 insertions(+) create mode 100644 components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolution.kt create mode 100644 components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolutionIntegrationTest.kt diff --git a/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt index 0f8d342828..abaa8883e3 100644 --- a/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt +++ b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt @@ -20,6 +20,8 @@ package org.eclipse.apoapsis.ortserver.components.resolutions import org.eclipse.apoapsis.ortserver.dao.dbQuery +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.RepositoryConfigurationsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.RepositoryConfigurationsVulnerabilityResolutionsTable import org.eclipse.apoapsis.ortserver.dao.repositories.userDisplayName.UserDisplayNameDao import org.eclipse.apoapsis.ortserver.dao.tables.ChangeLogTable import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable @@ -29,6 +31,7 @@ import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.UserDisplayName import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.model.util.asPresent import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService import org.jetbrains.exposed.sql.Database @@ -60,6 +63,29 @@ class VulnerabilityResolutionDefinitionService(private val db: Database, private VulnerabilityResolutionDefinitionsTable.get(id) } + suspend fun archive(id: Long, userDisplayName: UserDisplayName): VulnerabilityResolutionDefinition = db.dbQuery { + VulnerabilityResolutionDefinitionsTable.updateDefinition( + id, + archivedInput = true.asPresent() + ) + + addChangeLogEvent(id, ChangeEventAction.ARCHIVE, userDisplayName) + + val definition = VulnerabilityResolutionDefinitionsTable.get(id) + + ortRunService.markAsOutdated( + getAffectedRuns(id) + definition.contextRunId, + "Vulnerability resolution definition archived." + ) + + definition + } + + suspend fun getById(id: Long): VulnerabilityResolutionDefinition? = db.dbQuery { + VulnerabilityResolutionDefinitionsTable + .getOrNull(id) + } + private fun addChangeLogEvent( entityId: Long, action: ChangeEventAction, @@ -75,4 +101,17 @@ class VulnerabilityResolutionDefinitionService(private val db: Database, private action ) } + + private fun getAffectedRuns( + definitionId: Long + ): List { + return RepositoryConfigurationsVulnerabilityResolutionsTable + .innerJoin(RepositoryConfigurationsTable) + .select(RepositoryConfigurationsTable.ortRunId) + .where { + RepositoryConfigurationsVulnerabilityResolutionsTable.vulnerabilityResolutionDefinitionId eq + definitionId + } + .map { it[RepositoryConfigurationsTable.ortRunId].value } + } } diff --git a/components/resolutions/backend/src/routes/kotlin/Routing.kt b/components/resolutions/backend/src/routes/kotlin/Routing.kt index b9d7226c98..88baa59540 100644 --- a/components/resolutions/backend/src/routes/kotlin/Routing.kt +++ b/components/resolutions/backend/src/routes/kotlin/Routing.kt @@ -21,6 +21,7 @@ package org.eclipse.apoapsis.ortserver.components.resolutions import io.ktor.server.routing.Route +import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.deleteVulnerabilityResolution import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.postVulnerabilityResolution import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService @@ -30,4 +31,5 @@ fun Route.resolutionsRoutes( vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService ) { postVulnerabilityResolution(ortRunService, vulnerabilityResolutionDefinitionService) + deleteVulnerabilityResolution(vulnerabilityResolutionDefinitionService) } diff --git a/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolution.kt b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolution.kt new file mode 100644 index 0000000000..4b33dd963f --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolution.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.github.smiley4.ktoropenapi.delete + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.principal +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter +import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError + +internal fun Route.deleteVulnerabilityResolution( + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) = delete("/resolutions/vulnerabilities/{id}", { + operationId = "deleteVulnerabilityResolution" + summary = "Delete a vulnerability resolution" + tags = listOf("Resolutions") + + request { + pathParameter("id") { + description = "The ID of the vulnerability resolution definition" + } + } + + response { + HttpStatusCode.OK to { + description = "Success" + + jsonBody { + example("Delete Vulnerability Resolution") { + value = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + comment = "Comment", + archived = true, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-01T00:00:00Z"), + ChangeEventAction.CREATE + ), + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-02T00:00:00Z"), + ChangeEventAction.ARCHIVE + ) + ) + ) + } + } + } + + HttpStatusCode.NoContent to { + description = "The vulnerability resolution was already archived." + } + } +}) { + val id = call.requireIdParameter("id") + + val definition = vulnerabilityResolutionDefinitionService.getById(id) ?: throw AuthorizationException() + + requirePermission(RepositoryPermission.WRITE.roleName(definition.hierarchyId.value)) + + if (definition.archived) { + call.respond(HttpStatusCode.NoContent, "The vulnerability resolution was already archived.") + return@delete + } + + // Extract the user information from the principal. + val userDisplayName = call.principal()?.let { principal -> + ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName()) + } + + if (userDisplayName == null) { + call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.") + return@delete + } + + val archivedDefinition = vulnerabilityResolutionDefinitionService.archive(id, userDisplayName).mapToApi() + + call.respond(HttpStatusCode.OK, archivedDefinition) +} diff --git a/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt index 29a66f27b0..2113f27d9f 100644 --- a/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt +++ b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt @@ -21,6 +21,7 @@ package org.eclipse.apoapsis.ortserver.components.resolutions import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe @@ -132,4 +133,106 @@ class VulnerabilityResolutionDefinitionServiceTest : WordSpec({ } } } + + "archive" should { + "archive a vulnerability resolution definition" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + val archivedDefinition = definitionService.archive(definitionId, userDisplayName) + + with(archivedDefinition) { + archived shouldBe true + changes shouldHaveSize 2 + + with(changes.first()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.CREATE + } + + with(changes.last()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.ARCHIVE + } + } + } + + "mark the affected runs as outdated" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + val run2Id = fixtures.createOrtRun().id + + val repositoryConfiguration = fixtures.createRepositoryConfiguration(run2Id) + fixtures.resolvedConfigurationRepository.addResolutions(run2Id, repositoryConfiguration.resolutions) + + val run3Id = fixtures.createOrtRun().id + + definitionService.archive(definitionId, userDisplayName) + + ortRunService.getOrtRun(runId).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition archived." + } + + ortRunService.getOrtRun(run2Id).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition archived." + } + + ortRunService.getOrtRun(run3Id).shouldNotBeNull { + outdated shouldBe false + outdatedMessage.shouldBeNull() + } + } + } + + "getById" should { + "return the vulnerability resolution definition if it exists" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definition = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + + definitionService.getById(definition.id) shouldBe definition + } + + "return null if the vulnerability resolution definition doesn't exist" { + definitionService.getById(1) shouldBe null + } + } }) diff --git a/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt index 038ac79bba..38097de963 100644 --- a/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt +++ b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt @@ -19,6 +19,7 @@ package org.eclipse.apoapsis.ortserver.components.resolutions.routes +import io.ktor.client.request.delete import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.HttpStatusCode @@ -30,7 +31,10 @@ import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityRe import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService import org.eclipse.apoapsis.ortserver.components.resolutions.resolutionsRoutes import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.UserDisplayName import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractAuthorizationTest @@ -115,4 +119,33 @@ class ResolutionsAuthorizationTest : AbstractAuthorizationTest({ } } } + + "DeleteVulnerabilityResolution" should { + "require role RepositoryPermission.WRITE.roleName(repositoryId)" { + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + UserDisplayName("abc", "Test"), + createBody.idMatchers, + createBody.reason.mapToModel(), + createBody.comment + ).id + + requestShouldRequireRole( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + role = RepositoryPermission.WRITE.roleName(repositoryId) + ) { + delete("/resolutions/vulnerabilities/$definitionId") + } + } + + "respond with 'Forbidden' when repository id cannot be resolved" { + requestShouldRequireAuthentication( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + successStatus = HttpStatusCode.Forbidden + ) { + delete("/resolutions/vulnerabilities/9999") + } + } + } }) diff --git a/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolutionIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolutionIntegrationTest.kt new file mode 100644 index 0000000000..a862bbbcf1 --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolutionIntegrationTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +class DeleteVulnerabilityResolutionIntegrationTest : ResolutionsIntegrationTest({ + var runId = 0L + + lateinit var fixtures: Fixtures + + beforeEach { + fixtures = dbExtension.fixtures + runId = fixtures.ortRun.id + } + + "DeleteVulnerabilityResolution" should { + "archive the definition" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + val deleteResponse = client.delete("/resolutions/vulnerabilities/$definitionId") + + with(deleteResponse.body()) { + idMatchers shouldBe listOf("CVE-2020-15250") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Comment." + archived shouldBe true + changes shouldHaveSize 2 + changes.first().user shouldBe UserDisplayName("test", "Test User") + changes.first().action shouldBe ChangeEventAction.CREATE + changes.last().user shouldBe UserDisplayName("test", "Test User") + changes.last().action shouldBe ChangeEventAction.ARCHIVE + } + } + } + + "respond with 'No content' if the definition was already archived" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + client.delete("/resolutions/vulnerabilities/$definitionId") + + val deleteResponse = client.delete("/resolutions/vulnerabilities/$definitionId") + + deleteResponse shouldHaveStatus HttpStatusCode.NoContent + } + } + } +}) From bb383f26b0c1c8759e0e490b87c6c41cb9f7e1ac Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Tue, 28 Oct 2025 17:46:04 +0200 Subject: [PATCH 11/12] feat: Allow to restore vulnerability resolutions Allow to restore a previously archived resolution. After the restoration the resolution will again be injected to the repository configuration of new runs. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- ...ulnerabilityResolutionDefinitionService.kt | 18 +++ .../backend/src/routes/kotlin/Routing.kt | 2 + .../RestoreVulnerabilityResolution.kt | 126 ++++++++++++++++++ ...rabilityResolutionDefinitionServiceTest.kt | 85 ++++++++++++ .../routes/ResolutionsAuthorizationTest.kt | 33 +++++ ...eVulnerabilityResolutionIntegrationTest.kt | 109 +++++++++++++++ 6 files changed, 373 insertions(+) create mode 100644 components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolution.kt create mode 100644 components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolutionIntegrationTest.kt diff --git a/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt index abaa8883e3..f4a6eb9a4c 100644 --- a/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt +++ b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt @@ -81,6 +81,24 @@ class VulnerabilityResolutionDefinitionService(private val db: Database, private definition } + suspend fun restore(id: Long, userDisplayName: UserDisplayName): VulnerabilityResolutionDefinition = db.dbQuery { + VulnerabilityResolutionDefinitionsTable.updateDefinition( + id, + archivedInput = false.asPresent() + ) + + addChangeLogEvent(id, ChangeEventAction.RESTORE, userDisplayName) + + val definition = VulnerabilityResolutionDefinitionsTable.get(id) + + ortRunService.markAsOutdated( + getAffectedRuns(id) + definition.contextRunId, + "Vulnerability resolution definition restored." + ) + + definition + } + suspend fun getById(id: Long): VulnerabilityResolutionDefinition? = db.dbQuery { VulnerabilityResolutionDefinitionsTable .getOrNull(id) diff --git a/components/resolutions/backend/src/routes/kotlin/Routing.kt b/components/resolutions/backend/src/routes/kotlin/Routing.kt index 88baa59540..057846b2cf 100644 --- a/components/resolutions/backend/src/routes/kotlin/Routing.kt +++ b/components/resolutions/backend/src/routes/kotlin/Routing.kt @@ -23,6 +23,7 @@ import io.ktor.server.routing.Route import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.deleteVulnerabilityResolution import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.postVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.restoreVulnerabilityResolution import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService /** Add all resolutions routes. */ @@ -32,4 +33,5 @@ fun Route.resolutionsRoutes( ) { postVulnerabilityResolution(ortRunService, vulnerabilityResolutionDefinitionService) deleteVulnerabilityResolution(vulnerabilityResolutionDefinitionService) + restoreVulnerabilityResolution(vulnerabilityResolutionDefinitionService) } diff --git a/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolution.kt b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolution.kt new file mode 100644 index 0000000000..12a6ddc169 --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolution.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.github.smiley4.ktoropenapi.post + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.principal +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter +import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError + +internal fun Route.restoreVulnerabilityResolution( + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) = post("/resolutions/vulnerabilities/{id}/restore", { + operationId = "restoreVulnerabilityResolution" + summary = "Restore a vulnerability resolution" + tags = listOf("Resolutions") + + request { + pathParameter("id") { + description = "The ID of the vulnerability resolution definition." + } + } + + response { + HttpStatusCode.OK to { + description = "Success" + jsonBody { + example("Restore Vulnerability Resolution") { + value = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + comment = "Comment", + archived = false, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-01T00:00:00Z"), + ChangeEventAction.CREATE + ), + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-03T00:00:00Z"), + ChangeEventAction.ARCHIVE + ), + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-04T00:00:00Z"), + ChangeEventAction.RESTORE + ) + ) + ) + } + } + } + + HttpStatusCode.NoContent to { + description = "The vulnerability resolution is not archived." + } + } +}) { + val id = call.requireIdParameter("id") + + val definition = vulnerabilityResolutionDefinitionService.getById(id) ?: throw AuthorizationException() + + requirePermission(RepositoryPermission.WRITE.roleName(definition.hierarchyId.value)) + + if (!definition.archived) { + call.respond(HttpStatusCode.NoContent, "The vulnerability resolution is not archived.") + return@post + } + + // Extract the user information from the principal. + val userDisplayName = call.principal()?.let { principal -> + ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName()) + } + + if (userDisplayName == null) { + call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.") + return@post + } + + val vulnerabilityResolutionDefinition = + vulnerabilityResolutionDefinitionService.restore(id, userDisplayName).mapToApi() + + call.respond(HttpStatusCode.OK, vulnerabilityResolutionDefinition) +} diff --git a/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt index 2113f27d9f..bb62d9469f 100644 --- a/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt +++ b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt @@ -211,6 +211,91 @@ class VulnerabilityResolutionDefinitionServiceTest : WordSpec({ } } + "restore" should { + "restore an archived vulnerability resolution definition" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + definitionService.archive(definitionId, userDisplayName) + + val restoredDefinition = definitionService.restore(definitionId, userDisplayName) + + with(restoredDefinition) { + archived shouldBe false + changes shouldHaveSize 3 + + with(changes[0]) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.CREATE + } + + with(changes[1]) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.ARCHIVE + } + + with(changes[2]) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.RESTORE + } + } + } + + "mark the affected runs as outdated" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + val run2Id = fixtures.createOrtRun().id + + val repositoryConfiguration = fixtures.createRepositoryConfiguration(run2Id) + fixtures.resolvedConfigurationRepository.addResolutions(run2Id, repositoryConfiguration.resolutions) + + val run3Id = fixtures.createOrtRun().id + + definitionService.archive(definitionId, userDisplayName) + definitionService.restore(definitionId, userDisplayName) + + ortRunService.getOrtRun(runId).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition restored." + } + + ortRunService.getOrtRun(run2Id).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition restored." + } + + ortRunService.getOrtRun(run3Id).shouldNotBeNull { + outdated shouldBe false + outdatedMessage.shouldBeNull() + } + } + } + "getById" should { "return the vulnerability resolution definition if it exists" { val userDisplayName = UserDisplayName( diff --git a/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt index 38097de963..89c0d414d6 100644 --- a/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt +++ b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt @@ -148,4 +148,37 @@ class ResolutionsAuthorizationTest : AbstractAuthorizationTest({ } } } + + "RestoreVulnerabilityResolution" should { + "require role RepositoryPermission.WRITE.roleName(repositoryId)" { + val userDisplayName = UserDisplayName("abc", "Test") + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + createBody.idMatchers, + createBody.reason.mapToModel(), + createBody.comment + ).id + + definitionService.archive(definitionId, userDisplayName) + + requestShouldRequireRole( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + role = RepositoryPermission.WRITE.roleName(repositoryId) + ) { + post("/resolutions/vulnerabilities/$definitionId/restore") + } + } + + "respond with 'Forbidden' when repository ID cannot be resolved" { + requestShouldRequireAuthentication( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + successStatus = HttpStatusCode.Forbidden + ) { + post("/resolutions/vulnerabilities/9999/restore") + } + } + } }) diff --git a/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolutionIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolutionIntegrationTest.kt new file mode 100644 index 0000000000..b54bc9ee22 --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolutionIntegrationTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +class RestoreVulnerabilityResolutionIntegrationTest : ResolutionsIntegrationTest({ + var runId = 0L + + lateinit var fixtures: Fixtures + + beforeEach { + fixtures = dbExtension.fixtures + runId = fixtures.ortRun.id + } + + "RestoreVulnerabilityResolution" should { + "restore the definition" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + client.delete("/resolutions/vulnerabilities/$definitionId") + + val restoreResponse = client.post("/resolutions/vulnerabilities/$definitionId/restore") + + restoreResponse shouldHaveStatus HttpStatusCode.OK + + with(restoreResponse.body()) { + idMatchers shouldBe listOf("CVE-2020-15250") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Comment." + archived shouldBe false + changes shouldHaveSize 3 + changes[0].user shouldBe UserDisplayName("test", "Test User") + changes[0].action shouldBe ChangeEventAction.CREATE + changes[1].user shouldBe UserDisplayName("test", "Test User") + changes[1].action shouldBe ChangeEventAction.ARCHIVE + changes[2].user shouldBe UserDisplayName("test", "Test User") + changes[2].action shouldBe ChangeEventAction.RESTORE + } + } + } + + "respond with 'No content' if the definition is not archived" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + val restoreResponse = client.post("/resolutions/vulnerabilities/$definitionId/restore") + + restoreResponse shouldHaveStatus HttpStatusCode.NoContent + } + } + } +}) From 91f0ce90f476518d9180dbbea0f15296994164d7 Mon Sep 17 00:00:00 2001 From: Johanna Lamppu Date: Tue, 28 Oct 2025 18:15:40 +0200 Subject: [PATCH 12/12] feat: Allow to update vulnerability resolutions Updating a vulnerability resolution will update the definition, and the new updated values will be used when injecting the resolution on new runs. Old runs where the resolution was injected to previously will still retain the information of what the resolution was when the run was made. Relates to https://github.com/eclipse-apoapsis/ort-server/issues/1009. Signed-off-by: Johanna Lamppu --- .../kotlin/PatchVulnerabilityResolution.kt | 42 +++++ ...ulnerabilityResolutionDefinitionService.kt | 27 ++++ .../backend/src/routes/kotlin/Routing.kt | 2 + .../PatchVulnerabilityResolution.kt | 144 ++++++++++++++++++ ...rabilityResolutionDefinitionServiceTest.kt | 87 +++++++++++ .../routes/ResolutionsAuthorizationTest.kt | 44 ++++++ ...hVulnerabilityResolutionIntegrationTest.kt | 122 +++++++++++++++ 7 files changed, 468 insertions(+) create mode 100644 components/resolutions/api-model/src/commonMain/kotlin/PatchVulnerabilityResolution.kt create mode 100644 components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PatchVulnerabilityResolution.kt create mode 100644 components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PatchVulnerabilityResolutionIntegrationTest.kt diff --git a/components/resolutions/api-model/src/commonMain/kotlin/PatchVulnerabilityResolution.kt b/components/resolutions/api-model/src/commonMain/kotlin/PatchVulnerabilityResolution.kt new file mode 100644 index 0000000000..c5b7a5ef72 --- /dev/null +++ b/components/resolutions/api-model/src/commonMain/kotlin/PatchVulnerabilityResolution.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import kotlinx.serialization.Serializable + +import org.eclipse.apoapsis.ortserver.shared.apimodel.OptionalValue +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +/** + * The request object for updating a vulnerability resolution. + */ +@Serializable +data class PatchVulnerabilityResolution( + /** + * The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerabilities to resolve. + */ + val idMatchers: OptionalValue> = OptionalValue.Absent, + + /** The reason why the vulnerability is resolved. */ + val reason: OptionalValue = OptionalValue.Absent, + + /** A comment to further explain why the [reason] is applicable here. */ + val comment: OptionalValue = OptionalValue.Absent +) diff --git a/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt index f4a6eb9a4c..4459285dcc 100644 --- a/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt +++ b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt @@ -31,6 +31,7 @@ import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.UserDisplayName import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.model.util.OptionalValue import org.eclipse.apoapsis.ortserver.model.util.asPresent import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService @@ -99,6 +100,32 @@ class VulnerabilityResolutionDefinitionService(private val db: Database, private definition } + suspend fun update( + id: Long, + userDisplayName: UserDisplayName, + idMatchers: OptionalValue> = OptionalValue.Absent, + reason: OptionalValue = OptionalValue.Absent, + comment: OptionalValue = OptionalValue.Absent + ): VulnerabilityResolutionDefinition = db.dbQuery { + VulnerabilityResolutionDefinitionsTable.updateDefinition( + id, + idMatchers, + reason, + comment + ) + + addChangeLogEvent(id, ChangeEventAction.UPDATE, userDisplayName) + + val definition = VulnerabilityResolutionDefinitionsTable.get(id) + + ortRunService.markAsOutdated( + getAffectedRuns(id) + definition.contextRunId, + "Vulnerability resolution definition updated." + ) + + definition + } + suspend fun getById(id: Long): VulnerabilityResolutionDefinition? = db.dbQuery { VulnerabilityResolutionDefinitionsTable .getOrNull(id) diff --git a/components/resolutions/backend/src/routes/kotlin/Routing.kt b/components/resolutions/backend/src/routes/kotlin/Routing.kt index 057846b2cf..ef0bc112bf 100644 --- a/components/resolutions/backend/src/routes/kotlin/Routing.kt +++ b/components/resolutions/backend/src/routes/kotlin/Routing.kt @@ -22,6 +22,7 @@ package org.eclipse.apoapsis.ortserver.components.resolutions import io.ktor.server.routing.Route import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.deleteVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.patchVulnerabilityResolution import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.postVulnerabilityResolution import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.restoreVulnerabilityResolution import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService @@ -34,4 +35,5 @@ fun Route.resolutionsRoutes( postVulnerabilityResolution(ortRunService, vulnerabilityResolutionDefinitionService) deleteVulnerabilityResolution(vulnerabilityResolutionDefinitionService) restoreVulnerabilityResolution(vulnerabilityResolutionDefinitionService) + patchVulnerabilityResolution(vulnerabilityResolutionDefinitionService) } diff --git a/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PatchVulnerabilityResolution.kt b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PatchVulnerabilityResolution.kt new file mode 100644 index 0000000000..0e8c7f2334 --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PatchVulnerabilityResolution.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.github.smiley4.ktoropenapi.patch + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.resolutions.PatchVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter +import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError + +internal fun Route.patchVulnerabilityResolution( + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) = patch("/resolutions/vulnerabilities/{id}", { + operationId = "patchVulnerabilityResolution" + summary = "Update a vulnerability resolution" + tags = listOf("Resolutions") + + request { + pathParameter("id") { + description = "The ID of the vulnerability resolution definition" + } + + jsonBody { + description = "Set the values that should be updated." + example("Update Vulnerability Resolution") { + value = PatchVulnerabilityResolution( + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp").asPresent(), + reason = VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY.asPresent(), + comment = "Updated comment.".asPresent() + ) + } + } + } + + response { + HttpStatusCode.OK to { + description = "Success" + + jsonBody { + example("Update Vulnerability Resolution") { + value = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + comment = "Updated comment.", + archived = false, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-01T00:00:00Z"), + action = ChangeEventAction.CREATE + ), + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-02T00:00:00Z"), + action = ChangeEventAction.UPDATE + ) + ) + ) + } + } + } + + HttpStatusCode.BadRequest to { + description = "The requested vulnerability resolution is archived." + } + } +}) { + val id = call.requireIdParameter("id") + + val definition = vulnerabilityResolutionDefinitionService.getById(id) ?: throw AuthorizationException() + + requirePermission(RepositoryPermission.WRITE.roleName(definition.hierarchyId.value)) + + if (definition.archived) { + call.respondError(HttpStatusCode.Conflict, "The requested vulnerability resolution is archived.") + return@patch + } + + val updateResolution = call.receive() + + // Extract the user information from the principal. + val userDisplayName = call.principal()?.let { principal -> + ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName()) + } + + if (userDisplayName == null) { + call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.") + return@patch + } + + val updatedDefinition = vulnerabilityResolutionDefinitionService.update( + id, + userDisplayName, + updateResolution.idMatchers.mapToModel(), + updateResolution.reason.mapToModel { it.mapToModel() }, + updateResolution.comment.mapToModel() + ).mapToApi() + + call.respond(HttpStatusCode.OK, updatedDefinition) +} diff --git a/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt index bb62d9469f..cd926dfe5f 100644 --- a/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt +++ b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt @@ -33,6 +33,7 @@ import org.eclipse.apoapsis.ortserver.model.ChangeEventAction import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.UserDisplayName import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.model.util.asPresent import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService import org.jetbrains.exposed.sql.Database @@ -296,6 +297,92 @@ class VulnerabilityResolutionDefinitionServiceTest : WordSpec({ } } + "update" should { + "update a vulnerability resolution definition and add an event in the change log" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + "Comment." + ).id + + val updatedDefinition = definitionService.update( + definitionId, + userDisplayName, + listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp").asPresent(), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.asPresent(), + "Updated comment.".asPresent() + ) + + with(updatedDefinition) { + idMatchers shouldBe listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Updated comment." + archived shouldBe false + changes shouldHaveSize 2 + + with(changes.first()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.CREATE + } + + with(changes.last()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.UPDATE + } + } + } + + "mark the affected runs as outdated" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + val run2Id = fixtures.createOrtRun().id + + val repositoryConfiguration = fixtures.createRepositoryConfiguration(run2Id) + fixtures.resolvedConfigurationRepository.addResolutions(run2Id, repositoryConfiguration.resolutions) + + val run3Id = fixtures.createOrtRun().id + + definitionService.update(definitionId, userDisplayName, comment = "Updated comment.".asPresent()) + + ortRunService.getOrtRun(runId).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition updated." + } + + ortRunService.getOrtRun(run2Id).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition updated." + } + + ortRunService.getOrtRun(run3Id).shouldNotBeNull { + outdated shouldBe false + outdatedMessage.shouldBeNull() + } + } + } + "getById" should { "return the vulnerability resolution definition if it exists" { val userDisplayName = UserDisplayName( diff --git a/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt index 89c0d414d6..3d7634e4af 100644 --- a/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt +++ b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt @@ -20,6 +20,7 @@ package org.eclipse.apoapsis.ortserver.components.resolutions.routes import io.ktor.client.request.delete +import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.HttpStatusCode @@ -27,6 +28,7 @@ import io.ktor.http.HttpStatusCode import io.mockk.mockk import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.resolutions.PatchVulnerabilityResolution import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService import org.eclipse.apoapsis.ortserver.components.resolutions.resolutionsRoutes @@ -36,6 +38,7 @@ import org.eclipse.apoapsis.ortserver.model.UserDisplayName import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractAuthorizationTest import org.jetbrains.exposed.sql.Database @@ -181,4 +184,45 @@ class ResolutionsAuthorizationTest : AbstractAuthorizationTest({ } } } + + "PatchVulnerabilityResolution" should { + "require role RepositoryPermission.WRITE.roleName(repositoryId)" { + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + UserDisplayName("abc", "Test"), + createBody.idMatchers, + createBody.reason.mapToModel(), + createBody.comment + ).id + + requestShouldRequireRole( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + role = RepositoryPermission.WRITE.roleName(repositoryId) + ) { + patch("/resolutions/vulnerabilities/$definitionId") { + setBody( + PatchVulnerabilityResolution( + comment = "Updated comment.".asPresent() + ) + ) + } + } + } + + "respond with 'Forbidden' when repository ID cannot be resolved" { + requestShouldRequireAuthentication( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + successStatus = HttpStatusCode.Forbidden + ) { + patch("/resolutions/vulnerabilities/9999") { + setBody( + PatchVulnerabilityResolution( + comment = "Updated comment.".asPresent() + ) + ) + } + } + } + } }) diff --git a/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PatchVulnerabilityResolutionIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PatchVulnerabilityResolutionIntegrationTest.kt new file mode 100644 index 0000000000..c8aef1aede --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PatchVulnerabilityResolutionIntegrationTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.resolutions.PatchVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent + +class PatchVulnerabilityResolutionIntegrationTest : ResolutionsIntegrationTest({ + var runId = 0L + + lateinit var fixtures: Fixtures + + beforeEach { + fixtures = dbExtension.fixtures + runId = fixtures.ortRun.id + } + + "PatchVulnerabilityResolution" should { + "update a vulnerability resolution definition" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + val patchResponse = client.patch("/resolutions/vulnerabilities/$definitionId") { + setBody( + PatchVulnerabilityResolution( + listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp").asPresent(), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.asPresent(), + "Updated comment.".asPresent() + ) + ) + } + + with(patchResponse.body()) { + idMatchers shouldBe listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Updated comment." + archived shouldBe false + changes shouldHaveSize 2 + changes.first().user shouldBe UserDisplayName("test", "Test User") + changes.first().action shouldBe ChangeEventAction.CREATE + changes.last().user shouldBe UserDisplayName("test", "Test User") + changes.last().action shouldBe ChangeEventAction.UPDATE + } + } + } + + "respond with 'Conflict' if the definition is archived" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + client.delete("/resolutions/vulnerabilities/$definitionId") + + val patchResponse = client.patch("/resolutions/vulnerabilities/$definitionId") { + setBody( + PatchVulnerabilityResolution( + comment = "Updated comment.".asPresent() + ) + ) + } + + patchResponse shouldHaveStatus HttpStatusCode.Conflict + } + } + } +})