Skip to content

Commit 779209d

Browse files
committed
feat: Allow to update vulnerability resolution definitions
Signed-off-by: Johanna Lamppu <[email protected]>
1 parent 64347f6 commit 779209d

File tree

7 files changed

+435
-0
lines changed

7 files changed

+435
-0
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.eclipse.apoapsis.ortserver.components.resolutions
21+
22+
import kotlinx.serialization.Serializable
23+
24+
import org.eclipse.apoapsis.ortserver.shared.apimodel.OptionalValue
25+
import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason
26+
27+
/**
28+
* The request object for updating a vulnerability resolution definition.
29+
*/
30+
@Serializable
31+
data class UpdateVulnerabilityResolutionDefinition(
32+
/**
33+
* The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerabilities to resolve.
34+
*/
35+
val idMatchers: OptionalValue<List<String>> = OptionalValue.Absent,
36+
37+
/** The reason why the vulnerability is resolved. */
38+
val reason: OptionalValue<VulnerabilityResolutionReason> = OptionalValue.Absent,
39+
40+
/** A comment to further explain why the [reason] is applicable here. */
41+
val comment: OptionalValue<String> = OptionalValue.Absent
42+
)

components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import org.eclipse.apoapsis.ortserver.model.RepositoryId
3131
import org.eclipse.apoapsis.ortserver.model.UserDisplayName
3232
import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition
3333
import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason
34+
import org.eclipse.apoapsis.ortserver.model.util.OptionalValue
3435
import org.eclipse.apoapsis.ortserver.model.util.asPresent
3536
import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService
3637

@@ -119,6 +120,38 @@ class VulnerabilityResolutionDefinitionService(private val db: Database, private
119120
VulnerabilityResolutionDefinitionsTable.get(id)
120121
}
121122

123+
suspend fun update(
124+
id: Long,
125+
userDisplayName: UserDisplayName,
126+
idMatchers: OptionalValue<List<String>>,
127+
reason: OptionalValue<VulnerabilityResolutionReason>,
128+
comment: OptionalValue<String>
129+
): VulnerabilityResolutionDefinition = db.dbQuery {
130+
val user =
131+
UserDisplayNameDao.insertOrUpdate(userDisplayName) ?: throw NullPointerException("No user created or found")
132+
133+
VulnerabilityResolutionDefinitionsTable.updateDefinition(
134+
id,
135+
idMatchers,
136+
reason,
137+
comment
138+
)
139+
140+
ChangeLogTable.insert(
141+
ChangeEventEntityType.VULNERABILITY_RESOLUTION_DEFINITION,
142+
id.toString(),
143+
user.id.value,
144+
ChangeEventAction.UPDATE
145+
)
146+
147+
ortRunService.markAsOutdated(
148+
getAffectedRuns(id),
149+
"Vulnerability resolution definition updated."
150+
)
151+
152+
VulnerabilityResolutionDefinitionsTable.get(id)
153+
}
154+
122155
suspend fun getById(id: Long): VulnerabilityResolutionDefinition? = db.dbQuery {
123156
VulnerabilityResolutionDefinitionsTable
124157
.getOrNull(id)

components/resolutions/backend/src/routes/kotlin/Routing.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ package org.eclipse.apoapsis.ortserver.components.resolutions
2222
import io.ktor.server.routing.Route
2323

2424
import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.deleteVulnerabilityResolutionDefinition
25+
import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.patchVulnerabilityResolutionDefinition
2526
import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.postVulnerabilityResolutionDefinition
2627
import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.restoreVulnerabilityResolutionDefinition
2728
import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService
@@ -34,4 +35,5 @@ fun Route.resolutionsRoutes(
3435
postVulnerabilityResolutionDefinition(ortRunService, vulnerabilityResolutionDefinitionService)
3536
deleteVulnerabilityResolutionDefinition(vulnerabilityResolutionDefinitionService)
3637
restoreVulnerabilityResolutionDefinition(vulnerabilityResolutionDefinitionService)
38+
patchVulnerabilityResolutionDefinition(vulnerabilityResolutionDefinitionService)
3739
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities
21+
22+
import io.github.smiley4.ktoropenapi.patch
23+
24+
import io.ktor.http.HttpStatusCode
25+
import io.ktor.server.auth.principal
26+
import io.ktor.server.request.receive
27+
import io.ktor.server.response.respond
28+
import io.ktor.server.routing.Route
29+
30+
import kotlinx.datetime.Instant
31+
32+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException
33+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal
34+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName
35+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId
36+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername
37+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission
38+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission
39+
import org.eclipse.apoapsis.ortserver.components.resolutions.UpdateVulnerabilityResolutionDefinition
40+
import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService
41+
import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName
42+
import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi
43+
import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel
44+
import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent
45+
import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction
46+
import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName
47+
import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition
48+
import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason
49+
import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent
50+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody
51+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter
52+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError
53+
54+
internal fun Route.patchVulnerabilityResolutionDefinition(
55+
vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService
56+
) = patch("/resolutions/vulnerabilities/{id}", {
57+
operationId = "PatchVulnerabilityResolutionDefinition"
58+
summary = "Update a vulnerability resolution definition"
59+
tags = listOf("Resolutions")
60+
61+
request {
62+
pathParameter<Long>("id") {
63+
description = "The ID of the vulnerability resolution definition"
64+
}
65+
66+
jsonBody<UpdateVulnerabilityResolutionDefinition> {
67+
description = "Set the values that should be updated."
68+
example("Update Vulnerability Resolution Definition") {
69+
value = UpdateVulnerabilityResolutionDefinition(
70+
idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp").asPresent(),
71+
reason = VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY.asPresent(),
72+
comment = "Updated comment.".asPresent()
73+
)
74+
}
75+
}
76+
}
77+
78+
response {
79+
HttpStatusCode.OK to {
80+
description = "Success"
81+
82+
jsonBody<VulnerabilityResolutionDefinition> {
83+
example("Update Vulnerability Resolution Definition") {
84+
value = VulnerabilityResolutionDefinition(
85+
id = 1,
86+
idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"),
87+
reason = VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY,
88+
comment = "Updated comment.",
89+
archived = false,
90+
changes = listOf(
91+
ChangeEvent(
92+
user = UserDisplayName(username = "User"),
93+
occurredAt = Instant.parse("2024-01-01T00:00:00Z"),
94+
action = ChangeEventAction.CREATE
95+
),
96+
ChangeEvent(
97+
user = UserDisplayName(username = "User"),
98+
occurredAt = Instant.parse("2024-01-02T00:00:00Z"),
99+
action = ChangeEventAction.UPDATE
100+
)
101+
)
102+
)
103+
}
104+
}
105+
}
106+
107+
HttpStatusCode.BadRequest to {
108+
description = "The requested vulnerability resolution definition is archived."
109+
}
110+
}
111+
}) {
112+
val id = call.requireIdParameter("id")
113+
114+
val definition = vulnerabilityResolutionDefinitionService.getById(id)
115+
116+
if (definition == null) throw AuthorizationException()
117+
118+
requirePermission(RepositoryPermission.WRITE.roleName(definition.hierarchyId.value))
119+
120+
if (definition.archived) {
121+
call.respondError(HttpStatusCode.Conflict, "The requested vulnerability resolution definition is archived.")
122+
return@patch
123+
}
124+
125+
val updateResolution = call.receive<UpdateVulnerabilityResolutionDefinition>()
126+
127+
// Extract the user information from the principal.
128+
val userDisplayName = call.principal<OrtPrincipal>()?.let { principal ->
129+
ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName())
130+
}
131+
132+
if (userDisplayName == null) {
133+
call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.")
134+
return@patch
135+
}
136+
137+
val updatedDefinition = vulnerabilityResolutionDefinitionService.update(
138+
id,
139+
userDisplayName,
140+
updateResolution.idMatchers.mapToModel(),
141+
updateResolution.reason.mapToModel { it.mapToModel() },
142+
updateResolution.comment.mapToModel()
143+
).mapToApi()
144+
145+
call.respond(HttpStatusCode.OK, updatedDefinition)
146+
}

components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import org.eclipse.apoapsis.ortserver.model.ChangeEventAction
3232
import org.eclipse.apoapsis.ortserver.model.RepositoryId
3333
import org.eclipse.apoapsis.ortserver.model.UserDisplayName
3434
import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason
35+
import org.eclipse.apoapsis.ortserver.model.util.asPresent
3536
import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService
3637

3738
import org.jetbrains.exposed.sql.Database
@@ -212,6 +213,51 @@ class VulnerabilityResolutionDefinitionServiceTest : WordSpec({
212213
}
213214
}
214215

216+
"update" should {
217+
"update a vulnerability resolution definition and add an event in the change log" {
218+
val userDisplayName = UserDisplayName(
219+
"abc",
220+
"Test",
221+
"Test User"
222+
)
223+
224+
val definitionId = definitionService.create(
225+
RepositoryId(repositoryId),
226+
runId,
227+
userDisplayName,
228+
listOf("CVE-2020-15250"),
229+
VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY,
230+
"Comment."
231+
).id
232+
233+
val updatedDefinition = definitionService.update(
234+
definitionId,
235+
userDisplayName,
236+
listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp").asPresent(),
237+
VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.asPresent(),
238+
"Updated comment.".asPresent()
239+
)
240+
241+
with(updatedDefinition) {
242+
idMatchers shouldBe listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp")
243+
reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY
244+
comment shouldBe "Updated comment."
245+
archived shouldBe false
246+
changes shouldHaveSize 2
247+
248+
with(changes.first()) {
249+
user shouldBe userDisplayName
250+
action shouldBe ChangeEventAction.CREATE
251+
}
252+
253+
with(changes.last()) {
254+
user shouldBe userDisplayName
255+
action shouldBe ChangeEventAction.UPDATE
256+
}
257+
}
258+
}
259+
}
260+
215261
"getById" should {
216262
"return the vulnerability resolution definition if it exists" {
217263
val userDisplayName = UserDisplayName(

components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
package org.eclipse.apoapsis.ortserver.components.resolutions.routes
2121

2222
import io.ktor.client.request.delete
23+
import io.ktor.client.request.patch
2324
import io.ktor.client.request.post
2425
import io.ktor.client.request.setBody
2526
import io.ktor.http.HttpStatusCode
@@ -28,6 +29,7 @@ import io.mockk.mockk
2829

2930
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission
3031
import org.eclipse.apoapsis.ortserver.components.resolutions.CreateVulnerabilityResolutionDefinition
32+
import org.eclipse.apoapsis.ortserver.components.resolutions.UpdateVulnerabilityResolutionDefinition
3133
import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService
3234
import org.eclipse.apoapsis.ortserver.components.resolutions.resolutionsRoutes
3335
import org.eclipse.apoapsis.ortserver.dao.test.Fixtures
@@ -36,6 +38,7 @@ import org.eclipse.apoapsis.ortserver.model.UserDisplayName
3638
import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService
3739
import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel
3840
import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason
41+
import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent
3942
import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractAuthorizationTest
4043

4144
import org.jetbrains.exposed.sql.Database
@@ -181,4 +184,45 @@ class ResolutionsAuthorizationTest : AbstractAuthorizationTest({
181184
}
182185
}
183186
}
187+
188+
"PatchVulnerabilityResolutionDefinition" should {
189+
"require role RepositoryPermission.WRITE.roleName(repositoryId)" {
190+
val definitionId = definitionService.create(
191+
RepositoryId(repositoryId),
192+
runId,
193+
UserDisplayName("abc", "Test"),
194+
createBody.idMatchers,
195+
createBody.reason.mapToModel(),
196+
createBody.comment
197+
).id
198+
199+
requestShouldRequireRole(
200+
routes = { resolutionsRoutes(ortRunService, definitionService) },
201+
role = RepositoryPermission.WRITE.roleName(repositoryId)
202+
) {
203+
patch("/resolutions/vulnerabilities/$definitionId") {
204+
setBody(
205+
UpdateVulnerabilityResolutionDefinition(
206+
comment = "Updated comment.".asPresent()
207+
)
208+
)
209+
}
210+
}
211+
}
212+
213+
"respond with 'Forbidden' when repository ID cannot be resolved" {
214+
requestShouldRequireAuthentication(
215+
routes = { resolutionsRoutes(ortRunService, definitionService) },
216+
successStatus = HttpStatusCode.Forbidden
217+
) {
218+
patch("/resolutions/vulnerabilities/9999") {
219+
setBody(
220+
UpdateVulnerabilityResolutionDefinition(
221+
comment = "Updated comment.".asPresent()
222+
)
223+
)
224+
}
225+
}
226+
}
227+
}
184228
})

0 commit comments

Comments
 (0)