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": [