From 32bda6f55941332a14c681d792f84c64faa203c2 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sat, 8 Aug 2026 23:50:29 +0200 Subject: [PATCH] feat(backend): report repository visibility in the root listing The dashboard overview lists repositories with nothing but a name, so it cannot tell a public repository from a hidden or private one. The value is known at runtime but never left the backend. Entries of the root listing are now RepositoryDirectoryInfo and carry a visibility field. Every other directory keeps using SimpleDirectoryInfo, which has no visibility to report. The field is additive: name and type are unchanged, so clients written against the previous payload keep working. Nothing is leaked. Repositories the caller cannot access are dropped by RepositorySecurityProvider.canAccessRepository before the response is assembled, so an anonymous caller only ever learns that public repositories are public. Signed-off-by: TheMeinerLP --- .../RepositoryVisibilityIntegrationTest.kt | 128 ++++++++++++++++++ .../com/reposilite/maven/RepositoryService.kt | 11 +- .../maven/infrastructure/MavenApiEndpoints.kt | 17 +++ .../com/reposilite/storage/api/FileDetails.kt | 16 +++ .../com/reposilite/maven/MavenFacadeTest.kt | 36 +++++ .../maven/specification/MavenSpecification.kt | 14 ++ .../data/guides/developers/endpoints.md | 75 ++++++++++ 7 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 reposilite-backend/src/integration/kotlin/com/reposilite/maven/RepositoryVisibilityIntegrationTest.kt diff --git a/reposilite-backend/src/integration/kotlin/com/reposilite/maven/RepositoryVisibilityIntegrationTest.kt b/reposilite-backend/src/integration/kotlin/com/reposilite/maven/RepositoryVisibilityIntegrationTest.kt new file mode 100644 index 000000000..9f9b821fa --- /dev/null +++ b/reposilite-backend/src/integration/kotlin/com/reposilite/maven/RepositoryVisibilityIntegrationTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2023 dzikoysk + * + * 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 + * + * http://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. + */ + +@file:Suppress("FunctionName") + +package com.reposilite.maven + +import com.reposilite.RecommendedLocalSpecificationJunitExtension +import com.reposilite.ReposiliteSpecification +import com.reposilite.configuration.shared.SharedConfigurationFacade +import com.reposilite.maven.application.MavenSettings +import com.reposilite.maven.application.RepositorySettings +import com.reposilite.token.AccessTokenPermission.MANAGER +import com.reposilite.token.RoutePermission.READ +import kong.unirest.core.JsonNode +import kong.unirest.core.Unirest.get +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +private const val HIDDEN_REPOSITORY = "hidden" + +@ExtendWith(RecommendedLocalSpecificationJunitExtension::class) +internal class RepositoryVisibilityIntegrationTest : ReposiliteSpecification() { + + override fun overrideSharedConfiguration(sharedConfigurationFacade: SharedConfigurationFacade) { + sharedConfigurationFacade.getDomainSettings().update { settings -> + settings.copy( + repositories = settings.repositories + RepositorySettings( + id = HIDDEN_REPOSITORY, + visibility = RepositoryVisibility.HIDDEN + ) + ) + } + } + + @Test + fun `should describe only accessible repositories and their visibility`() { + // when: the root listing is requested without any credentials + val anonymousResponse = get("$base/api/maven/details").asJson() + + // then: only public repositories are returned, and each of them carries its visibility + assertThat(anonymousResponse.isSuccess).isTrue + assertThat(anonymousResponse.body.visibilities()).isEqualTo( + mapOf( + "releases" to "PUBLIC", + "snapshots" to "PUBLIC", + "proxied" to "PUBLIC", + "proxied-stored" to "PUBLIC", + "immutable" to "PUBLIC" + ) + ) + + // given: a manager token + val (managerName, managerSecret) = useAuth("manager-token", "manager-token-secret", listOf(MANAGER)) + + // when: the root listing is requested with that token + val managerResponse = get("$base/api/maven/details") + .basicAuth(managerName, managerSecret) + .asJson() + + // then: hidden and private repositories show up with their own visibility + assertThat(managerResponse.isSuccess).isTrue + assertThat(managerResponse.body.visibilities()).isEqualTo( + mapOf( + "releases" to "PUBLIC", + "snapshots" to "PUBLIC", + "private" to "PRIVATE", + "proxied" to "PUBLIC", + "proxied-stored" to "PUBLIC", + "immutable" to "PUBLIC", + HIDDEN_REPOSITORY to "HIDDEN" + ) + ) + } + + @Test + fun `should keep the directory shape of the listing untouched`() { + // when: the root listing is requested without any credentials + val response = get("$base/api/maven/details").asJson() + + // then: the entries a client written against the previous payload reads are still there + val root = response.body.`object` + assertThat(root.getString("name")).isEqualTo("/") + assertThat(root.getString("type")).isEqualTo("DIRECTORY") + + val releases = root.getJSONArray("files") + .let { files -> (0 until files.length()).map { files.getJSONObject(it) } } + .first { it.getString("name") == "releases" } + + assertThat(releases.getString("type")).isEqualTo("DIRECTORY") + assertThat(releases.getString("visibility")).isEqualTo("PUBLIC") + } + + @Test + fun `should not reveal a hidden repository to a token that cannot see it`() { + // given: a token scoped to an unrelated repository + val (name, secret) = useAuth("scoped", "scoped-secret", routes = mapOf("/releases" to READ)) + + // when: the root listing is requested with that token + val response = get("$base/api/maven/details") + .basicAuth(name, secret) + .asJson() + + // then: neither the hidden nor the private repository is part of the response + assertThat(response.isSuccess).isTrue + assertThat(response.body.visibilities().keys).doesNotContain(HIDDEN_REPOSITORY, "private") + } + + private fun JsonNode.visibilities(): Map = + `object`.getJSONArray("files") + .let { files -> (0 until files.length()).map { files.getJSONObject(it) } } + .associate { it.getString("name") to it.getString("visibility") } + +} diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/maven/RepositoryService.kt b/reposilite-backend/src/main/kotlin/com/reposilite/maven/RepositoryService.kt index ac754629e..1b85724c6 100644 --- a/reposilite-backend/src/main/kotlin/com/reposilite/maven/RepositoryService.kt +++ b/reposilite-backend/src/main/kotlin/com/reposilite/maven/RepositoryService.kt @@ -39,7 +39,7 @@ import com.reposilite.storage.api.DocumentInfo import com.reposilite.storage.api.FileDetails import com.reposilite.storage.api.FileType.DIRECTORY import com.reposilite.storage.api.Location -import com.reposilite.storage.api.SimpleDirectoryInfo +import com.reposilite.storage.api.RepositoryDirectoryInfo import com.reposilite.token.AccessTokenIdentifier import io.javalin.http.HttpStatus.CONFLICT import panda.std.Result @@ -187,10 +187,17 @@ internal class RepositoryService( } } + /** + * Lists the repositories the given token is allowed to see, each with its visibility. + * + * Exposing the visibility leaks nothing: entries the caller may not reach are dropped by + * [RepositorySecurityProvider.canAccessRepository] before the response is assembled, so an + * anonymous caller only ever learns that public repositories are public. + */ fun getRootDirectory(accessToken: AccessTokenIdentifier?): DirectoryInfo = repositoryProvider.getRepositories() .filter { securityProvider.canAccessRepository(accessToken, it) } - .map { SimpleDirectoryInfo(it.name) } + .map { RepositoryDirectoryInfo(name = it.name, visibility = it.visibility) } .let { DirectoryInfo("/", it) } override fun getLogger(): Logger = diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/maven/infrastructure/MavenApiEndpoints.kt b/reposilite-backend/src/main/kotlin/com/reposilite/maven/infrastructure/MavenApiEndpoints.kt index 8e4e125dd..41c59afcb 100644 --- a/reposilite-backend/src/main/kotlin/com/reposilite/maven/infrastructure/MavenApiEndpoints.kt +++ b/reposilite-backend/src/main/kotlin/com/reposilite/maven/infrastructure/MavenApiEndpoints.kt @@ -24,6 +24,7 @@ import com.reposilite.maven.api.VersionsResponse import com.reposilite.maven.api.PomDetails import com.reposilite.shared.ContextDsl import com.reposilite.shared.ErrorResponse +import com.reposilite.storage.api.DirectoryInfo import com.reposilite.storage.api.FileDetails import com.reposilite.storage.api.Location import com.reposilite.web.api.ReposiliteRoute @@ -79,6 +80,22 @@ internal class MavenApiEndpoints(mavenFacade: MavenFacade) : MavenRoutes(mavenFa } } + @OpenApi( + tags = ["Maven"], + path = "/api/maven/details", + methods = [HttpMethod.GET], + summary = "List the available repositories", + description = "Get the repositories the requesting token is allowed to see as JSON response. " + + "Every entry is a RepositoryDirectoryInfo and carries the repository visibility (PUBLIC, HIDDEN or PRIVATE) " + + "in addition to the fields of a regular directory. Repositories the token cannot access are not listed at all.", + responses = [ + OpenApiResponse( + status = "200", + description = "Returns a directory listing where every file entry describes one accessible repository", + content = [OpenApiContent(from = DirectoryInfo::class)] + ) + ] + ) private val findRepositories = ReposiliteRoute("/api/maven/details", GET, handler = findFileDetails) private val findRepository = ReposiliteRoute("/api/maven/details/{repository}", GET, handler = findFileDetails) private val findInRepository = ReposiliteRoute("/api/maven/details/{repository}/", GET, handler = findFileDetails) diff --git a/reposilite-backend/src/main/kotlin/com/reposilite/storage/api/FileDetails.kt b/reposilite-backend/src/main/kotlin/com/reposilite/storage/api/FileDetails.kt index 5c4716148..9279a14a7 100644 --- a/reposilite-backend/src/main/kotlin/com/reposilite/storage/api/FileDetails.kt +++ b/reposilite-backend/src/main/kotlin/com/reposilite/storage/api/FileDetails.kt @@ -15,6 +15,7 @@ */ package com.reposilite.storage.api +import com.reposilite.maven.RepositoryVisibility import com.reposilite.storage.api.FileType.DIRECTORY import com.reposilite.storage.api.FileType.FILE import io.javalin.http.ContentType @@ -54,6 +55,21 @@ class SimpleDirectoryInfo( name: String, ) : AbstractDirectoryInfo(name) +/** + * A directory that stands for a whole repository, used by the root listing of `/api/maven/details`. + * + * [SimpleDirectoryInfo] describes every other directory, so it deliberately carries no visibility: + * a folder inside a repository has none. This type exists so the repository level can expose one + * without putting a meaningless value on every entry of every other listing. + * + * Direct subclasses of the sealed [FileDetails] have to live in this package, which is why a + * repository concept is declared next to the storage ones. + */ +class RepositoryDirectoryInfo( + name: String, + val visibility: RepositoryVisibility, +) : AbstractDirectoryInfo(name) + class DirectoryInfo( name: String, val files: List diff --git a/reposilite-backend/src/test/kotlin/com/reposilite/maven/MavenFacadeTest.kt b/reposilite-backend/src/test/kotlin/com/reposilite/maven/MavenFacadeTest.kt index 3c43d5c45..30c833984 100644 --- a/reposilite-backend/src/test/kotlin/com/reposilite/maven/MavenFacadeTest.kt +++ b/reposilite-backend/src/test/kotlin/com/reposilite/maven/MavenFacadeTest.kt @@ -96,6 +96,42 @@ internal class MavenFacadeTest : MavenSpecification() { assertThat(availableRepositories).isEqualTo(listOf(PRIVATE.name, PUBLIC.name, "PROXIED", "PROXIED-LOOPBACK")) } + @Test + fun `should describe the visibility of every listed repository`() { + // when: repositories are requested without any credentials + val anonymousVisibilities = findRepositoryVisibilities(UNAUTHORIZED) + + // then: only public repositories are listed, and each of them says so + assertThat(anonymousVisibilities).isEqualTo( + mapOf( + PUBLIC.name to PUBLIC, + "PROXIED" to PUBLIC, + "PROXIED-LOOPBACK" to PUBLIC + ) + ) + + // given: a token that may see every repository + val manager = createManagerAccessToken("manager", "manager-secret") + + // when: repositories are requested with that token + val managerVisibilities = findRepositoryVisibilities(manager) + + // then: hidden and private repositories are listed with their own visibility + assertThat(managerVisibilities).isEqualTo( + mapOf( + PRIVATE.name to PRIVATE, + HIDDEN.name to HIDDEN, + PUBLIC.name to PUBLIC, + "PROXIED" to PUBLIC, + "PROXIED-LOOPBACK" to PUBLIC, + "PROXIED-PULL-AUTHED" to HIDDEN, + "PROXIED-DEFAULT-EXTENSIONS" to HIDDEN, + "PROXIED-ALL-EXTENSIONS" to HIDDEN, + "PROXIED-BLANK-EXTENSIONS" to HIDDEN + ) + ) + } + @ParameterizedTest @EnumSource(value = RepositoryVisibility::class, names = [ "PUBLIC", "HIDDEN" ]) fun `should find requested details without credentials in public and hidden repositories`(visibility: RepositoryVisibility) { diff --git a/reposilite-backend/src/test/kotlin/com/reposilite/maven/specification/MavenSpecification.kt b/reposilite-backend/src/test/kotlin/com/reposilite/maven/specification/MavenSpecification.kt index c8e6bed1d..db9d67426 100644 --- a/reposilite-backend/src/test/kotlin/com/reposilite/maven/specification/MavenSpecification.kt +++ b/reposilite-backend/src/test/kotlin/com/reposilite/maven/specification/MavenSpecification.kt @@ -22,6 +22,7 @@ import com.reposilite.frontend.application.FrontendSettings import com.reposilite.journalist.backend.InMemoryLogger import com.reposilite.maven.MavenFacade import com.reposilite.maven.Repository +import com.reposilite.maven.RepositoryVisibility import com.reposilite.maven.api.LookupRequest import com.reposilite.maven.api.Metadata import com.reposilite.maven.api.SaveMetadataRequest @@ -43,8 +44,10 @@ import com.reposilite.status.application.FailureComponents import com.reposilite.storage.StorageFacade import com.reposilite.storage.api.DocumentInfo import com.reposilite.storage.api.Location +import com.reposilite.storage.api.RepositoryDirectoryInfo import com.reposilite.storage.api.toLocation import com.reposilite.token.AccessTokenIdentifier +import com.reposilite.token.AccessTokenPermission import com.reposilite.token.AccessTokenType.TEMPORARY import com.reposilite.token.Route import com.reposilite.token.RoutePermission @@ -189,6 +192,11 @@ internal abstract class MavenSpecification { protected fun findRepositories(accessToken: AccessTokenIdentifier?): Collection = mavenFacade.findRepositories(accessToken).files.map { it.name } + protected fun findRepositoryVisibilities(accessToken: AccessTokenIdentifier?): Map = + mavenFacade.findRepositories(accessToken).files + .filterIsInstance() + .associate { it.name to it.visibility } + protected fun addFileToRepository(fileSpec: FileSpec): FileSpec { workingDirectory.toPath() .resolve("repositories") @@ -209,6 +217,12 @@ internal abstract class MavenSpecification { .also { accessTokenFacade.addRoute(it.identifier, Route("/$repository/${gav.toLocation()}", permission)) } .identifier + protected fun createManagerAccessToken(name: String, secret: String): AccessTokenIdentifier = + accessTokenFacade.createAccessToken(CreateAccessTokenRequest(TEMPORARY, name, secret = secret)) + .accessToken + .also { accessTokenFacade.addPermission(it.identifier, AccessTokenPermission.MANAGER) } + .identifier + private fun String.isAllowed(): Boolean = this.endsWith("/allow") diff --git a/reposilite-site/data/guides/developers/endpoints.md b/reposilite-site/data/guides/developers/endpoints.md index 2e0ea23af..30ddb84c5 100644 --- a/reposilite-site/data/guides/developers/endpoints.md +++ b/reposilite-site/data/guides/developers/endpoints.md @@ -193,6 +193,30 @@ Raw JSON scheme: "security": [] } }, + "/api/maven/details": { + "get": { + "tags": [ + "Maven" + ], + "summary": "List the available repositories", + "description": "Get the repositories the requesting token is allowed to see as JSON response. Every entry is a RepositoryDirectoryInfo and carries the repository visibility (PUBLIC, HIDDEN or PRIVATE) in addition to the fields of a regular directory. Repositories the token cannot access are not listed at all.", + "parameters": [], + "responses": { + "200": { + "description": "Returns a directory listing where every file entry describes one accessible repository", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryInfo" + } + } + } + } + }, + "deprecated": false, + "security": [] + } + }, "/api/maven/details/{repository}/{gav}": { "get": { "tags": [ @@ -1433,6 +1457,29 @@ Raw JSON scheme: "threads" ] }, + "DirectoryInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/components/schemas/FileType" + }, + "name": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDetails" + } + } + }, + "required": [ + "type", + "name", + "files" + ] + }, "FileDetails": { "type": "object", "additionalProperties": false, @@ -1449,6 +1496,34 @@ Raw JSON scheme: "name" ] }, + "RepositoryDirectoryInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/components/schemas/FileType" + }, + "name": { + "type": "string" + }, + "visibility": { + "$ref": "#/components/schemas/RepositoryVisibility" + } + }, + "required": [ + "type", + "name", + "visibility" + ] + }, + "RepositoryVisibility": { + "type": "string", + "enum": [ + "PUBLIC", + "HIDDEN", + "PRIVATE" + ] + }, "FileType": { "type": "string", "enum": [