Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<MavenSettings>().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<String, String> =
`object`.getJSONArray("files")
.let { files -> (0 until files.length()).map { files.getJSONObject(it) } }
.associate { it.getString("name") to it.getString("visibility") }

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}/<gav>", GET, handler = findFileDetails)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<FileDetails>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -189,6 +192,11 @@ internal abstract class MavenSpecification {
protected fun findRepositories(accessToken: AccessTokenIdentifier?): Collection<String> =
mavenFacade.findRepositories(accessToken).files.map { it.name }

protected fun findRepositoryVisibilities(accessToken: AccessTokenIdentifier?): Map<String, RepositoryVisibility> =
mavenFacade.findRepositories(accessToken).files
.filterIsInstance<RepositoryDirectoryInfo>()
.associate { it.name to it.visibility }

protected fun addFileToRepository(fileSpec: FileSpec): FileSpec {
workingDirectory.toPath()
.resolve("repositories")
Expand All @@ -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")

Expand Down
Loading
Loading