Repository: eclipse-apoapsis/ort-server
Pull request #2696: Add support for a custom "home" icon
Author: login: Etsija
Created: 2025-05-12T08:00:28Z | Merged: 2025-05-14T07:13:01Z

This PR adds a table for storing key-value pairs for UI configuration, and endpoints for getting, inserting and updating the stored configuration values.  A minimal UI is implemented as the first use case of the table: 

1. No entries in the configuration table of the database. The UI falls back to showing the "hardcoded" ORT Server favicon as the Home icon:

![Screenshot from 2025-05-12 10-42-45](https://github.com/user-attachments/assets/75356963-6e8a-4781-a6f5-319562ccc653)

2. The admin has configured the Home icon by providing a valid URL to it, and toggling the showing of the tailored icon on:

![Screenshot from 2025-05-12 10-43-38](https://github.com/user-attachments/assets/c0cc242e-f366-4dcd-a5eb-cc7c74dcc52b)

3. The admin can still  toggle between the tailored and hardcoded icons in the UI for comparison - this might be important later on, when we add support for more branding elements to the UI:

![Screenshot from 2025-05-12 10-43-52](https://github.com/user-attachments/assets/deefb255-0a9e-40ad-b36b-7fc95d0dad21)



Diff:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index be6766d266..d7c1b0bf01 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -169,6 +169,8 @@ For both languages, there are PR checks that enforce a consistent code style.
 It is advised to run those checks locally before creating a PR.
 
 For Kotlin, you can run `./gradlew detekt detektMain detektTest` to run the static code analysis.
+You can also force `detect` to automatically fix some issues (for example, import order) by running `./gradlew detekt --auto-correct`.
+
 For TypeScript, you can run `pnpm format:check` within the `ui/` and `/website` directories.
 
 ### Larger changes
diff --git a/components/admin-config/backend/build.gradle.kts b/components/admin-config/backend/build.gradle.kts
new file mode 100644
index 0000000000..a6dfacd5f0
--- /dev/null
+++ b/components/admin-config/backend/build.gradle.kts
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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-jvm-conventions")
+    id("ort-server-publication-conventions")
+
+    // Apply third-party plugins.
+    alias(libs.plugins.kotlinSerialization)
+}
+
+group = "org.eclipse.apoapsis.ortserver.components.adminconfig"
+
+repositories {
+    exclusiveContent {
+        forRepository {
+            maven("https://repo.gradle.org/gradle/libs-releases/")
+        }
+
+        filter {
+            includeGroup("org.gradle")
+        }
+    }
+}
+
+dependencies {
+    implementation(projects.components.authorization.implementation)
+    implementation(projects.shared.ktorUtils)
+
+    implementation(libs.exposedCore)
+    implementation(libs.exposedKotlinDatetime)
+    implementation(libs.ktorOpenApi)
+    implementation(libs.ktorServerAuth)
+    implementation(libs.ktorServerCore)
+
+    testImplementation(testFixtures(projects.clients.keycloak))
+    testImplementation(testFixtures(projects.dao))
+    testImplementation(testFixtures(projects.shared.ktorUtils))
+
+    testImplementation(libs.kotestAssertionsKtor)
+    testImplementation(libs.kotestRunnerJunit5)
+    testImplementation(libs.kotlinxSerializationJson)
+    testImplementation(libs.ktorKotlinxSerialization)
+    testImplementation(libs.ktorServerAuth)
+    testImplementation(libs.ktorServerContentNegotiation)
+    testImplementation(libs.ktorServerStatusPages)
+    testImplementation(libs.ktorServerTestHost)
+    testImplementation(libs.mockk)
+}
diff --git a/components/admin-config/backend/src/main/kotlin/ConfigTable.kt b/components/admin-config/backend/src/main/kotlin/ConfigTable.kt
new file mode 100644
index 0000000000..fdfae5084a
--- /dev/null
+++ b/components/admin-config/backend/src/main/kotlin/ConfigTable.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig
+
+import kotlinx.datetime.Clock
+import kotlinx.serialization.Serializable
+
+import org.jetbrains.exposed.sql.Table
+import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
+import org.jetbrains.exposed.sql.upsert
+
+/**
+ * A configuration entry.
+ *
+ * @property value The value of the configuration entry. If null, the default value is used.
+ * @property isEnabled Whether the configuration entry is enabled or not.
+ */
+@Serializable
+data class Config(
+    val value: String?,
+    val isEnabled: Boolean
+)
+
+/**
+ * The supported configuration keys. The keys are specified as enumerable values
+ * to prevent addition of arbitrary keys to the table. New keys should be added
+ * by adding a new entry to this enum class, possibly with a default value.
+ */
+enum class ConfigKey(val default: String) {
+    HOME_ICON_URL("https://example.com/icon.png"),
+}
+
+object ConfigTable : Table("config_table") {
+    val key = enumerationByName<ConfigKey>("key", 255)
+    val isEnabled = bool("is_enabled").default(false)
+    val value = text("value")
+    val updatedAt = timestamp("updated_at")
+
+    override val primaryKey = PrimaryKey(key, name = "PK_ConfigTable_key")
+
+    // If the config key doesn't exist in the database, return the default value of the enum.
+    // Also return the isEnabled status as false, to handle gracefully the case of missing config
+    // key in the UI, by falling back to using a default value.
+    fun get(key: ConfigKey): Config =
+        select(value, isEnabled)
+            .where { ConfigTable.key eq key }
+            .map { Config(value = it[value], it[isEnabled]) }
+            .firstOrNull() ?: Config(key.default, false)
+
+    fun insertOrUpdate(key: ConfigKey, value: String?, isEnabled: Boolean) {
+        upsert {
+            it[ConfigTable.key] = key
+            it[ConfigTable.value] = value ?: key.default
+            it[ConfigTable.isEnabled] = isEnabled
+            it[updatedAt] = Clock.System.now()
+        }
+    }
+}
diff --git a/components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt b/components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
new file mode 100644
index 0000000000..8f272d60d0
--- /dev/null
+++ b/components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+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.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireAuthenticated
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.getConfigByKey(db: Database) = get("admin/config/{key}", {
+    operationId = "GetConfigByKey"
+    summary = "Get the value of a configuration key"
+    description = "Get the value and isEnabled properties of a configuration key. " +
+        "If the key does not exist in the database, a default value is returned."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "Success"
+            body<Config> {
+                example("Configuration values") {
+                    value = """
+                    {
+                        "isEnabled": false,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+                }
+            }
+        }
+
+        HttpStatusCode.BadRequest to {
+            description = "Invalid configuration key."
+        }
+    }
+}) {
+    requireAuthenticated()
+
+    val keyParameter = call.requireParameter("key")
+    val key = runCatching {
+        enumValueOf<ConfigKey>(keyParameter)
+    }.getOrElse {
+        call.respond(
+            HttpStatusCode.BadRequest,
+            mapOf(
+                "message" to "Invalid configuration key: $keyParameter",
+                "cause" to "Allowed keys: ${ConfigKey.entries.joinToString(", ")}"
+            )
+        )
+        return@get
+    }
+    val configValue = transaction(db) { ConfigTable.get(key) }
+    call.respond(HttpStatusCode.OK, configValue)
+}
diff --git a/components/admin-config/backend/src/main/kotlin/endpoints/InsertOrUpdateConfig.kt b/components/admin-config/backend/src/main/kotlin/endpoints/InsertOrUpdateConfig.kt
new file mode 100644
index 0000000000..6430e93419
--- /dev/null
+++ b/components/admin-config/backend/src/main/kotlin/endpoints/InsertOrUpdateConfig.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.post
+
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.request.receive
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireSuperuser
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.insertOrUpdateConfig(db: Database) = post("admin/config/{key}", {
+    operationId = "InsertOrUpdateConfig"
+    summary = "Insert or update a configuration key"
+    description = "For an existing key, update the value and isEnabled of the key. " +
+        "If the key does not exist, insert it."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+        body<Config> {
+            description = "The configuration value and enabled parameters."
+            example("Configuration value") {
+                value = """
+                    {
+                        "isEnabled": true,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+            }
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "The configuration entry was successfully inserted or updated."
+        }
+        HttpStatusCode.BadRequest to {
+            description = "Invalid configuration key."
+        }
+    }
+}) {
+    requireSuperuser()
+
+    val keyParameter = call.requireParameter("key")
+    val key = runCatching {
+        enumValueOf<ConfigKey>(keyParameter)
+    }.getOrElse {
+        call.respond(
+            HttpStatusCode.BadRequest,
+            mapOf(
+                "message" to "Invalid configuration key: $keyParameter",
+                "cause" to "Allowed keys: ${ConfigKey.entries.joinToString(", ")}"
+            )
+        )
+        return@post
+    }
+    val config = call.receive<Config>()
+    transaction(db) {
+        ConfigTable.insertOrUpdate(
+            key = key,
+            value = config.value,
+            isEnabled = config.isEnabled
+        )
+    }
+    call.respond(HttpStatusCode.OK)
+}
diff --git a/components/admin-config/backend/src/test/kotlin/endpoints/AdminConfigAuthorizationTest.kt b/components/admin-config/backend/src/test/kotlin/endpoints/AdminConfigAuthorizationTest.kt
new file mode 100644
index 0000000000..34b3734b93
--- /dev/null
+++ b/components/admin-config/backend/src/test/kotlin/endpoints/AdminConfigAuthorizationTest.kt
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.core.extensions.install
+import io.kotest.core.spec.style.WordSpec
+
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.serialization.kotlinx.serialization
+import io.ktor.server.application.install
+import io.ktor.server.auth.authenticate
+import io.ktor.server.config.MapApplicationConfig
+import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.server.plugins.statuspages.StatusPages
+import io.ktor.server.response.respond
+import io.ktor.server.routing.routing
+import io.ktor.server.testing.testApplication
+
+import kotlinx.serialization.json.Json
+
+import org.eclipse.apoapsis.ortserver.clients.keycloak.DefaultKeycloakClient.Companion.configureAuthentication
+import org.eclipse.apoapsis.ortserver.clients.keycloak.GroupName
+import org.eclipse.apoapsis.ortserver.clients.keycloak.RoleName
+import org.eclipse.apoapsis.ortserver.clients.keycloak.User
+import org.eclipse.apoapsis.ortserver.clients.keycloak.UserId
+import org.eclipse.apoapsis.ortserver.clients.keycloak.UserName
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.KeycloakTestExtension
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.TEST_SUBJECT_CLIENT
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.addUserRole
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.createJwtConfigMapForTestRealm
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.createKeycloakClientConfigurationForTestRealm
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.createKeycloakClientForTestRealm
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.createKeycloakConfigMapForTestRealm
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.setUpClientScope
+import org.eclipse.apoapsis.ortserver.clients.keycloak.test.setUpUser
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.authorization.AuthorizationException
+import org.eclipse.apoapsis.ortserver.components.authorization.SecurityConfigurations
+import org.eclipse.apoapsis.ortserver.components.authorization.configureAuthentication
+import org.eclipse.apoapsis.ortserver.components.authorization.roles.Superuser
+import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.createJsonClient
+import org.eclipse.apoapsis.ortserver.utils.test.Integration
+
+private val TEST_USER = User(
+    id = UserId("test-user-id"),
+    username = UserName("test-user"),
+    firstName = "Test",
+    lastName = "User",
+    email = "test-user@example.org"
+)
+
+private const val TEST_USER_PASSWORD = "password"
+
+class AdminConfigAuthorizationTest : WordSpec({
+    tags(Integration)
+
+    val dbExtension = extension(DatabaseTestExtension())
+
+    val keycloak = install(KeycloakTestExtension(createRealmPerTest = true)) {
+        setUpUser(TEST_USER, TEST_USER_PASSWORD)
+        setUpClientScope(TEST_SUBJECT_CLIENT)
+    }
+
+    val json = Json { ignoreUnknownKeys = true }
+
+    val keycloakClient = keycloak.createKeycloakClientForTestRealm()
+    val keycloakConfig = keycloak.createKeycloakConfigMapForTestRealm()
+    val jwtConfig = keycloak.createJwtConfigMapForTestRealm()
+
+    beforeEach {
+        val roleName = RoleName(Superuser.ROLE_NAME)
+        val groupName = GroupName(Superuser.GROUP_NAME)
+        keycloakClient.createRole(name = roleName)
+        keycloakClient.createGroup(groupName)
+        val group = keycloakClient.getGroup(groupName)
+        val role = keycloakClient.getRole(roleName)
+        keycloakClient.addGroupClientRole(group.id, role)
+    }
+
+    "GetConfigByKey for HOME_ICON_URL" should {
+        "require authorization" {
+            testApplication {
+                val config = MapApplicationConfig()
+                (keycloakConfig + jwtConfig).forEach { config.put(it.key, it.value) }
+                config.put("jwt.audience", TEST_SUBJECT_CLIENT)
+
+                environment {
+                    this.config = config
+                }
+
+                application {
+                    install(ContentNegotiation) {
+                        serialization(ContentType.Application.Json, json)
+                    }
+
+                    install(StatusPages) {
+                        exception<AuthorizationException> { call, _ ->
+                            call.respond(HttpStatusCode.Forbidden)
+                        }
+                    }
+
+                    configureAuthentication(config, keycloakClient)
+
+                    routing {
+                        authenticate(SecurityConfigurations.TOKEN) {
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                val configKey = ConfigKey.HOME_ICON_URL
+
+                val clientConfig = keycloak.createKeycloakClientConfigurationForTestRealm(
+                    user = TEST_USER.username.value,
+                    secret = TEST_USER_PASSWORD
+                )
+                val client = createJsonClient().configureAuthentication(clientConfig, json)
+
+                client.get("/admin/config/$configKey") shouldHaveStatus HttpStatusCode.OK
+            }
+        }
+    }
+
+    "InsertOrUpdateConfig" should {
+        "require the superuser role" {
+            testApplication {
+                val config = MapApplicationConfig()
+                (keycloakConfig + jwtConfig).forEach { config.put(it.key, it.value) }
+                config.put("jwt.audience", TEST_SUBJECT_CLIENT)
+
+                environment {
+                    this.config = config
+                }
+
+                application {
+                    install(ContentNegotiation) {
+                        serialization(ContentType.Application.Json, json)
+                    }
+
+                    install(StatusPages) {
+                        exception<AuthorizationException> { call, _ ->
+                            call.respond(HttpStatusCode.Forbidden)
+                        }
+                    }
+
+                    configureAuthentication(config, keycloakClient)
+
+                    routing {
+                        authenticate(SecurityConfigurations.TOKEN) {
+                            insertOrUpdateConfig(dbExtension.db)
+                        }
+                    }
+                }
+
+                val configKey = ConfigKey.HOME_ICON_URL
+                val body = Config(value = "https://example.com/icon.png", isEnabled = true)
+
+                val clientConfig = keycloak.createKeycloakClientConfigurationForTestRealm(
+                    user = TEST_USER.username.value,
+                    secret = TEST_USER_PASSWORD
+                )
+                val client = createJsonClient().configureAuthentication(clientConfig, json)
+
+                client.post("/admin/config/$configKey") {
+                    contentType(ContentType.Application.Json)
+                    setBody(body)
+                } shouldHaveStatus HttpStatusCode.Forbidden
+                keycloak.keycloakAdminClient.addUserRole(TEST_USER.username.value, Superuser.ROLE_NAME)
+                client.post("/admin/config/$configKey") {
+                    contentType(ContentType.Application.Json)
+                    setBody(body)
+                } shouldHaveStatus HttpStatusCode.OK
+            }
+        }
+    }
+})
diff --git a/components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt b/components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt
new file mode 100644
index 0000000000..858f4a667f
--- /dev/null
+++ b/components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class GetConfigByKeyIntegrationTest : AbstractIntegrationTest({
+    "GetConfigByKey" should {
+        "return the value and enabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                // Insert a test configuration key to database
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/existing_icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/existing_icon.png"
+                config.isEnabled shouldBe true
+            }
+        }
+
+        "return the default value if the configuration key does not exist in db" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe false
+            }
+        }
+
+        "return BadRequest if the configuration key is invalid" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                val response = client.get("/admin/config/INVALID_KEY")
+
+                response shouldHaveStatus HttpStatusCode.BadRequest
+            }
+        }
+    }
+})
diff --git a/components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt b/components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt
new file mode 100644
index 0000000000..13103edd5e
--- /dev/null
+++ b/components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class InsertOrUpdateConfigIntegrationTest : AbstractIntegrationTest({
+    "InsertOrUpdateConfig" should {
+        "insert the new configuration key if it doesn't exist" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+            }
+        }
+
+        "update the value and isEnabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://changed/example.com/explicit_icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response2 = client.get("/admin/config/HOME_ICON_URL")
+
+                response2 shouldHaveStatus HttpStatusCode.OK
+                val config2 = response2.body<Config>()
+                config2.value shouldBe "https://changed/example.com/explicit_icon.png"
+                config2.isEnabled shouldBe true
+            }
+        }
+
+        "return BadRequest if the configuration key is invalid" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                val response = client.get("/admin/config/INVALID_KEY")
+
+                response shouldHaveStatus HttpStatusCode.BadRequest
+            }
+        }
+    }
+})
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index 3b27e4969f..189c7506ac 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -63,6 +63,7 @@ repositories {
 dependencies {
     implementation(projects.api.v1.apiV1Mapping)
     implementation(projects.clients.keycloak)
+    implementation(projects.components.adminConfig.backend)
     implementation(projects.components.authorization.implementation)
     implementation(projects.components.pluginManager.implementation)
     implementation(projects.config.configSpi)
diff --git a/core/src/main/kotlin/plugins/Routing.kt b/core/src/main/kotlin/plugins/Routing.kt
index a9af8a7528..7165f30294 100644
--- a/core/src/main/kotlin/plugins/Routing.kt
+++ b/core/src/main/kotlin/plugins/Routing.kt
@@ -24,6 +24,8 @@ import io.ktor.server.auth.authenticate
 import io.ktor.server.routing.route
 import io.ktor.server.routing.routing
 
+import org.eclipse.apoapsis.ortserver.components.adminconfig.endpoints.getConfigByKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.endpoints.insertOrUpdateConfig
 import org.eclipse.apoapsis.ortserver.components.authorization.SecurityConfigurations
 import org.eclipse.apoapsis.ortserver.components.pluginmanager.endpoints.disablePlugin
 import org.eclipse.apoapsis.ortserver.components.pluginmanager.endpoints.enablePlugin
@@ -50,7 +52,9 @@ fun Application.configureRouting() {
                 admin()
                 disablePlugin(get())
                 enablePlugin(get())
+                getConfigByKey(get())
                 getInstalledPlugins(get())
+                insertOrUpdateConfig(get())
                 organizations()
                 products()
                 repositories()
diff --git a/dao/src/main/resources/db/migration/V104__configTable.sql b/dao/src/main/resources/db/migration/V104__configTable.sql
new file mode 100644
index 0000000000..2839952e1f
--- /dev/null
+++ b/dao/src/main/resources/db/migration/V104__configTable.sql
@@ -0,0 +1,7 @@
+CREATE TABLE config_table
+(
+    key        text      PRIMARY KEY,
+    value      text      NOT NULL,
+    is_enabled boolean   DEFAULT FALSE,
+    updated_at timestamp NOT NULL DEFAULT now()
+);
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 41b4ff50e0..64a48bc0b4 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -28,6 +28,7 @@ include(":api:v1:mapping")
 include(":api:v1:model")
 include(":cli")
 include(":clients:keycloak")
+include(":components:admin-config:backend")
 include(":components:authorization:implementation")
 include(":components:plugin-manager:api")
 include(":components:plugin-manager:implementation")
diff --git a/ui/src/components/header.tsx b/ui/src/components/header.tsx
index 5ed995f7c5..0f8177eccc 100644
--- a/ui/src/components/header.tsx
+++ b/ui/src/components/header.tsx
@@ -24,6 +24,7 @@ import { useEffect } from 'react';
 import { useForm } from 'react-hook-form';
 import { z } from 'zod';
 
+import { useAdminServiceGetApiV1AdminConfigByKey } from '@/api/queries';
 import homeIcon from '@/assets/home-icon.svg';
 import { ModeToggle } from '@/components/mode-toggle';
 import { Avatar, AvatarFallback } from '@/components/ui/avatar';
@@ -35,6 +36,9 @@ import {
 } from '@/components/ui/tooltip';
 import { extractInitials } from '@/helpers/extract-initials.ts';
 import { useUser } from '@/hooks/use-user';
+import { toast } from '@/lib/toast';
+import { LoadingIndicator } from './loading-indicator';
+import { ToastError } from './toast-error';
 import {
   Breadcrumb,
   BreadcrumbItem,
@@ -63,6 +67,15 @@ export const Header = () => {
   const user = useUser();
   const navigate = useNavigate();
 
+  const {
+    data: dbHomeIcon,
+    isPending,
+    isError,
+    error,
+  } = useAdminServiceGetApiV1AdminConfigByKey({
+    key: 'HOME_ICON_URL',
+  });
+
   const form = useForm<z.infer<typeof formSchema>>({
     resolver: zodResolver(formSchema),
   });
@@ -119,6 +132,22 @@ export const Header = () => {
     }
   }, [organizationMatch, productMatch, repoMatch]);
 
+  if (isPending) {
+    return <LoadingIndicator />;
+  }
+
+  if (isError) {
+    toast.error('Unable to load data', {
+      description: <ToastError error={error} />,
+      duration: Infinity,
+      cancel: {
+        label: 'Dismiss',
+        onClick: () => {},
+      },
+    });
+    return;
+  }
+
   return (
     <header className='bg-background sticky top-0 z-50 flex h-16 justify-between gap-4 border-b px-4 md:px-6'>
       <div className='flex flex-row items-center gap-4'>
@@ -129,7 +158,15 @@ export const Header = () => {
                 to='/'
                 className='flex items-center gap-2 text-lg font-semibold md:text-base'
               >
-                <img src={homeIcon} alt='ORT Server' className='size-6' />
+                <img
+                  src={
+                    dbHomeIcon.isEnabled && dbHomeIcon.value
+                      ? dbHomeIcon.value
+                      : homeIcon
+                  }
+                  alt='ORT Server'
+                  className='size-6'
+                />
                 <span className='sr-only'>Home</span>
               </Link>
             </TooltipTrigger>
diff --git a/ui/src/routes/admin/content-management/home-icon/index.tsx b/ui/src/routes/admin/content-management/home-icon/index.tsx
new file mode 100644
index 0000000000..81c087d0d1
--- /dev/null
+++ b/ui/src/routes/admin/content-management/home-icon/index.tsx
@@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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 { zodResolver } from '@hookform/resolvers/zod';
+import { useQueryClient } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import { Loader2 } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import {
+  useAdminServiceGetApiV1AdminConfigByKey,
+  useAdminServiceGetApiV1AdminConfigByKeyKey,
+  useAdminServicePostApiV1AdminConfigByKey,
+} from '@/api/queries';
+import { ApiError } from '@/api/requests';
+import { LoadingIndicator } from '@/components/loading-indicator.tsx';
+import { ToastError } from '@/components/toast-error.tsx';
+import { Button } from '@/components/ui/button.tsx';
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardFooter,
+  CardHeader,
+  CardTitle,
+} from '@/components/ui/card.tsx';
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Switch } from '@/components/ui/switch';
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip.tsx';
+import { toast } from '@/lib/toast.ts';
+
+const formSchema = z.object({
+  iconUrl: z.string().url('Invalid URL'),
+  isEnabled: z.boolean(),
+});
+
+function HomeIconComponent() {
+  const queryClient = useQueryClient();
+
+  const {
+    data: dbHomeIcon,
+    isFetching,
+    isError,
+    error,
+  } = useAdminServiceGetApiV1AdminConfigByKey({
+    key: 'HOME_ICON_URL',
+  });
+
+  const form = useForm<z.infer<typeof formSchema>>({
+    resolver: zodResolver(formSchema),
+    defaultValues: {
+      iconUrl: dbHomeIcon?.value || '',
+      isEnabled: dbHomeIcon?.isEnabled || false,
+    },
+  });
+
+  const { mutateAsync, isPending } = useAdminServicePostApiV1AdminConfigByKey({
+    onSuccess() {
+      queryClient.invalidateQueries({
+        queryKey: [useAdminServiceGetApiV1AdminConfigByKeyKey],
+      });
+      toast.info('Home icon saved', {
+        description: `Home icon saved successfully.`,
+      });
+    },
+    onError(error: ApiError) {
+      toast.error(error.message, {
+        description: <ToastError error={error} />,
+        duration: Infinity,
+        cancel: {
+          label: 'Dismiss',
+          onClick: () => {},
+        },
+      });
+    },
+  });
+
+  async function onSubmit(values: z.infer<typeof formSchema>) {
+    await mutateAsync({
+      requestBody: {
+        isEnabled: values.isEnabled,
+        value: values.iconUrl,
+      },
+      key: 'HOME_ICON_URL',
+    });
+  }
+
+  if (isFetching) {
+    return <LoadingIndicator />;
+  }
+
+  if (isError) {
+    toast.error('Unable to load data', {
+      description: <ToastError error={error} />,
+      duration: Infinity,
+      cancel: {
+        label: 'Dismiss',
+        onClick: () => {},
+      },
+    });
+    return;
+  }
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>Home Icon</CardTitle>
+        <CardDescription>
+          Customise the home icon by providing a URL to an icon image. The
+          toggle specifies whether the default ORT Server icon should be used or
+          the icon from the provided URL.
+        </CardDescription>
+      </CardHeader>
+      <Form {...form}>
+        <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-8'>
+          <CardContent className='space-y-4'>
+            <FormField
+              control={form.control}
+              name='iconUrl'
+              render={({ field }) => (
+                <FormItem>
+                  <FormLabel>Icon URL</FormLabel>
+                  <FormControl>
+                    <Input {...field} />
+                  </FormControl>
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
+            <FormField
+              control={form.control}
+              name='isEnabled'
+              render={({ field }) => (
+                <FormItem className='flex items-center gap-2'>
+                  <FormControl>
+                    <Switch
+                      checked={field.value}
+                      onCheckedChange={field.onChange}
+                    />
+                  </FormControl>
+                  <FormLabel>Use the provided icon URL</FormLabel>
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
+          </CardContent>
+          <CardFooter className='flex gap-2'>
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <Button
+                  type='button'
+                  variant='outline'
+                  onClick={() => form.reset()}
+                >
+                  Reset
+                </Button>
+              </TooltipTrigger>
+              <TooltipContent>
+                Revert to the saved home icon and clear unsaved edits.
+              </TooltipContent>
+            </Tooltip>
+            <Button type='submit' disabled={isPending}>
+              {isPending ? (
+                <>
+                  <span className='sr-only'>Saving ...</span>
+                  <Loader2 size={16} className='mx-3 animate-spin' />
+                </>
+              ) : (
+                'Save'
+              )}
+            </Button>
+          </CardFooter>
+        </form>
+      </Form>
+    </Card>
+  );
+}
+
+export const Route = createFileRoute('/admin/content-management/home-icon/')({
+  component: HomeIconComponent,
+});
diff --git a/ui/src/routes/admin/route.tsx b/ui/src/routes/admin/route.tsx
index 2286fb7085..447b30c98d 100644
--- a/ui/src/routes/admin/route.tsx
+++ b/ui/src/routes/admin/route.tsx
@@ -21,6 +21,7 @@ import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
 import {
   Blocks,
   Eye,
+  House,
   KeyRound,
   ListVideo,
   Palette,
@@ -80,6 +81,11 @@ const Layout = () => {
     {
       label: 'Content Management',
       items: [
+        {
+          title: 'Home Icon',
+          to: '/admin/content-management/home-icon',
+          icon: () => <House className='h-4 w-4' />,
+        },
         {
           title: 'Colors',
           to: '/admin/colors',


Reviews:
user: login: sschuberth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T08:25:56Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T09:15:19Z
user: login: sschuberth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T09:32:59Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T11:32:51Z
user: login: mnonnenmacher
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T12:58:54Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:35:13Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:42:29Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:48:37Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:50:19Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:53:41Z
user: login: sschuberth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:55:32Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:59:13Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:03:06Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:05:15Z
user: login: mnonnenmacher
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:05:25Z
user: login: mnonnenmacher
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:15:30Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T03:20:42Z
user: login: Etsija
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T04:37:59Z
user: login: mnonnenmacher
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:00:43Z
user: login: mnonnenmacher
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:03:57Z
user: login: sschuberth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T06:59:47Z

Review comments:
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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-jvm-conventions")
+    id("ort-server-publication-conventions")
+
+    // Apply third-party plugins.
+    alias(libs.plugins.kotlinSerialization)
+}
+
+group = "org.eclipse.apoapsis.ortserver.components.adminconfig"
+
+repositories {
+    exclusiveContent {
+        forRepository {
+            maven("https://repo.gradle.org/gradle/libs-releases/")
+        }
+
+        filter {
+            includeGroup("org.gradle")
+        }
+    }
+}
+
+dependencies {
+    implementation(projects.components.authorization.implementation)
+    implementation(projects.dao)
path: components/admin-config/backend/build.gradle.kts
user: login: sschuberth
body: Looks like this can be removed.
created_at: 2025-05-13T08:23:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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-jvm-conventions")
+    id("ort-server-publication-conventions")
+
+    // Apply third-party plugins.
+    alias(libs.plugins.kotlinSerialization)
+}
+
+group = "org.eclipse.apoapsis.ortserver.components.adminconfig"
+
+repositories {
+    exclusiveContent {
+        forRepository {
+            maven("https://repo.gradle.org/gradle/libs-releases/")
+        }
+
+        filter {
+            includeGroup("org.gradle")
+        }
+    }
+}
+
+dependencies {
+    implementation(projects.components.authorization.implementation)
+    implementation(projects.dao)
+    implementation(projects.shared.ktorUtils)
+
+    implementation(libs.exposedCore)
+    implementation(libs.exposedJson)
path: components/admin-config/backend/build.gradle.kts
user: login: sschuberth
body: Looks like this can be removed.
created_at: 2025-05-13T08:23:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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-jvm-conventions")
+    id("ort-server-publication-conventions")
+
+    // Apply third-party plugins.
+    alias(libs.plugins.kotlinSerialization)
+}
+
+group = "org.eclipse.apoapsis.ortserver.components.adminconfig"
+
+repositories {
+    exclusiveContent {
+        forRepository {
+            maven("https://repo.gradle.org/gradle/libs-releases/")
+        }
+
+        filter {
+            includeGroup("org.gradle")
+        }
+    }
+}
+
+dependencies {
+    implementation(projects.components.authorization.implementation)
+    implementation(projects.dao)
+    implementation(projects.shared.ktorUtils)
+
+    implementation(libs.exposedCore)
+    implementation(libs.exposedJson)
+    implementation(libs.exposedKotlinDatetime)
+    implementation(libs.ktorOpenApi)
+    implementation(libs.ktorServerAuth)
+    implementation(libs.ktorServerCore)
+
+    testImplementation(testFixtures(projects.clients.keycloak))
+    testImplementation(testFixtures(projects.dao))
+    testImplementation(testFixtures(projects.shared.ktorUtils))
+
+    testImplementation(libs.kotestAssertionsCore)
path: components/admin-config/backend/build.gradle.kts
user: login: sschuberth
body: Looks like this can be removed.
created_at: 2025-05-13T08:24:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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-jvm-conventions")
+    id("ort-server-publication-conventions")
+
+    // Apply third-party plugins.
+    alias(libs.plugins.kotlinSerialization)
+}
+
+group = "org.eclipse.apoapsis.ortserver.components.adminconfig"
+
+repositories {
+    exclusiveContent {
+        forRepository {
+            maven("https://repo.gradle.org/gradle/libs-releases/")
+        }
+
+        filter {
+            includeGroup("org.gradle")
+        }
+    }
+}
+
+dependencies {
+    implementation(projects.components.authorization.implementation)
+    implementation(projects.dao)
path: components/admin-config/backend/build.gradle.kts
user: login: Etsija
body: Do you mean both lines? That breaks the module. @mnonnenmacher promised in the alignment meeting to go through the dependencies and suggest removals.
created_at: 2025-05-13T09:15:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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-jvm-conventions")
+    id("ort-server-publication-conventions")
+
+    // Apply third-party plugins.
+    alias(libs.plugins.kotlinSerialization)
+}
+
+group = "org.eclipse.apoapsis.ortserver.components.adminconfig"
+
+repositories {
+    exclusiveContent {
+        forRepository {
+            maven("https://repo.gradle.org/gradle/libs-releases/")
+        }
+
+        filter {
+            includeGroup("org.gradle")
+        }
+    }
+}
+
+dependencies {
+    implementation(projects.components.authorization.implementation)
+    implementation(projects.dao)
path: components/admin-config/backend/build.gradle.kts
user: login: sschuberth
body: No, only line 44. Similar for the other comments, they apply to the bottom-most quoted line.
created_at: 2025-05-13T09:32:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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-jvm-conventions")
+    id("ort-server-publication-conventions")
+
+    // Apply third-party plugins.
+    alias(libs.plugins.kotlinSerialization)
+}
+
+group = "org.eclipse.apoapsis.ortserver.components.adminconfig"
+
+repositories {
+    exclusiveContent {
+        forRepository {
+            maven("https://repo.gradle.org/gradle/libs-releases/")
+        }
+
+        filter {
+            includeGroup("org.gradle")
+        }
+    }
+}
+
+dependencies {
+    implementation(projects.components.authorization.implementation)
+    implementation(projects.dao)
path: components/admin-config/backend/build.gradle.kts
user: login: Etsija
body: Removed.
created_at: 2025-05-13T11:32:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -63,6 +63,7 @@ repositories {
 dependencies {
     implementation(projects.api.v1.apiV1Mapping)
     implementation(projects.clients.keycloak)
+    implementation(projects.components.adminConfig.backend)
path: core/build.gradle.kts
user: login: mnonnenmacher
body: Nit: This dependency should be added in the commit where `core` actually starts depending on the new module.
created_at: 2025-05-13T12:38:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.get
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireAuthenticated
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.getConfigByKey(db: Database) = get("admin/config/{key}", {
+    operationId = "GetConfigByKey"
+    summary = "Get the value of a configuration key"
+    description = "Get the value and isEnabled properties of a configuration key. " +
+        "If the key does not exist in the database, a default value is returned."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "Success"
+            body<Config> {
+                mediaTypes = setOf(ContentType.Application.Json)
path: components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
user: login: mnonnenmacher
body: That should not be required.
created_at: 2025-05-13T12:44:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.get
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireAuthenticated
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.getConfigByKey(db: Database) = get("admin/config/{key}", {
+    operationId = "GetConfigByKey"
+    summary = "Get the value of a configuration key"
+    description = "Get the value and isEnabled properties of a configuration key. " +
+        "If the key does not exist in the database, a default value is returned."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "Success"
+            body<Config> {
+                mediaTypes = setOf(ContentType.Application.Json)
+                example("Configuration values") {
+                    value = """
+                    {
+                        "isEnabled": false,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+                }
+            }
+        }
+
+        HttpStatusCode.InternalServerError to {
path: components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
user: login: mnonnenmacher
body: This should return `BadRequest` instead.
created_at: 2025-05-13T12:45:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.post
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.request.receive
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireSuperuser
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.insertOrUpdateConfig(db: Database) = post("admin/config/{key}", {
+    operationId = "InsertOrUpdateConfig"
+    summary = "Insert or update a configuration key"
+    description = "For an existing key, update the value and isEnabled of the key. " +
+        "If the key does not exist, insert it."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+        body<Config> {
+            mediaTypes = setOf(ContentType.Application.Json)
path: components/admin-config/backend/src/main/kotlin/endpoints/InsertOrUpdateConfig.kt
user: login: mnonnenmacher
body: This should not be required.
created_at: 2025-05-13T12:45:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.post
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.request.receive
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireSuperuser
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.insertOrUpdateConfig(db: Database) = post("admin/config/{key}", {
+    operationId = "InsertOrUpdateConfig"
+    summary = "Insert or update a configuration key"
+    description = "For an existing key, update the value and isEnabled of the key. " +
+        "If the key does not exist, insert it."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+        body<Config> {
+            mediaTypes = setOf(ContentType.Application.Json)
+            description = "The configuration value and enabled parameters."
+            example("Configuration value") {
+                value = """
+                    {
+                        "isEnabled": true,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+            }
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "The configuration entry was successfully inserted or updated."
+        }
+        HttpStatusCode.InternalServerError to {
path: components/admin-config/backend/src/main/kotlin/endpoints/InsertOrUpdateConfig.kt
user: login: mnonnenmacher
body: This should return `BadRequest` instead.
created_at: 2025-05-13T12:46:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class GetConfigByKeyIntegrationTest : AbstractIntegrationTest({
+    "GetConfigByKey" should {
+        "return the value and enabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                // Insert a test configuration key to database
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
path: components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt
user: login: mnonnenmacher
body: This should not be required.
created_at: 2025-05-13T12:49:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class GetConfigByKeyIntegrationTest : AbstractIntegrationTest({
+    "GetConfigByKey" should {
+        "return the value and enabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                // Insert a test configuration key to database
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/existing_icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/existing_icon.png"
+                config.isEnabled shouldBe true
+            }
+        }
+        "return the default value if the configuration key does not exist in db" {
path: components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt
user: login: mnonnenmacher
body: Add an empty line between test cases.
created_at: 2025-05-13T12:54:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class GetConfigByKeyIntegrationTest : AbstractIntegrationTest({
+    "GetConfigByKey" should {
+        "return the value and enabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                // Insert a test configuration key to database
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/existing_icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/existing_icon.png"
+                config.isEnabled shouldBe true
+            }
+        }
+        "return the default value if the configuration key does not exist in db" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe false
+            }
+        }
+        "return InternalServerError if the configuration key is invalid" {
path: components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt
user: login: mnonnenmacher
body: Add an empty line between test cases.
created_at: 2025-05-13T12:54:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class InsertOrUpdateConfigIntegrationTest : AbstractIntegrationTest({
+    "InsertOrUpdateConfig" should {
+        "insert the new configuration key if it doesn't exist" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+            }
+        }
+        "update the value and isEnabled status of an existing configuration key" {
path: components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt
user: login: mnonnenmacher
body: Add an empty line between test cases.
created_at: 2025-05-13T12:55:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class InsertOrUpdateConfigIntegrationTest : AbstractIntegrationTest({
+    "InsertOrUpdateConfig" should {
+        "insert the new configuration key if it doesn't exist" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+            }
+        }
+        "update the value and isEnabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://changed/example.com/explicit_icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response2 = client.get("/admin/config/HOME_ICON_URL")
+
+                response2 shouldHaveStatus HttpStatusCode.OK
+                val config2 = response2.body<Config>()
+                config2.value shouldBe "https://changed/example.com/explicit_icon.png"
+                config2.isEnabled shouldBe true
+            }
+        }
+        "return InternalServerError if the configuration key is invalid" {
path: components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt
user: login: mnonnenmacher
body: Add an empty line between test cases.
created_at: 2025-05-13T12:55:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class InsertOrUpdateConfigIntegrationTest : AbstractIntegrationTest({
+    "InsertOrUpdateConfig" should {
+        "insert the new configuration key if it doesn't exist" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+            }
+        }
+        "update the value and isEnabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
path: components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt
user: login: mnonnenmacher
body: This should not be required.
created_at: 2025-05-13T12:55:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class InsertOrUpdateConfigIntegrationTest : AbstractIntegrationTest({
+    "InsertOrUpdateConfig" should {
+        "insert the new configuration key if it doesn't exist" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
path: components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt
user: login: mnonnenmacher
body: This should not be required.
created_at: 2025-05-13T12:55:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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 { zodResolver } from '@hookform/resolvers/zod';
+import { useQueryClient } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import { Loader2 } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import {
+  useAdminServiceGetApiV1AdminConfigByKey,
+  useAdminServiceGetApiV1AdminConfigByKeyKey,
+  useAdminServicePostApiV1AdminConfigByKey,
+} from '@/api/queries';
+import { ApiError } from '@/api/requests';
+import { LoadingIndicator } from '@/components/loading-indicator.tsx';
+import { ToastError } from '@/components/toast-error.tsx';
+import { Button } from '@/components/ui/button.tsx';
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardFooter,
+  CardHeader,
+  CardTitle,
+} from '@/components/ui/card.tsx';
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Switch } from '@/components/ui/switch';
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from '@/components/ui/tooltip.tsx';
+import { toast } from '@/lib/toast.ts';
+
+const formSchema = z.object({
+  iconUrl: z.string().url('Invalid URL'),
+  isEnabled: z.boolean(),
+});
+
+function HomeIconComponent() {
+  const queryClient = useQueryClient();
+
+  const {
+    data: dbHomeIcon,
+    isFetching,
+    isError,
+    error,
+  } = useAdminServiceGetApiV1AdminConfigByKey({
+    key: 'HOME_ICON_URL',
+  });
+
+  const form = useForm<z.infer<typeof formSchema>>({
+    resolver: zodResolver(formSchema),
+    defaultValues: {
+      iconUrl: dbHomeIcon?.value || '',
+      isEnabled: dbHomeIcon?.isEnabled || false,
+    },
+  });
+
+  const { mutateAsync, isPending } = useAdminServicePostApiV1AdminConfigByKey({
+    onSuccess() {
+      queryClient.invalidateQueries({
+        queryKey: [useAdminServiceGetApiV1AdminConfigByKeyKey],
+      });
+      toast.info('Home icon saved', {
+        description: `Home icon saved successfully.`,
+      });
+    },
+    onError(error: ApiError) {
+      toast.error(error.message, {
+        description: <ToastError error={error} />,
+        duration: Infinity,
+        cancel: {
+          label: 'Dismiss',
+          onClick: () => {},
+        },
+      });
+    },
+  });
+
+  async function onSubmit(values: z.infer<typeof formSchema>) {
+    await mutateAsync({
+      requestBody: {
+        isEnabled: values.isEnabled,
+        value: values.iconUrl,
+      },
+      key: 'HOME_ICON_URL',
+    });
+  }
+
+  if (isFetching) {
+    return <LoadingIndicator />;
+  }
+
+  if (isError) {
+    toast.error('Unable to load data', {
+      description: <ToastError error={error} />,
+      duration: Infinity,
+      cancel: {
+        label: 'Dismiss',
+        onClick: () => {},
+      },
+    });
+    return;
+  }
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>Home Icon</CardTitle>
+        <CardDescription>
+          Customise the home icon by providing a URL to an icon image. The
+          toggle specifies whether the default (ORT Server) icon should be used
+          or the icon from the provided URL.
path: ui/src/routes/admin/content-management/home-icon/index.tsx
user: login: mnonnenmacher
body: I would remove the parenthesis around "ORT Server".
created_at: 2025-05-13T12:58:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.get
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireAuthenticated
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.getConfigByKey(db: Database) = get("admin/config/{key}", {
+    operationId = "GetConfigByKey"
+    summary = "Get the value of a configuration key"
+    description = "Get the value and isEnabled properties of a configuration key. " +
+        "If the key does not exist in the database, a default value is returned."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "Success"
+            body<Config> {
+                mediaTypes = setOf(ContentType.Application.Json)
+                example("Configuration values") {
+                    value = """
+                    {
+                        "isEnabled": false,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+                }
+            }
+        }
+
+        HttpStatusCode.InternalServerError to {
path: components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
user: login: Etsija
body: But the endpoint returns `InternalServerError` when a non-existing key is requested for, so I don't quite understand how I can force that behavior.
created_at: 2025-05-13T14:35:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -63,6 +63,7 @@ repositories {
 dependencies {
     implementation(projects.api.v1.apiV1Mapping)
     implementation(projects.clients.keycloak)
+    implementation(projects.components.adminConfig.backend)
path: core/build.gradle.kts
user: login: Etsija
body: To clarify: this is the second commit, where the endpoints are defined?
created_at: 2025-05-13T14:42:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -63,6 +63,7 @@ repositories {
 dependencies {
     implementation(projects.api.v1.apiV1Mapping)
     implementation(projects.clients.keycloak)
+    implementation(projects.components.adminConfig.backend)
path: core/build.gradle.kts
user: login: Etsija
body: Moved the dep to the second commit.
created_at: 2025-05-13T14:48:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.post
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.request.receive
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireSuperuser
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.insertOrUpdateConfig(db: Database) = post("admin/config/{key}", {
+    operationId = "InsertOrUpdateConfig"
+    summary = "Insert or update a configuration key"
+    description = "For an existing key, update the value and isEnabled of the key. " +
+        "If the key does not exist, insert it."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+        body<Config> {
+            mediaTypes = setOf(ContentType.Application.Json)
+            description = "The configuration value and enabled parameters."
+            example("Configuration value") {
+                value = """
+                    {
+                        "isEnabled": true,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+            }
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "The configuration entry was successfully inserted or updated."
+        }
+        HttpStatusCode.InternalServerError to {
path: components/admin-config/backend/src/main/kotlin/endpoints/InsertOrUpdateConfig.kt
user: login: Etsija
body: Same comment as before.
created_at: 2025-05-13T14:50:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class InsertOrUpdateConfigIntegrationTest : AbstractIntegrationTest({
+    "InsertOrUpdateConfig" should {
+        "insert the new configuration key if it doesn't exist" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+            }
+        }
+        "update the value and isEnabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
+                    setBody(
+                        Config(
+                            value = "https://example.com/icon.png",
+                            isEnabled = true,
+                        )
+                    )
+                }
+
+                val response = client.get("/admin/config/HOME_ICON_URL")
+
+                response shouldHaveStatus HttpStatusCode.OK
+                val config = response.body<Config>()
+                config.value shouldBe "https://example.com/icon.png"
+                config.isEnabled shouldBe true
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
path: components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt
user: login: Etsija
body: Same comment as above.
created_at: 2025-05-13T14:53:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.post
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.request.receive
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireSuperuser
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.insertOrUpdateConfig(db: Database) = post("admin/config/{key}", {
+    operationId = "InsertOrUpdateConfig"
+    summary = "Insert or update a configuration key"
+    description = "For an existing key, update the value and isEnabled of the key. " +
+        "If the key does not exist, insert it."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+        body<Config> {
+            mediaTypes = setOf(ContentType.Application.Json)
path: components/admin-config/backend/src/main/kotlin/endpoints/InsertOrUpdateConfig.kt
user: login: sschuberth
body: As for my comments, these comments always refer to the bottom-most line, i.e. the content type. GitHub just renders the other lines for context.
created_at: 2025-05-13T14:55:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class GetConfigByKeyIntegrationTest : AbstractIntegrationTest({
+    "GetConfigByKey" should {
+        "return the value and enabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                // Insert a test configuration key to database
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
path: components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt
user: login: Etsija
body: `contentType` _is_ needed, otherwise I get 
```
Fail to prepare request body for sending. 
The body type is: class org.eclipse.apoapsis.ortserver.components.adminconfig.Config, with Content-Type: null.

If you expect serialized body, please check that you have installed the corresponding plugin(like `ContentNegotiation`) and set `Content-Type` header.
```
created_at: 2025-05-13T14:59:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class InsertOrUpdateConfigIntegrationTest : AbstractIntegrationTest({
+    "InsertOrUpdateConfig" should {
+        "insert the new configuration key if it doesn't exist" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
path: components/admin-config/backend/src/test/kotlin/endpoints/InsertOrUpdateConfigIntegrationTest.kt
user: login: Etsija
body: Same comment as above.
created_at: 2025-05-13T15:03:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.post
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.request.receive
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireSuperuser
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.insertOrUpdateConfig(db: Database) = post("admin/config/{key}", {
+    operationId = "InsertOrUpdateConfig"
+    summary = "Insert or update a configuration key"
+    description = "For an existing key, update the value and isEnabled of the key. " +
+        "If the key does not exist, insert it."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+        body<Config> {
+            mediaTypes = setOf(ContentType.Application.Json)
path: components/admin-config/backend/src/main/kotlin/endpoints/InsertOrUpdateConfig.kt
user: login: Etsija
body: Nice to know...or, would've been nice to know long time ago :smirk: I already deduced that must be it, from Martin's comments, otherwise, the code excerpt wouldn't have made sense.
created_at: 2025-05-13T15:05:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class GetConfigByKeyIntegrationTest : AbstractIntegrationTest({
+    "GetConfigByKey" should {
+        "return the value and enabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                // Insert a test configuration key to database
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
path: components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt
user: login: mnonnenmacher
body: Ok, good to know, I'll address this in a follow-up because we don't need it in the integration tests in `core`.
created_at: 2025-05-13T15:05:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.get
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireAuthenticated
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.getConfigByKey(db: Database) = get("admin/config/{key}", {
+    operationId = "GetConfigByKey"
+    summary = "Get the value of a configuration key"
+    description = "Get the value and isEnabled properties of a configuration key. " +
+        "If the key does not exist in the database, a default value is returned."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "Success"
+            body<Config> {
+                mediaTypes = setOf(ContentType.Application.Json)
+                example("Configuration values") {
+                    value = """
+                    {
+                        "isEnabled": false,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+                }
+            }
+        }
+
+        HttpStatusCode.InternalServerError to {
path: components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
user: login: mnonnenmacher
body: This happens because `enumValueOf<ConfigKey>` below throws an exception when the key is invalid and you do not catch it, so the catch-all hanlder in `StatusPages.kt` in `core` turns it into an internal server error.
Instead, you should catch the exception and return a meaningful error response like so:
```kotlin
val keyParameter = call.requireParameter("key")
val key = runCatching { 
  enumValueOf<ConfigKey>(keyParameter)
}.getOrElse {
  call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid config key $keyParameter.", "Allowed keys are: ..."))
  return@get
}
```

You could also throw an exception that is mapped to `BadRequest` in `StatusPages.kt`, but this global error handling does not play well with the idea that component should be self-contained.
created_at: 2025-05-13T15:15:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.get
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireAuthenticated
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.getConfigByKey(db: Database) = get("admin/config/{key}", {
+    operationId = "GetConfigByKey"
+    summary = "Get the value of a configuration key"
+    description = "Get the value and isEnabled properties of a configuration key. " +
+        "If the key does not exist in the database, a default value is returned."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "Success"
+            body<Config> {
+                mediaTypes = setOf(ContentType.Application.Json)
+                example("Configuration values") {
+                    value = """
+                    {
+                        "isEnabled": false,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+                }
+            }
+        }
+
+        HttpStatusCode.InternalServerError to {
path: components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
user: login: Etsija
body: Trying to use `ErrorResponse` in the endpoint leads to a circular dependency warning, so I guess that will also break the self-containment requirement. Isn't core/api's `ErrorResponse` sitting in wrong location for the components to stay self-contained?
![Screenshot from 2025-05-14 06-19-10](https://github.com/user-attachments/assets/d8a482e1-b6a9-4482-8caa-e7bdb5f1771d)


created_at: 2025-05-14T03:20:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.get
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireAuthenticated
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.getConfigByKey(db: Database) = get("admin/config/{key}", {
+    operationId = "GetConfigByKey"
+    summary = "Get the value of a configuration key"
+    description = "Get the value and isEnabled properties of a configuration key. " +
+        "If the key does not exist in the database, a default value is returned."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "Success"
+            body<Config> {
+                mediaTypes = setOf(ContentType.Application.Json)
+                example("Configuration values") {
+                    value = """
+                    {
+                        "isEnabled": false,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+                }
+            }
+        }
+
+        HttpStatusCode.InternalServerError to {
path: components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
user: login: Etsija
body: I avoided the circular dependency problem by mapping the error response locally to a structure similar to the `ErrorResponse` class. Also verified that the error toast is rendered properly. While this isn't as elegant as using `ErrorResponse` directly for typing of the response, I hope it is sufficient for now.
created_at: 2025-05-14T04:37:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.github.smiley4.ktoropenapi.get
+
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.server.response.respond
+import io.ktor.server.routing.Route
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigKey
+import org.eclipse.apoapsis.ortserver.components.adminconfig.ConfigTable
+import org.eclipse.apoapsis.ortserver.components.authorization.requireAuthenticated
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireParameter
+
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.transactions.transaction
+
+fun Route.getConfigByKey(db: Database) = get("admin/config/{key}", {
+    operationId = "GetConfigByKey"
+    summary = "Get the value of a configuration key"
+    description = "Get the value and isEnabled properties of a configuration key. " +
+        "If the key does not exist in the database, a default value is returned."
+    tags = listOf("Admin")
+
+    request {
+        pathParameter<ConfigKey>("key") {
+            description = "The configuration key."
+            required = true
+        }
+    }
+
+    response {
+        HttpStatusCode.OK to {
+            description = "Success"
+            body<Config> {
+                mediaTypes = setOf(ContentType.Application.Json)
+                example("Configuration values") {
+                    value = """
+                    {
+                        "isEnabled": false,
+                        "value": "http://example.com/icon.png"
+                    }
+                """.trimIndent()
+                }
+            }
+        }
+
+        HttpStatusCode.InternalServerError to {
path: components/admin-config/backend/src/main/kotlin/endpoints/GetConfigByKey.kt
user: login: mnonnenmacher
body: I would suggest to move `ErrorResponse` to `:shared:ktor-utils` but it can be done in a follow-up.
created_at: 2025-05-14T06:00:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
+ *
+ * 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.adminconfig.endpoints
+
+import io.kotest.assertions.ktor.client.shouldHaveStatus
+import io.kotest.matchers.shouldBe
+
+import io.ktor.client.call.body
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.server.auth.authenticate
+import io.ktor.server.routing.routing
+
+import org.eclipse.apoapsis.ortserver.components.adminconfig.Config
+import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest
+
+class GetConfigByKeyIntegrationTest : AbstractIntegrationTest({
+    "GetConfigByKey" should {
+        "return the value and enabled status of an existing configuration key" {
+            integrationTestApplication { client ->
+                application {
+                    routing {
+                        authenticate("test") {
+                            insertOrUpdateConfig(dbExtension.db)
+                            getConfigByKey(dbExtension.db)
+                        }
+                    }
+                }
+
+                // Insert a test configuration key to database
+                client.post("/admin/config/HOME_ICON_URL") {
+                    contentType(ContentType.Application.Json)
path: components/admin-config/backend/src/test/kotlin/endpoints/GetConfigByKeyIntegrationTest.kt
user: login: sschuberth
body: @mnonnenmacher, will you then also do the follow-up you've mentioned [here](https://github.com/eclipse-apoapsis/ort-server/pull/2696#discussion_r2088105104)? I just don't want to lose track of planned follow-ups.
created_at: 2025-05-14T06:59:47Z
author_association: CONTRIBUTOR

PR comments: