Skip to content

Commit 64347f6

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

File tree

6 files changed

+339
-0
lines changed

6 files changed

+339
-0
lines changed

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,30 @@ class VulnerabilityResolutionDefinitionService(private val db: Database, private
9595
VulnerabilityResolutionDefinitionsTable.get(id)
9696
}
9797

98+
suspend fun restore(id: Long, userDisplayName: UserDisplayName): VulnerabilityResolutionDefinition = db.dbQuery {
99+
val user =
100+
UserDisplayNameDao.insertOrUpdate(userDisplayName) ?: throw NullPointerException("No user created or found")
101+
102+
VulnerabilityResolutionDefinitionsTable.updateDefinition(
103+
id,
104+
archivedInput = false.asPresent()
105+
)
106+
107+
ChangeLogTable.insert(
108+
ChangeEventEntityType.VULNERABILITY_RESOLUTION_DEFINITION,
109+
id.toString(),
110+
user.id.value,
111+
ChangeEventAction.RESTORE
112+
)
113+
114+
ortRunService.markAsOutdated(
115+
getAffectedRuns(id),
116+
"Vulnerability resolution definition restored."
117+
)
118+
119+
VulnerabilityResolutionDefinitionsTable.get(id)
120+
}
121+
98122
suspend fun getById(id: Long): VulnerabilityResolutionDefinition? = db.dbQuery {
99123
VulnerabilityResolutionDefinitionsTable
100124
.getOrNull(id)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import io.ktor.server.routing.Route
2323

2424
import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.deleteVulnerabilityResolutionDefinition
2525
import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.postVulnerabilityResolutionDefinition
26+
import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.restoreVulnerabilityResolutionDefinition
2627
import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService
2728

2829
/** Add all resolutions routes. */
@@ -32,4 +33,5 @@ fun Route.resolutionsRoutes(
3233
) {
3334
postVulnerabilityResolutionDefinition(ortRunService, vulnerabilityResolutionDefinitionService)
3435
deleteVulnerabilityResolutionDefinition(vulnerabilityResolutionDefinitionService)
36+
restoreVulnerabilityResolutionDefinition(vulnerabilityResolutionDefinitionService)
3537
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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.post
23+
24+
import io.ktor.http.HttpStatusCode
25+
import io.ktor.server.auth.principal
26+
import io.ktor.server.response.respond
27+
import io.ktor.server.routing.Route
28+
29+
import kotlinx.datetime.Instant
30+
31+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException
32+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal
33+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName
34+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId
35+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername
36+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission
37+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission
38+
import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService
39+
import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName
40+
import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi
41+
import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent
42+
import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction
43+
import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName
44+
import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition
45+
import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason
46+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody
47+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter
48+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError
49+
50+
internal fun Route.restoreVulnerabilityResolutionDefinition(
51+
vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService
52+
) = post("/resolutions/vulnerabilities/{id}/restore", {
53+
operationId = "RestoreVulnerabilityResolutionDefinition"
54+
summary = "Restore a vulnerability resolution definition"
55+
tags = listOf("Resolutions")
56+
57+
request {
58+
pathParameter<Long>("id") {
59+
description = "The ID of the vulnerability resolution definition."
60+
}
61+
}
62+
63+
response {
64+
HttpStatusCode.OK to {
65+
description = "Success"
66+
jsonBody<VulnerabilityResolutionDefinition> {
67+
example("Restore Vulnerability Resolution Definition") {
68+
value = VulnerabilityResolutionDefinition(
69+
id = 1,
70+
idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"),
71+
reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY,
72+
comment = "Comment",
73+
archived = false,
74+
changes = listOf(
75+
ChangeEvent(
76+
user = UserDisplayName(username = "User"),
77+
occurredAt = Instant.parse("2024-01-01T00:00:00Z"),
78+
ChangeEventAction.CREATE
79+
),
80+
ChangeEvent(
81+
user = UserDisplayName(username = "User"),
82+
occurredAt = Instant.parse("2024-01-03T00:00:00Z"),
83+
ChangeEventAction.ARCHIVE
84+
),
85+
ChangeEvent(
86+
user = UserDisplayName(username = "User"),
87+
occurredAt = Instant.parse("2024-01-04T00:00:00Z"),
88+
ChangeEventAction.RESTORE
89+
)
90+
)
91+
)
92+
}
93+
}
94+
}
95+
96+
HttpStatusCode.Conflict to {
97+
description = "The vulnerability resolution definition is not archived."
98+
}
99+
}
100+
}) {
101+
val id = call.requireIdParameter("id")
102+
103+
val definition = vulnerabilityResolutionDefinitionService.getById(id)
104+
105+
if (definition == null) throw AuthorizationException()
106+
107+
requirePermission(RepositoryPermission.WRITE.roleName(definition.hierarchyId.value))
108+
109+
if (!definition.archived) {
110+
call.respond(HttpStatusCode.NoContent)
111+
return@post
112+
}
113+
114+
// Extract the user information from the principal.
115+
val userDisplayName = call.principal<OrtPrincipal>()?.let { principal ->
116+
ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName())
117+
}
118+
119+
if (userDisplayName == null) {
120+
call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.")
121+
return@post
122+
}
123+
124+
val vulnerabilityResolutionDefinition =
125+
vulnerabilityResolutionDefinitionService.restore(id, userDisplayName).mapToApi()
126+
127+
call.respond(HttpStatusCode.OK, vulnerabilityResolutionDefinition)
128+
}

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,49 @@ class VulnerabilityResolutionDefinitionServiceTest : WordSpec({
169169
}
170170
}
171171

172+
"restore" should {
173+
"restore an archived vulnerability resolution definition" {
174+
val userDisplayName = UserDisplayName(
175+
"abc",
176+
"Test",
177+
"Test User"
178+
)
179+
180+
val definitionId = definitionService.create(
181+
RepositoryId(repositoryId),
182+
runId,
183+
userDisplayName,
184+
listOf("CVE-2020-15250"),
185+
VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY,
186+
"Comment."
187+
).id
188+
189+
definitionService.archive(definitionId, userDisplayName)
190+
191+
val restoredDefinition = definitionService.restore(definitionId, userDisplayName)
192+
193+
with(restoredDefinition) {
194+
archived shouldBe false
195+
changes shouldHaveSize 3
196+
197+
with(changes[0]) {
198+
user shouldBe userDisplayName
199+
action shouldBe ChangeEventAction.CREATE
200+
}
201+
202+
with(changes[1]) {
203+
user shouldBe userDisplayName
204+
action shouldBe ChangeEventAction.ARCHIVE
205+
}
206+
207+
with(changes[2]) {
208+
user shouldBe userDisplayName
209+
action shouldBe ChangeEventAction.RESTORE
210+
}
211+
}
212+
}
213+
}
214+
172215
"getById" should {
173216
"return the vulnerability resolution definition if it exists" {
174217
val userDisplayName = UserDisplayName(

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,37 @@ class ResolutionsAuthorizationTest : AbstractAuthorizationTest({
148148
}
149149
}
150150
}
151+
152+
"RestoreVulnerabilityResolutionDefinition" should {
153+
"require role RepositoryPermission.WRITE.roleName(repositoryId)" {
154+
val userDisplayName = UserDisplayName("abc", "Test")
155+
156+
val definitionId = definitionService.create(
157+
RepositoryId(repositoryId),
158+
runId,
159+
userDisplayName,
160+
createBody.idMatchers,
161+
createBody.reason.mapToModel(),
162+
createBody.comment
163+
).id
164+
165+
definitionService.archive(definitionId, userDisplayName)
166+
167+
requestShouldRequireRole(
168+
routes = { resolutionsRoutes(ortRunService, definitionService) },
169+
role = RepositoryPermission.WRITE.roleName(repositoryId)
170+
) {
171+
post("/resolutions/vulnerabilities/$definitionId/restore")
172+
}
173+
}
174+
175+
"respond with 'Forbidden' when repository ID cannot be resolved" {
176+
requestShouldRequireAuthentication(
177+
routes = { resolutionsRoutes(ortRunService, definitionService) },
178+
successStatus = HttpStatusCode.Forbidden
179+
) {
180+
post("/resolutions/vulnerabilities/9999/restore")
181+
}
182+
}
183+
}
151184
})
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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.kotest.assertions.ktor.client.shouldHaveStatus
23+
import io.kotest.matchers.collections.shouldHaveSize
24+
import io.kotest.matchers.shouldBe
25+
26+
import io.ktor.client.call.body
27+
import io.ktor.client.request.delete
28+
import io.ktor.client.request.post
29+
import io.ktor.client.request.setBody
30+
import io.ktor.http.HttpStatusCode
31+
32+
import org.eclipse.apoapsis.ortserver.components.resolutions.CreateVulnerabilityResolutionDefinition
33+
import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest
34+
import org.eclipse.apoapsis.ortserver.dao.test.Fixtures
35+
import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction
36+
import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName
37+
import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition
38+
import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason
39+
40+
class RestoreVulnerabilityResolutionDefinition : ResolutionsIntegrationTest({
41+
var runId = 0L
42+
43+
lateinit var fixtures: Fixtures
44+
45+
beforeEach {
46+
fixtures = dbExtension.fixtures
47+
runId = fixtures.ortRun.id
48+
}
49+
50+
"RestoreVulnerabilityResolutionDefinition" should {
51+
"restore the definition" {
52+
resolutionsTestApplication { client ->
53+
val createResponse = client.post("/resolutions/vulnerabilities") {
54+
setBody(
55+
CreateVulnerabilityResolutionDefinition(
56+
runId,
57+
listOf("CVE-2020-15250"),
58+
VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY,
59+
"Comment."
60+
)
61+
)
62+
}
63+
64+
val definitionId = (createResponse.body<VulnerabilityResolutionDefinition>()).id
65+
66+
client.delete("/resolutions/vulnerabilities/$definitionId")
67+
68+
val restoreResponse = client.post("/resolutions/vulnerabilities/$definitionId/restore")
69+
70+
restoreResponse shouldHaveStatus HttpStatusCode.OK
71+
72+
with(restoreResponse.body<VulnerabilityResolutionDefinition>()) {
73+
idMatchers shouldBe listOf("CVE-2020-15250")
74+
reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY
75+
comment shouldBe "Comment."
76+
archived shouldBe false
77+
changes shouldHaveSize 3
78+
changes[0].user shouldBe UserDisplayName("test", "Test User")
79+
changes[0].action shouldBe ChangeEventAction.CREATE
80+
changes[1].user shouldBe UserDisplayName("test", "Test User")
81+
changes[1].action shouldBe ChangeEventAction.ARCHIVE
82+
changes[2].user shouldBe UserDisplayName("test", "Test User")
83+
changes[2].action shouldBe ChangeEventAction.RESTORE
84+
}
85+
}
86+
}
87+
88+
"respond with 'No content' if the definition is not archived" {
89+
resolutionsTestApplication { client ->
90+
val createResponse = client.post("/resolutions/vulnerabilities") {
91+
setBody(
92+
CreateVulnerabilityResolutionDefinition(
93+
runId,
94+
listOf("CVE-2020-15250"),
95+
VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY,
96+
"Comment."
97+
)
98+
)
99+
}
100+
101+
val definitionId = (createResponse.body<VulnerabilityResolutionDefinition>()).id
102+
103+
val restoreResponse = client.post("/resolutions/vulnerabilities/$definitionId/restore")
104+
105+
restoreResponse shouldHaveStatus HttpStatusCode.NoContent
106+
}
107+
}
108+
}
109+
})

0 commit comments

Comments
 (0)