diff --git a/components/secrets/backend/build.gradle.kts b/components/secrets/backend/build.gradle.kts index 2ac8940bd9..a6e57a37f3 100644 --- a/components/secrets/backend/build.gradle.kts +++ b/components/secrets/backend/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(projects.components.authorization.backend) implementation(projects.model) + implementation(projects.services.hierarchyService) implementation(projects.services.secretService) implementation(projects.shared.apiMappings) implementation(projects.shared.apiModel) @@ -46,4 +47,5 @@ dependencies { testImplementation(ktorLibs.server.testHost) testImplementation(libs.kotestAssertionsCore) testImplementation(libs.kotestAssertionsKtor) + testImplementation(libs.mockk) } diff --git a/components/secrets/backend/src/main/kotlin/Routing.kt b/components/secrets/backend/src/main/kotlin/Routing.kt index 4239a0b8fb..fe94d191e3 100644 --- a/components/secrets/backend/src/main/kotlin/Routing.kt +++ b/components/secrets/backend/src/main/kotlin/Routing.kt @@ -32,13 +32,15 @@ import org.eclipse.apoapsis.ortserver.components.secrets.routes.product.getSecre import org.eclipse.apoapsis.ortserver.components.secrets.routes.product.patchSecretByProductIdAndName import org.eclipse.apoapsis.ortserver.components.secrets.routes.product.postSecretForProduct import org.eclipse.apoapsis.ortserver.components.secrets.routes.repository.deleteSecretByRepositoryIdAndName +import org.eclipse.apoapsis.ortserver.components.secrets.routes.repository.getAvailableSecretsByRepositoryId import org.eclipse.apoapsis.ortserver.components.secrets.routes.repository.getSecretByRepositoryIdAndName import org.eclipse.apoapsis.ortserver.components.secrets.routes.repository.getSecretsByRepositoryId import org.eclipse.apoapsis.ortserver.components.secrets.routes.repository.patchSecretByRepositoryIdAndName import org.eclipse.apoapsis.ortserver.components.secrets.routes.repository.postSecretForRepository +import org.eclipse.apoapsis.ortserver.services.RepositoryService import org.eclipse.apoapsis.ortserver.services.SecretService -fun Route.secretsRoutes(secretService: SecretService) { +fun Route.secretsRoutes(repositoryService: RepositoryService, secretService: SecretService) { // Organization secrets deleteSecretByOrganizationIdAndName(secretService) getSecretByOrganizationIdAndName(secretService) @@ -55,6 +57,7 @@ fun Route.secretsRoutes(secretService: SecretService) { // Repository secrets deleteSecretByRepositoryIdAndName(secretService) + getAvailableSecretsByRepositoryId(repositoryService, secretService) getSecretByRepositoryIdAndName(secretService) getSecretsByRepositoryId(secretService) patchSecretByRepositoryIdAndName(secretService) diff --git a/components/secrets/backend/src/main/kotlin/routes/repository/GetAvailableSecretsByRepositoryId.kt b/components/secrets/backend/src/main/kotlin/routes/repository/GetAvailableSecretsByRepositoryId.kt new file mode 100644 index 0000000000..1e387be1fd --- /dev/null +++ b/components/secrets/backend/src/main/kotlin/routes/repository/GetAvailableSecretsByRepositoryId.kt @@ -0,0 +1,79 @@ +/* + * 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.secrets.routes.repository + +import io.github.smiley4.ktoropenapi.get + +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import org.eclipse.apoapsis.ortserver.components.authorization.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.requirePermission +import org.eclipse.apoapsis.ortserver.components.secrets.Secret +import org.eclipse.apoapsis.ortserver.components.secrets.mapToApi +import org.eclipse.apoapsis.ortserver.services.RepositoryService +import org.eclipse.apoapsis.ortserver.services.SecretService +import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedResponse +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter +import org.eclipse.apoapsis.ortserver.shared.ktorutils.standardListQueryParameters + +internal fun Route.getAvailableSecretsByRepositoryId( + repositoryService: RepositoryService, + secretService: SecretService +) = get("/repositories/{repositoryId}/availableSecrets", { + operationId = "GetAvailableSecretsByRepositoryId" + summary = "Get all available secrets for a repository" + description = "Get all secrets that are available in the context of the provided repository. In addition to " + + "the repository secrets, this includes secrets from the product and organization the repository " + + "belongs to." + tags = listOf("Repositories") + + request { + pathParameter("repositoryId") { + description = "The ID of the repository." + } + standardListQueryParameters() + } + + response { + HttpStatusCode.OK to { + description = "Success" + jsonBody> { + example("Get all available secrets for a repository") { + value = listOf( + Secret(name = "USERNAME-SECRET", description = "The username."), + Secret(name = "PASSWORD-SECRET", description = "The password.") + ) + } + } + } + } +}) { + requirePermission(RepositoryPermission.READ) + + val repositoryId = call.requireIdParameter("repositoryId") + + val hierarchy = repositoryService.getHierarchy(repositoryId) + val secrets = secretService.listForHierarchy(hierarchy) + + call.respond(HttpStatusCode.OK, secrets.map { it.mapToApi() }) +} diff --git a/components/secrets/backend/src/test/kotlin/SecretsIntegrationTest.kt b/components/secrets/backend/src/test/kotlin/SecretsIntegrationTest.kt index 1964e0409b..6af1046760 100644 --- a/components/secrets/backend/src/test/kotlin/SecretsIntegrationTest.kt +++ b/components/secrets/backend/src/test/kotlin/SecretsIntegrationTest.kt @@ -22,15 +22,19 @@ package org.eclipse.apoapsis.ortserver.components.secrets import io.ktor.client.HttpClient import io.ktor.server.testing.ApplicationTestBuilder +import io.mockk.mockk + import org.eclipse.apoapsis.ortserver.model.repositories.SecretRepository import org.eclipse.apoapsis.ortserver.secrets.SecretStorage import org.eclipse.apoapsis.ortserver.secrets.SecretsProviderFactoryForTesting +import org.eclipse.apoapsis.ortserver.services.RepositoryService import org.eclipse.apoapsis.ortserver.services.SecretService import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest /** An [AbstractIntegrationTest] pre-configured for testing the secrets routes. */ @Suppress("UnnecessaryAbstractClass") abstract class SecretsIntegrationTest(body: SecretsIntegrationTest.() -> Unit) : AbstractIntegrationTest({}) { + lateinit var repositoryService: RepositoryService lateinit var secretRepository: SecretRepository lateinit var secretService: SecretService @@ -38,6 +42,18 @@ abstract class SecretsIntegrationTest(body: SecretsIntegrationTest.() -> Unit) : init { beforeEach { + repositoryService = RepositoryService( + dbExtension.db, + dbExtension.fixtures.ortRunRepository, + dbExtension.fixtures.repositoryRepository, + dbExtension.fixtures.analyzerJobRepository, + dbExtension.fixtures.advisorJobRepository, + dbExtension.fixtures.scannerJobRepository, + dbExtension.fixtures.evaluatorJobRepository, + dbExtension.fixtures.reporterJobRepository, + dbExtension.fixtures.notifierJobRepository, + mockk() + ) secretRepository = dbExtension.fixtures.secretRepository secretService = SecretService( dbExtension.db, @@ -53,7 +69,7 @@ abstract class SecretsIntegrationTest(body: SecretsIntegrationTest.() -> Unit) : fun secretsTestApplication( block: suspend ApplicationTestBuilder.(client: HttpClient) -> Unit ) = integrationTestApplication( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, validations = { secretsValidations() }, block = block ) diff --git a/components/secrets/backend/src/test/kotlin/routes/SecretsAuthorizationTest.kt b/components/secrets/backend/src/test/kotlin/routes/SecretsAuthorizationTest.kt index d5d40e3e3a..8854823bbd 100644 --- a/components/secrets/backend/src/test/kotlin/routes/SecretsAuthorizationTest.kt +++ b/components/secrets/backend/src/test/kotlin/routes/SecretsAuthorizationTest.kt @@ -34,6 +34,7 @@ import org.eclipse.apoapsis.ortserver.components.secrets.UpdateSecret import org.eclipse.apoapsis.ortserver.components.secrets.secretsRoutes import org.eclipse.apoapsis.ortserver.secrets.SecretStorage import org.eclipse.apoapsis.ortserver.secrets.SecretsProviderFactoryForTesting +import org.eclipse.apoapsis.ortserver.services.RepositoryService import org.eclipse.apoapsis.ortserver.services.SecretService import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractAuthorizationTest @@ -42,6 +43,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ var orgId = 0L var prodId = 0L var repoId = 0L + lateinit var repositoryService: RepositoryService lateinit var secretService: SecretService beforeEach { @@ -51,6 +53,19 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ authorizationService.ensureSuperuserAndSynchronizeRolesAndPermissions() + repositoryService = RepositoryService( + dbExtension.db, + dbExtension.fixtures.ortRunRepository, + dbExtension.fixtures.repositoryRepository, + dbExtension.fixtures.analyzerJobRepository, + dbExtension.fixtures.advisorJobRepository, + dbExtension.fixtures.scannerJobRepository, + dbExtension.fixtures.evaluatorJobRepository, + dbExtension.fixtures.reporterJobRepository, + dbExtension.fixtures.notifierJobRepository, + authorizationService + ) + secretService = SecretService( dbExtension.db, dbExtension.fixtures.secretRepository, @@ -62,7 +77,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "DeleteSecretByOrganizationIdAndName" should { "require OrganizationPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = OrganizationPermission.WRITE_SECRETS.roleName(orgId), successStatus = HttpStatusCode.NotFound ) { @@ -74,7 +89,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "DeleteSecretByProductIdAndName" should { "require ProductPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = ProductPermission.WRITE_SECRETS.roleName(prodId), successStatus = HttpStatusCode.NotFound ) { @@ -86,7 +101,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "DeleteSecretByRepositoryIdAndName" should { "require RepositoryPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = RepositoryPermission.WRITE_SECRETS.roleName(repoId), successStatus = HttpStatusCode.NotFound ) { @@ -95,10 +110,21 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ } } + "GetAvailableSecretsByRepositoryId" should { + "require RepositoryPermission.READ" { + requestShouldRequireRole( + routes = { secretsRoutes(repositoryService, secretService) }, + role = RepositoryPermission.READ.roleName(repoId) + ) { + get("/repositories/$repoId/secrets/availableSecrets") + } + } + } + "GetSecretByOrganizationIdAndName" should { "require OrganizationPermission.READ" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = OrganizationPermission.READ.roleName(orgId), successStatus = HttpStatusCode.NotFound ) { @@ -110,7 +136,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "GetSecretByProductIdAndName" should { "require ProductPermission.READ" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = ProductPermission.READ.roleName(prodId), successStatus = HttpStatusCode.NotFound ) { @@ -122,7 +148,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "GetSecretByRepositoryIdAndName" should { "require RepositoryPermission.READ" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = RepositoryPermission.READ.roleName(repoId), successStatus = HttpStatusCode.NotFound ) { @@ -134,7 +160,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "GetSecretsByOrganizationId" should { "require OrganizationPermission.READ" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = OrganizationPermission.READ.roleName(orgId) ) { get("/organizations/$orgId/secrets") @@ -145,7 +171,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "GetSecretsByProductId" should { "require ProductPermission.READ" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = ProductPermission.READ.roleName(prodId) ) { get("/products/$prodId/secrets") @@ -156,7 +182,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "GetSecretsByRepositoryId" should { "require RepositoryPermission.READ" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = RepositoryPermission.READ.roleName(repoId) ) { get("/repositories/$repoId/secrets") @@ -167,7 +193,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "PatchSecretByOrganizationIdAndName" should { "require OrganizationPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = OrganizationPermission.WRITE_SECRETS.roleName(orgId), successStatus = HttpStatusCode.NotFound ) { @@ -180,7 +206,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "PatchSecretByProductIdAndName" should { "require ProductPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = ProductPermission.WRITE_SECRETS.roleName(prodId), successStatus = HttpStatusCode.NotFound ) { @@ -193,7 +219,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "PatchSecretByRepositoryIdAndName" should { "require RepositoryPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = RepositoryPermission.WRITE_SECRETS.roleName(repoId), successStatus = HttpStatusCode.NotFound ) { @@ -206,7 +232,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "PostSecretForOrganization" should { "require OrganizationPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = OrganizationPermission.WRITE_SECRETS.roleName(orgId), successStatus = HttpStatusCode.Created ) { @@ -219,7 +245,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "PostSecretForProduct" should { "require ProductPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = ProductPermission.WRITE_SECRETS.roleName(prodId), successStatus = HttpStatusCode.Created ) { @@ -232,7 +258,7 @@ class SecretsAuthorizationTest : AbstractAuthorizationTest({ "PostSecretForRepository" should { "require RepositoryPermission.WRITE_SECRETS" { requestShouldRequireRole( - routes = { secretsRoutes(secretService) }, + routes = { secretsRoutes(repositoryService, secretService) }, role = RepositoryPermission.WRITE_SECRETS.roleName(repoId), successStatus = HttpStatusCode.Created ) { diff --git a/components/secrets/backend/src/test/kotlin/routes/repository/GetAvailableSecretsByRepositoryIdIntegrationTest.kt b/components/secrets/backend/src/test/kotlin/routes/repository/GetAvailableSecretsByRepositoryIdIntegrationTest.kt new file mode 100644 index 0000000000..20213eea7f --- /dev/null +++ b/components/secrets/backend/src/test/kotlin/routes/repository/GetAvailableSecretsByRepositoryIdIntegrationTest.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.components.secrets.routes.repository + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.containExactlyInAnyOrder +import io.kotest.matchers.should + +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.secrets.Secret +import org.eclipse.apoapsis.ortserver.components.secrets.SecretsIntegrationTest +import org.eclipse.apoapsis.ortserver.components.secrets.mapToApi +import org.eclipse.apoapsis.ortserver.components.secrets.routes.createOrganizationSecret +import org.eclipse.apoapsis.ortserver.components.secrets.routes.createProductSecret +import org.eclipse.apoapsis.ortserver.components.secrets.routes.createRepositorySecret + +class GetAvailableSecretsByRepositoryIdIntegrationTest : SecretsIntegrationTest({ + var organizationId = 0L + var productId = 0L + var repoId = 0L + + beforeEach { + organizationId = dbExtension.fixtures.organization.id + productId = dbExtension.fixtures.product.id + repoId = dbExtension.fixtures.repository.id + } + + "GetAvailableSecretsByRepositoryId" should { + "return all secrets from the hierarchy" { + secretsTestApplication { client -> + val secret1 = + secretRepository.createOrganizationSecret(organizationId, "path1", "name1", "description1") + val secret2 = secretRepository.createProductSecret(productId, "path2", "name2", "description2") + val secret3 = secretRepository.createRepositorySecret(repoId, "path3", "name3", "description3") + + val response = client.get("/repositories/$repoId/availableSecrets") + + response shouldHaveStatus HttpStatusCode.OK + response.body>() should containExactlyInAnyOrder( + secret1.mapToApi(), + secret2.mapToApi(), + secret3.mapToApi() + ) + } + } + } +}) diff --git a/core/src/main/kotlin/di/Module.kt b/core/src/main/kotlin/di/Module.kt index 0686f3fa8f..f6a8547a97 100644 --- a/core/src/main/kotlin/di/Module.kt +++ b/core/src/main/kotlin/di/Module.kt @@ -113,9 +113,9 @@ import org.ossreviewtoolkit.scanner.utils.FileListResolver /** * Creates the Koin module for the ORT server. The [config] is used to configure the application and the database. For - * integration tests, the [database][db] from the testcontainer can be provided directly. + * integration tests, the [database][db] from the testcontainer and an [authorizationService] can be provided directly. */ -fun ortServerModule(config: ApplicationConfig, db: Database?) = module { +fun ortServerModule(config: ApplicationConfig, db: Database?, authorizationService: AuthorizationService?) = module { single { config } single { ConfigFactory.parseMap(config.toMap()) } singleOf(ConfigManager::create) @@ -194,9 +194,13 @@ fun ortServerModule(config: ApplicationConfig, db: Database?) = module { singleOf(::SecretService) singleOf(::VulnerabilityService) - single { - val keycloakGroupPrefix = get().tryGetString("keycloak.groupPrefix").orEmpty() - DefaultAuthorizationService(get(), get(), get(), get(), get(), keycloakGroupPrefix) + if (authorizationService != null) { + single { authorizationService } + } else { + single { + val keycloakGroupPrefix = get().tryGetString("keycloak.groupPrefix").orEmpty() + DefaultAuthorizationService(get(), get(), get(), get(), get(), keycloakGroupPrefix) + } } single { diff --git a/core/src/main/kotlin/plugins/Koin.kt b/core/src/main/kotlin/plugins/Koin.kt index 0255af91f5..2510bb753f 100644 --- a/core/src/main/kotlin/plugins/Koin.kt +++ b/core/src/main/kotlin/plugins/Koin.kt @@ -23,15 +23,16 @@ import io.ktor.server.application.Application import io.ktor.server.application.install import org.eclipse.apoapsis.ortserver.core.di.ortServerModule +import org.eclipse.apoapsis.ortserver.services.AuthorizationService import org.jetbrains.exposed.sql.Database import org.koin.ktor.plugin.Koin -fun Application.configureKoin(db: Database? = null) { +fun Application.configureKoin(db: Database? = null, authorizationService: AuthorizationService? = null) { install(Koin) { modules( - ortServerModule(environment.config, db) + ortServerModule(environment.config, db, authorizationService) ) } } diff --git a/core/src/main/kotlin/plugins/Routing.kt b/core/src/main/kotlin/plugins/Routing.kt index e168ef758d..4d53bd91fd 100644 --- a/core/src/main/kotlin/plugins/Routing.kt +++ b/core/src/main/kotlin/plugins/Routing.kt @@ -54,7 +54,7 @@ fun Application.configureRouting() { products() repositories() runs() - secretsRoutes(get()) + secretsRoutes(get(), get()) versions() } } diff --git a/core/src/test/kotlin/ApplicationTest.kt b/core/src/test/kotlin/ApplicationTest.kt index 0b80e7e18e..801c7278c2 100644 --- a/core/src/test/kotlin/ApplicationTest.kt +++ b/core/src/test/kotlin/ApplicationTest.kt @@ -21,6 +21,8 @@ package org.eclipse.apoapsis.ortserver.core import io.ktor.server.application.Application +import io.mockk.mockk + import org.eclipse.apoapsis.ortserver.components.authorization.configureAuthentication import org.eclipse.apoapsis.ortserver.core.plugins.* import org.eclipse.apoapsis.ortserver.core.testutils.configureTestAuthentication @@ -41,7 +43,7 @@ fun main(args: Array) = io.ktor.server.netty.EngineMain.main(args) * authentication which is always valid. */ fun Application.testModule(db: Database) { - configureKoin(db) + configureKoin(db, authorizationService = mockk()) configureTestAuthentication() configureStatusPages() configureRouting() diff --git a/services/hierarchy/src/main/kotlin/RepositoryService.kt b/services/hierarchy/src/main/kotlin/RepositoryService.kt index 96cc713885..bf128ca30d 100644 --- a/services/hierarchy/src/main/kotlin/RepositoryService.kt +++ b/services/hierarchy/src/main/kotlin/RepositoryService.kt @@ -26,6 +26,7 @@ import org.eclipse.apoapsis.ortserver.dao.repositories.advisorjob.AdvisorJobsTab import org.eclipse.apoapsis.ortserver.dao.repositories.analyzerjob.AnalyzerJobsTable import org.eclipse.apoapsis.ortserver.dao.repositories.evaluatorjob.EvaluatorJobsTable import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunsTable +import org.eclipse.apoapsis.ortserver.model.Hierarchy import org.eclipse.apoapsis.ortserver.model.JobStatus import org.eclipse.apoapsis.ortserver.model.Jobs import org.eclipse.apoapsis.ortserver.model.OrtRun @@ -100,6 +101,11 @@ class RepositoryService( Jobs(analyzerJob, advisorJob, scannerJob, evaluatorJob, reporterJob, notifierJob) } + /** Get the [Hierarchy] for the provided [repository][repositoryId]. */ + suspend fun getHierarchy(repositoryId: Long): Hierarchy = db.dbQuery { + repositoryRepository.getHierarchy(repositoryId) + } + suspend fun getOrtRun(repositoryId: Long, ortRunIndex: Long): OrtRun? = db.dbQuery { ortRunRepository.getByIndex(repositoryId, ortRunIndex) } diff --git a/services/hierarchy/src/test/kotlin/RepositoryServiceTest.kt b/services/hierarchy/src/test/kotlin/RepositoryServiceTest.kt index 6a2284862d..47a60c1745 100644 --- a/services/hierarchy/src/test/kotlin/RepositoryServiceTest.kt +++ b/services/hierarchy/src/test/kotlin/RepositoryServiceTest.kt @@ -40,6 +40,7 @@ import org.eclipse.apoapsis.ortserver.model.Repository import org.eclipse.apoapsis.ortserver.model.RepositoryType import org.eclipse.apoapsis.ortserver.model.util.asPresent +import org.jetbrains.exposed.dao.exceptions.EntityNotFoundException import org.jetbrains.exposed.sql.Database class RepositoryServiceTest : WordSpec({ @@ -142,6 +143,26 @@ class RepositoryServiceTest : WordSpec({ } } + "getHierarchy" should { + "return the hierarchy of the repository" { + val service = createService() + + service.getHierarchy(fixtures.repository.id).shouldNotBeNull { + organization.id shouldBe fixtures.organization.id + product.id shouldBe fixtures.product.id + repository.id shouldBe fixtures.repository.id + } + } + + "throw an exception if the repository does not exist" { + val service = createService() + + shouldThrow { + service.getHierarchy(9999) + } + } + } + "addUserToGroup" should { "throw an exception if the organization does not exist" { val service = createService() diff --git a/services/secret/build.gradle.kts b/services/secret/build.gradle.kts index 24f34b9681..a96664619c 100644 --- a/services/secret/build.gradle.kts +++ b/services/secret/build.gradle.kts @@ -35,6 +35,9 @@ dependencies { runtimeOnly(libs.logback) + testImplementation(testFixtures(projects.dao)) + testImplementation(testFixtures(projects.secrets.secretsSpi)) + testImplementation(libs.kotestRunnerJunit5) testImplementation(libs.mockk) } diff --git a/services/secret/src/main/kotlin/SecretService.kt b/services/secret/src/main/kotlin/SecretService.kt index 22246a95f1..056dae52f1 100644 --- a/services/secret/src/main/kotlin/SecretService.kt +++ b/services/secret/src/main/kotlin/SecretService.kt @@ -20,7 +20,11 @@ package org.eclipse.apoapsis.ortserver.services import org.eclipse.apoapsis.ortserver.dao.dbQuery +import org.eclipse.apoapsis.ortserver.model.Hierarchy import org.eclipse.apoapsis.ortserver.model.HierarchyId +import org.eclipse.apoapsis.ortserver.model.OrganizationId +import org.eclipse.apoapsis.ortserver.model.ProductId +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.Secret import org.eclipse.apoapsis.ortserver.model.repositories.InfrastructureServiceRepository import org.eclipse.apoapsis.ortserver.model.repositories.SecretRepository @@ -84,6 +88,34 @@ class SecretService( secretRepository.getByIdAndName(id, name) } + /** + * List all secrets for the provided [hierarchy]. If there are secrets with the same name in different levels of the + * hierarchy, only the one closest to the repository is returned. + */ + suspend fun listForHierarchy( + hierarchy: Hierarchy + ): List = db.dbQuery { + val parameters = ListQueryParameters(limit = Integer.MAX_VALUE) + val organizationSecrets = secretRepository.listForId(OrganizationId(hierarchy.organization.id), parameters) + val productSecrets = secretRepository.listForId(ProductId(hierarchy.product.id), parameters) + val repositorySecrets = secretRepository.listForId(RepositoryId(hierarchy.repository.id), parameters) + + buildList { + addAll(repositorySecrets.data) + addAll( + productSecrets.data.filter { productSecret -> + repositorySecrets.data.none { it.name == productSecret.name } + } + ) + addAll( + organizationSecrets.data.filter { organizationSecret -> + repositorySecrets.data.none { it.name == organizationSecret.name } && + productSecrets.data.none { it.name == organizationSecret.name } + } + ) + } + } + /** * List all secrets for a specific [id] and according to the given [parameters]. */ diff --git a/services/secret/src/test/kotlin/SecretServiceTest.kt b/services/secret/src/test/kotlin/SecretServiceTest.kt new file mode 100644 index 0000000000..13a46a4688 --- /dev/null +++ b/services/secret/src/test/kotlin/SecretServiceTest.kt @@ -0,0 +1,196 @@ +/* + * 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.services + +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.collections.beEmpty +import io.kotest.matchers.collections.containExactlyInAnyOrder +import io.kotest.matchers.nulls.beNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.should +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.Hierarchy +import org.eclipse.apoapsis.ortserver.model.OrganizationId +import org.eclipse.apoapsis.ortserver.model.ProductId +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.secrets.SecretStorage +import org.eclipse.apoapsis.ortserver.secrets.SecretsProviderFactoryForTesting + +import org.jetbrains.exposed.sql.Database + +class SecretServiceTest : WordSpec({ + val dbExtension = extension(DatabaseTestExtension()) + + lateinit var db: Database + lateinit var fixtures: Fixtures + lateinit var secretService: SecretService + + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + secretService = SecretService( + db, + fixtures.secretRepository, + fixtures.infrastructureServiceRepository, + SecretStorage(SecretsProviderFactoryForTesting().createProvider()) + ) + } + + "listForHierarchy" should { + "return an empty list if there are no secrets" { + val hierarchy = Hierarchy( + repository = fixtures.repository, + product = fixtures.product, + organization = fixtures.organization + ) + + secretService.listForHierarchy(hierarchy) should beEmpty() + } + + "return secrets from all levels of the hierarchy" { + val hierarchy = Hierarchy( + repository = fixtures.repository, + product = fixtures.product, + organization = fixtures.organization + ) + + secretService.createSecret( + "organizationSecret", + "value", + "description", + OrganizationId(hierarchy.organization.id) + ) + secretService.createSecret( + "productSecret", + "value", + "description", + ProductId(fixtures.product.id) + ) + secretService.createSecret( + "repositorySecret", + "value", + "description", + RepositoryId(fixtures.repository.id) + ) + + secretService.listForHierarchy(hierarchy).map { it.name } should + containExactlyInAnyOrder("organizationSecret", "productSecret", "repositorySecret") + } + + "resolve conflicts for secrets with the same name correctly" { + val hierarchy = Hierarchy( + repository = fixtures.repository, + product = fixtures.product, + organization = fixtures.organization + ) + + // Create a secret on all levels. + secretService.createSecret( + "secret1", + "value", + "description", + OrganizationId(hierarchy.organization.id) + ) + secretService.createSecret( + "secret1", + "value", + "description", + ProductId(hierarchy.organization.id) + ) + secretService.createSecret( + "secret1", + "value", + "description", + RepositoryId(hierarchy.organization.id) + ) + + // Create a secret on organization and product levels. + secretService.createSecret( + "secret2", + "value", + "description", + OrganizationId(hierarchy.organization.id) + ) + secretService.createSecret( + "secret2", + "value", + "description", + ProductId(hierarchy.product.id) + ) + + // Create a secret on organization and repository levels. + secretService.createSecret( + "secret3", + "value", + "description", + OrganizationId(hierarchy.organization.id) + ) + secretService.createSecret( + "secret3", + "value", + "description", + RepositoryId(hierarchy.repository.id) + ) + + // Create a secret on product and repository levels. + secretService.createSecret( + "secret4", + "value", + "description", + ProductId(hierarchy.product.id) + ) + secretService.createSecret( + "secret4", + "value", + "description", + RepositoryId(hierarchy.repository.id) + ) + + val secrets = secretService.listForHierarchy(hierarchy) + + secrets.find { it.name == "secret1" }.shouldNotBeNull { + organization should beNull() + product should beNull() + repository shouldBe hierarchy.repository + } + + secrets.find { it.name == "secret2" }.shouldNotBeNull { + organization should beNull() + product shouldBe hierarchy.product + repository should beNull() + } + + secrets.find { it.name == "secret3" }.shouldNotBeNull { + organization should beNull() + product should beNull() + repository shouldBe hierarchy.repository + } + + secrets.find { it.name == "secret4" }.shouldNotBeNull { + organization should beNull() + product should beNull() + repository shouldBe hierarchy.repository + } + } + } +}) diff --git a/ui/src/components/form/plugin-multi-select-field.tsx b/ui/src/components/form/plugin-multi-select-field.tsx new file mode 100644 index 0000000000..ea16ebbf4c --- /dev/null +++ b/ui/src/components/form/plugin-multi-select-field.tsx @@ -0,0 +1,266 @@ +/* + * 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 + */ + +import { CheckedState } from '@radix-ui/react-checkbox'; +import React from 'react'; +import { + FieldPathByValue, + FieldPathValue, + FieldValues, + Path, + UseFormReturn, +} from 'react-hook-form'; + +import { PreconfiguredPluginDescriptor, Secret } from '@/api/requests'; +import { OptionalInput } from '@/components/form/optional-input.tsx'; +import { Badge } from '@/components/ui/badge.tsx'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input.tsx'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select.tsx'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; + +type PluginMultiSelectFieldProps< + TFieldValues extends FieldValues, + TName extends FieldPathByValue>, +> = { + form: UseFormReturn; + name: TName; + configName: TName; + label?: string; + description?: React.ReactNode; + plugins: readonly PreconfiguredPluginDescriptor[]; + secrets: readonly Secret[]; + className?: string; +}; + +export const PluginMultiSelectField = < + TFieldValues extends FieldValues, + TName extends FieldPathByValue>, +>({ + form, + name, + configName, + label, + description, + plugins, + secrets, + className, +}: PluginMultiSelectFieldProps) => { + return ( + ( + + {label} + {description} +
+ + form.getValues(name).includes(plugin.id) + ) + ? true + : plugins.some((plugin) => + form.getValues(name).includes(plugin.id) + ) + ? 'indeterminate' + : false + } + onCheckedChange={(checked) => { + const enabledItems = checked + ? plugins.map((plugin) => plugin.id) + : []; + form.setValue( + name, + // TypeScript doesn't get this, but TName extends FieldPathByValue>, + // so the field behind TName is always an Array + // and options.map((option) => option.id) is also Array, + // so this type cast is safe. + enabledItems as FieldPathValue + ); + }} + /> + +
+ + {plugins.map((plugin) => ( + + + { + return checked + ? field.onChange([...field.value, plugin.id]) + : field.onChange( + field.value?.filter( + (value: string) => value !== plugin.id + ) + ); + }} + /> + +
+ + {plugin.displayName} + + {plugin.description != null && ( + + {plugin.description} + + )} + {field.value?.includes(plugin.id) && + plugin.options.map((option) => ( + + } + render={({ field }) => ( + + + {option.name} + + {option.type} + + + + {option.description} + + + {option.type === 'BOOLEAN' ? ( + + ) : option.type == 'SECRET' ? ( + secrets.length === 0 ? ( + + No secrets available. Create a new secret to + be able to use this option. + + ) : ( + + ) + ) : option.isRequired ? ( + + ) : ( + + )} + + {option.isFixed && ( + + This option is set by an administrator and cannot + be changed. + + )} + + )} + /> + ))} +
+
+ ))} + +
+ )} + /> + ); +}; diff --git a/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/-components/advisor-fields.tsx b/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/-components/advisor-fields.tsx index 2137a48ac7..87e0edf4e7 100644 --- a/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/-components/advisor-fields.tsx +++ b/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/-components/advisor-fields.tsx @@ -19,8 +19,8 @@ import { UseFormReturn } from 'react-hook-form'; -import { PreconfiguredPluginDescriptor } from '@/api/requests'; -import { MultiSelectField } from '@/components/form/multi-select-field'; +import { PreconfiguredPluginDescriptor, Secret } from '@/api/requests'; +import { PluginMultiSelectField } from '@/components/form/plugin-multi-select-field.tsx'; import { AccordionContent, AccordionItem, @@ -41,6 +41,7 @@ type AdvisorFieldsProps = { value: string; onToggle: () => void; advisorPlugins: PreconfiguredPluginDescriptor[]; + secrets: Secret[]; }; export const AdvisorFields = ({ @@ -48,13 +49,8 @@ export const AdvisorFields = ({ value, onToggle, advisorPlugins, + secrets, }: AdvisorFieldsProps) => { - const advisorOptions = advisorPlugins.map((plugin) => ({ - id: plugin.id, - label: plugin.displayName, - description: plugin.description, - })); - return (
)} /> - Select the advisors enabled for this run.} - options={advisorOptions} + plugins={advisorPlugins} + secrets={secrets} /> diff --git a/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/-components/reporter-fields.tsx b/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/-components/reporter-fields.tsx index 3665bb0d8c..544a94b5f9 100644 --- a/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/-components/reporter-fields.tsx +++ b/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/-components/reporter-fields.tsx @@ -19,20 +19,14 @@ import { UseFormReturn } from 'react-hook-form'; -import { PreconfiguredPluginDescriptor } from '@/api/requests'; -import { MultiSelectField } from '@/components/form/multi-select-field'; +import { PreconfiguredPluginDescriptor, Secret } from '@/api/requests'; +import { PluginMultiSelectField } from '@/components/form/plugin-multi-select-field.tsx'; import { AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, -} from '@/components/ui/form'; +import { FormControl, FormField } from '@/components/ui/form'; import { Switch } from '@/components/ui/switch'; import { CreateRunFormValues } from '../_repo-layout/create-run/-create-run-utils'; @@ -41,6 +35,7 @@ type ReporterFieldsProps = { value: string; onToggle: () => void; reporterPlugins: PreconfiguredPluginDescriptor[]; + secrets: Secret[]; }; export const ReporterFields = ({ @@ -48,13 +43,8 @@ export const ReporterFields = ({ value, onToggle, reporterPlugins, + secrets, }: ReporterFieldsProps) => { - const reporterOptions = reporterPlugins.map((plugin) => ({ - id: plugin.id, - label: plugin.displayName, - description: plugin.description, - })); - return (
Reporter - Select the report formats to generate from the run. } - options={reporterOptions} + plugins={reporterPlugins} + secrets={secrets} /> - {form.getValues('jobConfigs.reporter.formats').includes('WebApp') && ( - ( - -
- Deduplicate dependency tree - - A flag to control whether subtrees occurring multiple - times in the dependency tree are stripped. - - - This will significantly reduce memory consumption of the - Reporter and might alleviate some out-of-memory issues. - - - NOTE: This option is currently effective only for the - WebApp report format. - -
- - - -
- )} - /> - )}
diff --git a/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/_repo-layout/create-run/-create-run-utils.ts b/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/_repo-layout/create-run/-create-run-utils.ts index 268829db1e..3272e60ddb 100644 --- a/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/_repo-layout/create-run/-create-run-utils.ts +++ b/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/_repo-layout/create-run/-create-run-utils.ts @@ -18,13 +18,15 @@ */ import { FieldErrors } from 'react-hook-form'; -import { z } from 'zod'; +import { z, ZodType } from 'zod'; import { AnalyzerJobConfiguration, CreateOrtRun, OrtRun, - ReporterJobConfiguration, + PluginConfig, + PluginOptionType, + PreconfiguredPluginDescriptor, } from '@/api/requests'; import { PackageManagerId, packageManagers } from '@/lib/types'; @@ -55,83 +57,150 @@ const environmentVariableSchema = z.object({ value: z.string().min(1).nullable().optional(), }); -export const createRunFormSchema = z.object({ - revision: z.string(), - path: z.string(), - jobConfigs: z.object({ - analyzer: z.object({ - enabled: z.boolean(), - repositoryConfigPath: z.string().optional(), - allowDynamicVersions: z.boolean(), - skipExcluded: z.boolean(), - environmentVariables: z.array(environmentVariableSchema).optional(), - packageManagers: z - .object({ - Bazel: packageManagerOptionsSchema, - Bower: packageManagerOptionsSchema, - Bundler: packageManagerOptionsSchema, - Cargo: packageManagerOptionsSchema, - Carthage: packageManagerOptionsSchema, - CocoaPods: packageManagerOptionsSchema, - Composer: packageManagerOptionsSchema, - Conan: packageManagerOptionsSchema, - GoMod: packageManagerOptionsSchema, - Gradle: packageManagerOptionsSchema, - GradleInspector: packageManagerOptionsSchema, - Maven: packageManagerOptionsSchema, - NPM: packageManagerOptionsSchema, - NuGet: packageManagerOptionsSchema, - PIP: packageManagerOptionsSchema, - Pipenv: packageManagerOptionsSchema, - PNPM: packageManagerOptionsSchema, - Poetry: packageManagerOptionsSchema, - Pub: packageManagerOptionsSchema, - SBT: packageManagerOptionsSchema, - SpdxDocumentFile: packageManagerOptionsSchema, - Stack: packageManagerOptionsSchema, - SwiftPM: packageManagerOptionsSchema, - Yarn: packageManagerOptionsSchema, - Yarn2: packageManagerOptionsSchema, - }) - .refine((schema) => { - // Ensure that not both Gradle and GradleInspector are enabled at the same time. - return !(schema.Gradle.enabled && schema.GradleInspector.enabled); - }, '"Gradle Legacy" and "Gradle" cannot be enabled at the same time.'), - }), - advisor: z.object({ - enabled: z.boolean(), - skipExcluded: z.boolean(), - advisors: z.array(z.string()), - }), - scanner: z.object({ - enabled: z.boolean(), - skipConcluded: z.boolean(), - skipExcluded: z.boolean(), - }), - evaluator: z.object({ - enabled: z.boolean(), +function optionTypeToZodType(type: PluginOptionType): ZodType { + switch (type) { + case 'BOOLEAN': + return z.boolean(); + case 'INTEGER': + return z.coerce.string(); + case 'LONG': + return z.coerce.string(); + case 'SECRET': + return z.string(); + case 'STRING': + return z.string(); + case 'STRING_LIST': + return z.array(z.string()); + default: + throw new Error(`Unsupported option type: ${type}`); + } +} + +const createPluginConfigSchema = (plugin: PreconfiguredPluginDescriptor) => { + const optionsSchema: Record = {}; + const secretsSchema: Record = {}; + + plugin.options?.forEach((option) => { + let schema = optionTypeToZodType(option.type); + if (option.isNullable) { + schema = schema.nullable(); + } + if (!option.isRequired) { + schema = schema.optional(); + } + + if (option.type == 'SECRET') { + secretsSchema[option.name] = schema; + } else { + optionsSchema[option.name] = schema; + } + }); + + return z + .object({ + options: z.object(optionsSchema).optional(), + secrets: z.object(secretsSchema).optional(), + }) + .optional(); +}; + +export const createRunFormSchema = ( + advisorPlugins: PreconfiguredPluginDescriptor[], + reporterPlugins: PreconfiguredPluginDescriptor[] +) => { + const advisorConfigSchema: Record = {}; + + advisorPlugins.forEach((plugin) => { + advisorConfigSchema[plugin.id] = createPluginConfigSchema(plugin); + }); + + const reporterConfigSchema: Record = {}; + + reporterPlugins.forEach((plugin) => { + reporterConfigSchema[plugin.id] = createPluginConfigSchema(plugin); + }); + + return z.object({ + revision: z.string(), + path: z.string(), + jobConfigs: z.object({ + analyzer: z.object({ + enabled: z.boolean(), + repositoryConfigPath: z.string().optional(), + allowDynamicVersions: z.boolean(), + skipExcluded: z.boolean(), + environmentVariables: z.array(environmentVariableSchema).optional(), + packageManagers: z + .object({ + Bazel: packageManagerOptionsSchema, + Bower: packageManagerOptionsSchema, + Bundler: packageManagerOptionsSchema, + Cargo: packageManagerOptionsSchema, + Carthage: packageManagerOptionsSchema, + CocoaPods: packageManagerOptionsSchema, + Composer: packageManagerOptionsSchema, + Conan: packageManagerOptionsSchema, + GoMod: packageManagerOptionsSchema, + Gradle: packageManagerOptionsSchema, + GradleInspector: packageManagerOptionsSchema, + Maven: packageManagerOptionsSchema, + NPM: packageManagerOptionsSchema, + NuGet: packageManagerOptionsSchema, + PIP: packageManagerOptionsSchema, + Pipenv: packageManagerOptionsSchema, + PNPM: packageManagerOptionsSchema, + Poetry: packageManagerOptionsSchema, + Pub: packageManagerOptionsSchema, + SBT: packageManagerOptionsSchema, + SpdxDocumentFile: packageManagerOptionsSchema, + Stack: packageManagerOptionsSchema, + SwiftPM: packageManagerOptionsSchema, + Yarn: packageManagerOptionsSchema, + Yarn2: packageManagerOptionsSchema, + }) + .refine((schema) => { + // Ensure that not both Gradle and GradleInspector are enabled at the same time. + return !(schema.Gradle.enabled && schema.GradleInspector.enabled); + }, '"Gradle Legacy" and "Gradle" cannot be enabled at the same time.'), + }), + advisor: z.object({ + enabled: z.boolean(), + skipExcluded: z.boolean(), + advisors: z.array(z.string()), + config: z.object(advisorConfigSchema).optional(), + }), + scanner: z.object({ + enabled: z.boolean(), + skipConcluded: z.boolean(), + skipExcluded: z.boolean(), + }), + evaluator: z.object({ + enabled: z.boolean(), + ruleSet: z.string().optional(), + licenseClassificationsFile: z.string().optional(), + copyrightGarbageFile: z.string().optional(), + resolutionsFile: z.string().optional(), + }), + reporter: z.object({ + enabled: z.boolean(), + formats: z.array(z.string()), + config: z.object(reporterConfigSchema).optional(), + }), + notifier: z.object({ + enabled: z.boolean(), + recipientAddresses: z.array(z.object({ email: z.string() })).optional(), + }), + parameters: z.array(keyValueSchema).optional(), ruleSet: z.string().optional(), - licenseClassificationsFile: z.string().optional(), - copyrightGarbageFile: z.string().optional(), - resolutionsFile: z.string().optional(), - }), - reporter: z.object({ - enabled: z.boolean(), - formats: z.array(z.string()), - deduplicateDependencyTree: z.boolean().optional(), - }), - notifier: z.object({ - enabled: z.boolean(), - recipientAddresses: z.array(z.object({ email: z.string() })).optional(), }), - parameters: z.array(keyValueSchema).optional(), - ruleSet: z.string().optional(), - }), - labels: z.array(keyValueSchema).optional(), - jobConfigContext: z.string().optional(), -}); + labels: z.array(keyValueSchema).optional(), + jobConfigContext: z.string().optional(), + }); +}; -export type CreateRunFormValues = z.infer; +export type CreateRunFormValues = z.infer< + ReturnType +>; /** * Converts an object map coming from the back-end to an array of key-value pairs. @@ -213,13 +282,75 @@ export const flattenErrors = ( return result; }; +/** + * Merge the plugin configs from the last run with the default plugin configs. The configs from the last run take + * precedence. + */ +function mergePluginConfigs( + lastRunConfig: { [p: string]: PluginConfig } | null | undefined, + defaultConfig: Record +): Record { + const merged: Record = {}; + + for (const pluginId of Object.keys(defaultConfig)) { + const defaultPlugin = defaultConfig[pluginId]; + const ortPlugin = lastRunConfig?.[pluginId]; + + merged[pluginId] = { + options: { + ...(defaultPlugin?.options ?? {}), + ...(ortPlugin?.options ?? {}), + }, + secrets: { + ...(defaultPlugin?.secrets ?? {}), + ...(ortPlugin?.secrets ?? {}), + }, + }; + } + + if (lastRunConfig) { + for (const pluginId of Object.keys(lastRunConfig)) { + if (!merged[pluginId] && lastRunConfig[pluginId]) { + merged[pluginId] = lastRunConfig[pluginId]; + } + } + } + + return merged; +} + +function getPluginDefaultValues(plugins: PreconfiguredPluginDescriptor[]) { + return plugins.reduce( + (acc, plugin) => { + const options: Record = {}; + const secrets: Record = {}; + + plugin.options?.forEach((option) => { + if (option.defaultValue !== undefined) { + if (option.type === 'SECRET') { + secrets[option.name] = String(option.defaultValue); + } else { + options[option.name] = String(option.defaultValue); + } + } + }); + + acc[plugin.id] = { options: options, secrets: secrets }; + return acc; + }, + {} as Record + ); +} + /** * Get the default values for the create run form. The form can be provided with a previously run * ORT run, in which case the values from it are used as defaults. Otherwise uses base defaults. */ export function defaultValues( - ortRun: OrtRun | null -): z.infer { + ortRun: OrtRun | null, + advisorPlugins: PreconfiguredPluginDescriptor[], + reporterPlugins: PreconfiguredPluginDescriptor[] +): z.infer> { /** * Constructs the default options for a package manager, either as a blank set of options * or from an earlier ORT run if rerun functionality is used. @@ -256,15 +387,8 @@ export function defaultValues( }; }; - // Find out if any of the reporters had their options.deduplicateDependencyTree set to true in the previous run. - // This is used to set the default value for the deduplicateDependencyTree toggle in the UI. - const deduplicateDependencyTreeEnabled = ortRun - ? ortRun.jobConfigs.reporter?.config && - Object.keys(ortRun.jobConfigs.reporter.config ?? {}).some((key) => { - const config = ortRun.jobConfigs.reporter?.config?.[key]; - return config?.options?.deduplicateDependencyTree === 'true'; - }) - : false; + const advisorPluginDefaultValues = getPluginDefaultValues(advisorPlugins); + const reporterPluginDefaultValues = getPluginDefaultValues(reporterPlugins); // Default values for the form: edit only these, not the defaultValues object. const baseDefaults = { @@ -308,6 +432,7 @@ export function defaultValues( enabled: true, skipExcluded: true, advisors: ['OSV', 'VulnerableCode'], + config: advisorPluginDefaultValues, }, scanner: { enabled: true, @@ -325,6 +450,7 @@ export function defaultValues( enabled: true, formats: ['CycloneDX', 'SpdxDocument', 'WebApp'], deduplicateDependencyTree: false, + config: reporterPluginDefaultValues, }, notifier: { enabled: false, @@ -371,6 +497,10 @@ export function defaultValues( advisors: ortRun.jobConfigs.advisor?.advisors || baseDefaults.jobConfigs.advisor.advisors, + config: mergePluginConfigs( + ortRun?.jobConfigs?.advisor?.config, + advisorPluginDefaultValues + ), }, scanner: { enabled: @@ -399,8 +529,10 @@ export function defaultValues( ortRun.jobConfigs.reporter?.formats?.map((format) => format === 'CycloneDx' ? 'CycloneDX' : format ) || baseDefaults.jobConfigs.reporter.formats, - deduplicateDependencyTree: - deduplicateDependencyTreeEnabled || undefined, + config: mergePluginConfigs( + ortRun?.jobConfigs?.reporter?.config, + reporterPluginDefaultValues + ), }, notifier: { enabled: @@ -425,12 +557,70 @@ export function defaultValues( : baseDefaults; } +/** + * Convert the plugin config from form values to the payload format expected by the back-end. Configuration for plugins + * which are not enabled is not included in the payload. + */ +function createPluginPayload( + config: Record | undefined, + enabledPlugins: string[] +): { [key: string]: PluginConfig } | undefined { + if (!config) return undefined; + + const filtered = Object.fromEntries( + Object.entries(config) + .filter(([key]) => enabledPlugins.includes(key)) + .map(([key, value]) => { + if (value && typeof value === 'object') { + const pluginConfig = value as Record; + const convertedConfig: PluginConfig = { + options: {}, + secrets: {}, + }; + + if ( + pluginConfig.options && + typeof pluginConfig.options === 'object' + ) { + convertedConfig.options = Object.fromEntries( + Object.entries(pluginConfig.options as Record) + .filter( + ([, optValue]) => optValue !== undefined && optValue !== null + ) + .map(([optKey, optValue]) => [optKey, String(optValue)]) + ); + } + + if ( + pluginConfig.secrets && + typeof pluginConfig.secrets === 'object' + ) { + convertedConfig.secrets = Object.fromEntries( + Object.entries(pluginConfig.secrets as Record) + .filter( + ([, secValue]) => secValue !== undefined && secValue !== null + ) + .map(([secKey, secValue]) => [secKey, String(secValue)]) + ); + } + + return [key, convertedConfig]; + } + return [key, value]; + }) + ); + + return Object.keys(filtered).length > 0 + ? (filtered as { [key: string]: PluginConfig }) + : undefined; +} + /** * Due to API schema and requirements for the form schema, the form values can't be directly passed * to the API. This function converts form values to correct payload to create an ORT run. */ export function formValuesToPayload( - values: z.infer + values: z.infer> ): CreateOrtRun { /** * A helper function to get the enabled package managers from the form values. @@ -534,6 +724,10 @@ export function formValuesToPayload( ? { skipExcluded: values.jobConfigs.advisor.skipExcluded, advisors: values.jobConfigs.advisor.advisors, + config: createPluginPayload( + values.jobConfigs.advisor.config, + values.jobConfigs.advisor.advisors + ), } : undefined; @@ -559,61 +753,13 @@ export function formValuesToPayload( // Reporter configuration // - // Check if CycloneDX, SPDX, and/or NOTICE file reports are enabled in the form, - // and configure them to use all output formats, accordingly. - - const cycloneDxEnabled = - values.jobConfigs.reporter.formats.includes('CycloneDX'); - const spdxDocumentEnabled = - values.jobConfigs.reporter.formats.includes('SpdxDocument'); - const noticeFileEnabled = - values.jobConfigs.reporter.formats.includes('PlainTextTemplate'); - - const config: ReporterJobConfiguration['config'] = {}; - - if (spdxDocumentEnabled) { - config.SpdxDocument = { - options: { - outputFileFormats: 'YAML,JSON', - }, - secrets: {}, - }; - } - - if (cycloneDxEnabled) { - config.CycloneDX = { - options: { - outputFileFormats: 'XML,JSON', - }, - secrets: {}, - }; - } - - if (noticeFileEnabled) { - config.PlainTextTemplate = { - options: { - templateIds: 'NOTICE_DEFAULT,NOTICE_SUMMARY', - }, - secrets: {}, - }; - } - - // If WebApp and the deduplicateDependencyTree option are enabled, add the configuration. - - const webAppEnabled = values.jobConfigs.reporter.formats.includes('WebApp'); - if (webAppEnabled && values.jobConfigs.reporter.deduplicateDependencyTree) { - config.WebApp = { - options: { - deduplicateDependencyTree: 'true', - }, - secrets: {}, - }; - } - const reporterConfig = values.jobConfigs.reporter.enabled ? { formats: values.jobConfigs.reporter.formats, - config: Object.keys(config).length > 0 ? config : undefined, + config: createPluginPayload( + values.jobConfigs.reporter.config, + values.jobConfigs.reporter.formats + ), } : undefined; diff --git a/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/_repo-layout/create-run/index.tsx b/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/_repo-layout/create-run/index.tsx index c97dc890da..00a9aebb34 100644 --- a/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/_repo-layout/create-run/index.tsx +++ b/ui/src/routes/organizations/$orgId/products/$productId/repositories/$repoId/_repo-layout/create-run/index.tsx @@ -69,7 +69,7 @@ import { const CreateRunPage = () => { const navigate = useNavigate(); const params = Route.useParams(); - const { ortRun, plugins } = Route.useLoaderData(); + const { ortRun, plugins, secrets } = Route.useLoaderData(); const [isTest, setIsTest] = useState(false); const advisorPlugins = @@ -124,9 +124,11 @@ const CreateRunPage = () => { }, }); - const form = useForm({ - resolver: zodResolver(createRunFormSchema), - defaultValues: defaultValues(ortRun), + const formSchema = createRunFormSchema(advisorPlugins, reporterPlugins); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: defaultValues(ortRun, advisorPlugins, reporterPlugins), }); const { @@ -425,6 +427,7 @@ const CreateRunPage = () => { value='advisor' onToggle={() => toggleAccordionOpen('advisor')} advisorPlugins={advisorPlugins} + secrets={secrets} /> { value='reporter' onToggle={() => toggleAccordionOpen('reporter')} reporterPlugins={reporterPlugins} + secrets={secrets} /> { - const [ortRun, plugins] = await Promise.all([ + const [ortRun, plugins, secrets] = await Promise.all([ rerunIndex !== undefined ? RepositoriesService.getApiV1RepositoriesByRepositoryIdRunsByOrtRunIndex( { @@ -544,11 +548,15 @@ export const Route = createFileRoute( RepositoriesService.getApiV1RepositoriesByRepositoryIdPlugins({ repositoryId: Number.parseInt(params.repoId), }), + RepositoriesService.getApiV1RepositoriesByRepositoryIdAvailableSecrets({ + repositoryId: Number.parseInt(params.repoId), + }), ]); return { ortRun, plugins, + secrets, }; }, component: CreateRunPage,