diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/MinestomPermissionSnapshotLoaderTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/MinestomPermissionSnapshotLoaderTest.kt index 6d21543..fe2a03e 100644 --- a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/MinestomPermissionSnapshotLoaderTest.kt +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/MinestomPermissionSnapshotLoaderTest.kt @@ -24,7 +24,7 @@ class MinestomPermissionSnapshotLoaderTest { client = FakeClient(PermissionSnapshotFetchResult.Success(snapshot)), ) - val result = loader.loadSnapshot(playerId, "lukas") + val result = loader.loadSnapshot(playerId, "Alex") assertTrue(result.allowed) assertEquals(snapshot, result.snapshot) @@ -42,7 +42,7 @@ class MinestomPermissionSnapshotLoaderTest { client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable")), ) - val result = loader.loadSnapshot(playerId, "lukas") + val result = loader.loadSnapshot(playerId, "Alex") assertTrue(result.allowed) assertEquals(snapshot, result.snapshot) @@ -54,7 +54,7 @@ class MinestomPermissionSnapshotLoaderTest { val loader = loader(client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable"))) - val result = loader.loadSnapshot(playerId, "lukas") + val result = loader.loadSnapshot(playerId, "Alex") assertFalse(result.allowed) } @@ -70,7 +70,7 @@ class MinestomPermissionSnapshotLoaderTest { client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable")), ) - val result = loader.loadSnapshot(playerId, "lukas") + val result = loader.loadSnapshot(playerId, "Alex") assertFalse(result.allowed) } @@ -82,7 +82,7 @@ class MinestomPermissionSnapshotLoaderTest { val context = PermissionSnapshotContext(serverType = "arena", serverId = "arena-1") val loader = loader(client = client, context = context) - loader.loadSnapshot(playerId, "lukas") + loader.loadSnapshot(playerId, "Alex") assertSame(context, client.lastContext) } diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt index e138eaa..ef0ce55 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt @@ -1,6 +1,7 @@ package gg.grounds.permissions.velocity import com.google.inject.Inject +import com.velocitypowered.api.command.CommandMeta import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.proxy.ProxyInitializeEvent import com.velocitypowered.api.event.proxy.ProxyShutdownEvent @@ -10,6 +11,7 @@ import com.velocitypowered.api.proxy.ProxyServer import gg.grounds.BuildInfo import gg.grounds.permissions.InMemoryPermissionSnapshots import gg.grounds.permissions.PermissionCheckScope +import gg.grounds.permissions.SnapshotPermissions import io.grpc.LoadBalancerRegistry import io.grpc.NameResolverRegistry import io.grpc.internal.DnsNameResolverProvider @@ -33,6 +35,8 @@ constructor( @param:DataDirectory private val dataDirectory: Path, ) { private var client: PermissionSnapshotClient? = null + private var catalogClient: PermissionCatalogClient? = null + private var commandMeta: CommandMeta? = null init { logger.info("Initialized plugin (plugin=plugin-permissions, version={})", BuildInfo.VERSION) @@ -44,18 +48,72 @@ constructor( val config = VelocityPermissionsConfig.fromEnvironment(System.getenv()) val snapshotClient = GrpcPermissionSnapshotClient.create(config.grpcTarget) + val manifestClient = GrpcPermissionCatalogClient.create(config.grpcTarget) client = snapshotClient + catalogClient = manifestClient - proxy.eventManager.register( - this, + val snapshots = InMemoryPermissionSnapshots() + val listener = PermissionLoginListener( logger = logger, - snapshots = InMemoryPermissionSnapshots(), + snapshots = snapshots, cache = SnapshotDiskCache(logger, dataDirectory.resolve("snapshots")), client = snapshotClient, context = config.context, - ), - ) + ) + proxy.eventManager.register(this, listener) + + val permissions = + SnapshotPermissions(snapshots, defaultScope = config.context.toCheckScope()) + val manifest = PermissionManifest.loadResource() + val commandPermissions = PermissionCommandPermissions.fromManifest(manifest) + val router = + PermissionCommandRouter( + service = + PermissionCommandService( + snapshots = snapshots, + permissions = permissions, + refreshSnapshot = listener::loadSnapshot, + status = + PermissionCommandStatus( + version = BuildInfo.VERSION, + grpcTarget = config.grpcTarget, + context = config.context, + ), + ), + findOnlinePlayer = { identifier -> + VelocityPermissionsCommand.findOnlinePlayer(proxy, identifier) + }, + onlinePlayers = { VelocityPermissionsCommand.onlinePlayers(proxy) }, + defaultScope = + PermissionCheckScopeArgument( + serverType = config.context.serverType, + server = config.context.serverId, + ), + ) + val command = + VelocityPermissionsCommand( + plugin = this, + proxy = proxy, + router = router, + commandPermissions = commandPermissions, + isAuthorized = { source, permission -> + VelocityPermissionsCommand.isPlayerAuthorized(source, permissions, permission) + }, + ) + val commandMeta = + proxy.commandManager.metaBuilder("permissions").aliases("perm").plugin(this).build() + proxy.commandManager.register(commandMeta, command) + this.commandMeta = commandMeta + + logger.info("Registered permissions commands successfully (root=permissions, alias=perm)") + + proxy.scheduler + .buildTask( + this, + Runnable { registerPermissionManifest(manifestClient, manifest, config) }, + ) + .schedule() logger.info( "Configured permissions snapshot client (target={}, serverType={}, serverId={})", @@ -67,14 +125,46 @@ constructor( @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + commandMeta?.let(proxy.commandManager::unregister) + commandMeta = null client?.close() client = null + catalogClient?.close() + catalogClient = null } private fun registerProviders() { NameResolverRegistry.getDefaultRegistry().register(DnsNameResolverProvider()) LoadBalancerRegistry.getDefaultRegistry().register(PickFirstLoadBalancerProvider()) } + + private fun registerPermissionManifest( + manifestClient: PermissionCatalogClient, + manifest: PermissionManifest, + config: VelocityPermissionsConfig, + ) { + when ( + val result = + manifestClient.register( + manifest = manifest, + source = "plugin-permissions", + sourceVersion = BuildInfo.VERSION, + context = config.context, + ) + ) { + PermissionManifestRegistrationResult.Accepted -> + logger.info( + "Registered permission catalog manifest successfully (source=plugin-permissions, version={}, permissionCount={})", + BuildInfo.VERSION, + manifest.permissions.size, + ) + is PermissionManifestRegistrationResult.Unavailable -> + logger.warn( + "Failed to register permission catalog manifest (source=plugin-permissions, reason={})", + result.reason, + ) + } + } } data class VelocityPermissionsConfig( diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt new file mode 100644 index 0000000..198af29 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt @@ -0,0 +1,106 @@ +package gg.grounds.permissions.velocity + +import gg.grounds.grpc.permissions.PermissionCatalogServiceGrpc +import gg.grounds.grpc.permissions.PermissionManifestEntry as GrpcPermissionManifestEntry +import gg.grounds.grpc.permissions.PermissionScopeKind +import gg.grounds.grpc.permissions.RegisterPermissionManifestRequest +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Status +import io.grpc.StatusRuntimeException +import java.util.concurrent.TimeUnit + +sealed interface PermissionManifestRegistrationResult { + data object Accepted : PermissionManifestRegistrationResult + + data class Unavailable(val reason: String) : PermissionManifestRegistrationResult +} + +interface PermissionCatalogClient : AutoCloseable { + fun register( + manifest: PermissionManifest, + source: String, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult + + override fun close() {} +} + +class GrpcPermissionCatalogClient private constructor(private val channel: ManagedChannel) : + PermissionCatalogClient { + private val stub = PermissionCatalogServiceGrpc.newBlockingStub(channel) + + override fun register( + manifest: PermissionManifest, + source: String, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult = + try { + val reply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .registerPermissionManifest( + RegisterPermissionManifestRequest.newBuilder() + .setSource(source) + .setSourceVersion(sourceVersion) + .setServerType(context.serverType.orEmpty()) + .setServerId(context.serverId.orEmpty()) + .addAllPermissions( + manifest.permissions.map(PermissionManifestEntry::toGrpc) + ) + .build() + ) + if (reply.accepted) { + PermissionManifestRegistrationResult.Accepted + } else { + PermissionManifestRegistrationResult.Unavailable( + reply.message.ifBlank { "rejected" } + ) + } + } catch (e: StatusRuntimeException) { + PermissionManifestRegistrationResult.Unavailable(e.status.toSafeReason()) + } catch (e: RuntimeException) { + PermissionManifestRegistrationResult.Unavailable(e.message ?: e::class.java.name) + } + + override fun close() { + channel.shutdown() + try { + if (!channel.awaitTermination(3, TimeUnit.SECONDS)) { + channel.shutdownNow() + channel.awaitTermination(3, TimeUnit.SECONDS) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + channel.shutdownNow() + } + } + + companion object { + fun create(target: String): GrpcPermissionCatalogClient = + GrpcPermissionCatalogClient( + ManagedChannelBuilder.forTarget(target).usePlaintext().build() + ) + + private const val DEFAULT_TIMEOUT_MS = 2000L + } +} + +private fun PermissionManifestEntry.toGrpc(): GrpcPermissionManifestEntry = + GrpcPermissionManifestEntry.newBuilder() + .setKey(key) + .setLabel(label) + .setDescription(description) + .addAllSupportedScopes(supportedScopes.map(PermissionManifestScope::toGrpc)) + .build() + +private fun PermissionManifestScope.toGrpc(): PermissionScopeKind = + when (this) { + PermissionManifestScope.GLOBAL -> PermissionScopeKind.PERMISSION_SCOPE_KIND_GLOBAL + PermissionManifestScope.SERVER_TYPE -> PermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER_TYPE + PermissionManifestScope.SERVER -> PermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER + } + +private fun Status.toSafeReason(): String = code.toString() diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt new file mode 100644 index 0000000..14def9b --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt @@ -0,0 +1,45 @@ +package gg.grounds.permissions.velocity + +class PermissionCommandPermissions private constructor(private val nodes: Map) { + fun forArguments(arguments: Array): String = + when { + arguments.firstOrNull().equals("status", ignoreCase = true) -> node("status") + arguments.firstOrNull().equals("refresh", ignoreCase = true) -> node("refresh") + arguments.firstOrNull().equals("user", ignoreCase = true) && + arguments.getOrNull(2).equals("info", ignoreCase = true) -> node("user.info") + arguments.firstOrNull().equals("user", ignoreCase = true) && + arguments.getOrNull(2).equals("refresh", ignoreCase = true) -> node("user.refresh") + arguments.firstOrNull().equals("user", ignoreCase = true) && + arguments.getOrNull(2).equals("permission", ignoreCase = true) && + arguments.getOrNull(3).equals("check", ignoreCase = true) -> node("user.check") + else -> node("root") + } + + private fun node(key: String): String = + requireNotNull(nodes[key]) { "Missing command permission: $key" } + + companion object { + private val REQUIRED_KEYS = + setOf("root", "status", "user.info", "user.check", "user.refresh", "refresh") + + fun fromManifest(manifest: PermissionManifest): PermissionCommandPermissions { + val nodes = + manifest.permissions.map(PermissionManifestEntry::key).associateBy { key -> + when (key) { + "grounds.permissions.command" -> "root" + "grounds.permissions.command.status" -> "status" + "grounds.permissions.command.user.info" -> "user.info" + "grounds.permissions.command.user.check" -> "user.check" + "grounds.permissions.command.user.refresh" -> "user.refresh" + "grounds.permissions.command.refresh" -> "refresh" + else -> key + } + } + val missing = REQUIRED_KEYS - nodes.keys + require(missing.isEmpty()) { + "Missing required command permissions: ${missing.sorted().joinToString(",")}" + } + return PermissionCommandPermissions(nodes) + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandRouter.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandRouter.kt new file mode 100644 index 0000000..49563d5 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandRouter.kt @@ -0,0 +1,149 @@ +package gg.grounds.permissions.velocity + +import java.util.UUID + +class PermissionCommandRouter( + private val service: PermissionCommandService, + private val findOnlinePlayer: (String) -> OnlinePermissionPlayer?, + private val onlinePlayers: () -> Collection, + private val defaultScope: PermissionCheckScopeArgument = PermissionCheckScopeArgument.global(), +) { + fun execute(arguments: Array): PermissionCommandResult { + if (arguments.isEmpty()) { + return help() + } + + return when (arguments[0].lowercase()) { + "help" -> help() + "status" -> if (arguments.size == 1) service.status() else usageStatus() + "user" -> executeUser(arguments) + "refresh" -> executeRefresh(arguments) + else -> help() + } + } + + fun suggest(arguments: Array): List = + when { + arguments.isEmpty() -> ROOT_COMMANDS + arguments.size == 1 -> filterPrefix(ROOT_COMMANDS, arguments[0]) + arguments[0].equals("user", ignoreCase = true) && arguments.size == 2 -> + filterPrefix(playerNames(), arguments[1]) + arguments[0].equals("user", ignoreCase = true) && arguments.size == 3 -> + filterPrefix(USER_COMMANDS, arguments[2]) + arguments[0].equals("user", ignoreCase = true) && + arguments.size == 4 && + arguments[2].equals("permission", ignoreCase = true) -> + filterPrefix(PERMISSION_COMMANDS, arguments[3]) + arguments[0].equals("user", ignoreCase = true) && + arguments.size == 6 && + arguments[2].equals("permission", ignoreCase = true) && + arguments[3].equals("check", ignoreCase = true) -> + filterPrefix(SCOPE_ARGUMENTS, arguments[5]) + arguments[0].equals("refresh", ignoreCase = true) && arguments.size == 2 -> + filterPrefix(playerNames() + "all", arguments[1]) + else -> emptyList() + } + + private fun executeUser(arguments: Array): PermissionCommandResult { + if (arguments.size < 3) { + return usageUser() + } + + val player = findOnlinePlayer(arguments[1]) ?: return playerOffline(arguments[1]) + return when (arguments[2].lowercase()) { + "info" -> if (arguments.size == 3) service.info(player.playerId) else usageInfo() + "refresh" -> + if (arguments.size == 3) service.refresh(player.playerId, player.username) + else usageRefresh() + "permission" -> executePermissionCheck(player, arguments) + else -> usageUser() + } + } + + private fun executePermissionCheck( + player: OnlinePermissionPlayer, + arguments: Array, + ): PermissionCommandResult { + if (arguments.size !in 5..6 || !arguments[3].equals("check", ignoreCase = true)) { + return usagePermissionCheck() + } + + val scope = + if (arguments.size == 6) { + PermissionCheckScopeArgument.parseOrNull(arguments[5]) + ?: return PermissionCommandResult.Failure( + "Invalid permission scope: ${arguments[5]}" + ) + } else { + defaultScope + } + return service.check(player.playerId, arguments[4], scope) + } + + private fun executeRefresh(arguments: Array): PermissionCommandResult { + if (arguments.size != 2) { + return usageRefresh() + } + + if (arguments[1].equals("all", ignoreCase = true)) { + val results = onlinePlayers().map { service.refresh(it.playerId, it.username) } + return PermissionCommandResult.Success( + listOf( + "refreshed=${results.count { it is PermissionCommandResult.Success }}", + "failed=${results.count { it is PermissionCommandResult.Failure }}", + ) + ) + } + + val player = findOnlinePlayer(arguments[1]) ?: return playerOffline(arguments[1]) + return service.refresh(player.playerId, player.username) + } + + private fun playerNames(): List = + onlinePlayers() + .map(OnlinePermissionPlayer::username) + .sortedWith(String.CASE_INSENSITIVE_ORDER) + + private fun help(): PermissionCommandResult = + PermissionCommandResult.Success( + listOf( + "/perm status", + "/perm user info", + "/perm user permission check [scope]", + "/perm user refresh", + "/perm refresh ", + ) + ) + + private fun usageStatus(): PermissionCommandResult.Failure = + PermissionCommandResult.Failure("Usage: /perm status") + + private fun usageUser(): PermissionCommandResult.Failure = + PermissionCommandResult.Failure("Usage: /perm user ") + + private fun usageInfo(): PermissionCommandResult.Failure = + PermissionCommandResult.Failure("Usage: /perm user info") + + private fun usageRefresh(): PermissionCommandResult.Failure = + PermissionCommandResult.Failure("Usage: /perm refresh ") + + private fun usagePermissionCheck(): PermissionCommandResult.Failure = + PermissionCommandResult.Failure( + "Usage: /perm user permission check [scope]" + ) + + private fun playerOffline(name: String): PermissionCommandResult.Failure = + PermissionCommandResult.Failure("Player is not online: $name") + + private fun filterPrefix(values: List, prefix: String): List = + values.filter { it.startsWith(prefix, ignoreCase = true) } + + companion object { + private val ROOT_COMMANDS = listOf("help", "refresh", "status", "user") + private val USER_COMMANDS = listOf("info", "permission", "refresh") + private val PERMISSION_COMMANDS = listOf("check") + private val SCOPE_ARGUMENTS = listOf("global", "server=", "server-type=") + } +} + +data class OnlinePermissionPlayer(val playerId: UUID, val username: String) diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandService.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandService.kt new file mode 100644 index 0000000..0dff794 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandService.kt @@ -0,0 +1,112 @@ +package gg.grounds.permissions.velocity + +import gg.grounds.permissions.InMemoryPermissionSnapshots +import gg.grounds.permissions.PermissionCheckScope +import gg.grounds.permissions.Permissions +import java.util.UUID + +class PermissionCommandService( + private val snapshots: InMemoryPermissionSnapshots, + private val permissions: Permissions, + private val refreshSnapshot: (UUID, String) -> PermissionLoginResult, + private val status: PermissionCommandStatus, +) { + fun status(): PermissionCommandResult = + PermissionCommandResult.Success( + listOf( + "version=${status.version}", + "target=${status.grpcTarget}", + "serverType=${status.context.serverType ?: "none"}", + "serverId=${status.context.serverId ?: "none"}", + ) + ) + + fun info(playerId: UUID): PermissionCommandResult { + val snapshot = snapshots.get(playerId) ?: return noSnapshot() + return PermissionCommandResult.Success( + listOf( + "playerId=$playerId", + "policyVersion=${snapshot.policyVersion}", + "roles=${snapshot.roleKeys.sorted().joinToString(",")}", + "allow=${snapshot.allowPatterns.joinToString(",") { it.pattern }}", + "deny=${snapshot.denyPatterns.joinToString(",") { it.pattern }}", + ) + ) + } + + fun check( + playerId: UUID, + permission: String, + scope: PermissionCheckScopeArgument, + ): PermissionCommandResult { + if (snapshots.get(playerId) == null) { + return noSnapshot() + } + return PermissionCommandResult.Success( + listOf("allowed=${permissions.hasPermission(playerId, permission, scope.toDomain())}") + ) + } + + fun refresh(playerId: UUID, username: String): PermissionCommandResult { + val result = refreshSnapshot(playerId, username) + if (!result.allowed || result.snapshot == null) { + return PermissionCommandResult.Failure(result.message) + } + + snapshots.put(result.snapshot) + return PermissionCommandResult.Success( + listOf("policyVersion=${result.snapshot.policyVersion}") + ) + } + + private fun noSnapshot(): PermissionCommandResult.Failure = + PermissionCommandResult.Failure("No permission snapshot is loaded for this player.") +} + +data class PermissionCommandStatus( + val version: String, + val grpcTarget: String, + val context: PermissionSnapshotContext, +) + +sealed interface PermissionCommandResult { + data class Success(val lines: List) : PermissionCommandResult + + data class Failure(val message: String) : PermissionCommandResult +} + +data class PermissionCheckScopeArgument( + val serverType: String? = null, + val server: String? = null, +) { + fun toDomain(): PermissionCheckScope = + when { + server != null && serverType != null -> PermissionCheckScope.server(server, serverType) + server != null -> PermissionCheckScope.serverOnly(server) + serverType != null -> PermissionCheckScope.serverType(serverType) + else -> PermissionCheckScope.global() + } + + companion object { + fun global(): PermissionCheckScopeArgument = PermissionCheckScopeArgument() + + fun parse(value: String): PermissionCheckScopeArgument = + requireNotNull(parseOrNull(value)) { "Invalid permission scope: $value" } + + fun parseOrNull(value: String): PermissionCheckScopeArgument? = + when { + value == "global" -> global() + value.startsWith("server-type=") -> + value + .removePrefix("server-type=") + .takeIf { it.isNotBlank() } + ?.let(::PermissionCheckScopeArgument) + value.startsWith("server=") -> + value + .removePrefix("server=") + .takeIf { it.isNotBlank() } + ?.let { server -> PermissionCheckScopeArgument(server = server) } + else -> null + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt new file mode 100644 index 0000000..a22ab21 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt @@ -0,0 +1,63 @@ +package gg.grounds.permissions.velocity + +import java.io.InputStream +import tools.jackson.databind.DeserializationFeature +import tools.jackson.databind.ObjectMapper +import tools.jackson.databind.json.JsonMapper +import tools.jackson.module.kotlin.KotlinModule + +data class PermissionManifest(val permissions: List) { + init { + require(permissions.isNotEmpty()) { + "Permission manifest must declare at least one permission." + } + require(permissions.map(PermissionManifestEntry::key).toSet().size == permissions.size) { + "Permission manifest must not declare duplicate keys." + } + } + + companion object { + private const val RESOURCE_NAME = "META-INF/grounds-permissions.json" + private val mapper: ObjectMapper = + JsonMapper.builder() + .addModule(KotlinModule.Builder().build()) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build() + + fun loadResource(): PermissionManifest { + val input = + PermissionManifest::class.java.classLoader.getResourceAsStream(RESOURCE_NAME) + ?: error("Missing required permission manifest resource $RESOURCE_NAME") + return input.use(::load) + } + + fun load(input: InputStream): PermissionManifest = + mapper.readValue(input, PermissionManifest::class.java).validated() + } + + private fun validated(): PermissionManifest { + permissions.forEach { permission -> + require(permission.key.isNotBlank()) { "Permission manifest keys must not be blank." } + require(permission.label.isNotBlank()) { + "Permission manifest labels must not be blank." + } + require(permission.supportedScopes.isNotEmpty()) { + "Permission manifest scopes must not be empty (key=${permission.key})." + } + } + return this + } +} + +data class PermissionManifestEntry( + val key: String, + val label: String, + val description: String = "", + val supportedScopes: List, +) + +enum class PermissionManifestScope { + GLOBAL, + SERVER_TYPE, + SERVER, +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/VelocityPermissionsCommand.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/VelocityPermissionsCommand.kt new file mode 100644 index 0000000..b5f6fee --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/VelocityPermissionsCommand.kt @@ -0,0 +1,67 @@ +package gg.grounds.permissions.velocity + +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.command.SimpleCommand +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ProxyServer +import java.util.UUID +import net.kyori.adventure.text.Component + +class VelocityPermissionsCommand( + private val plugin: Any, + private val proxy: ProxyServer, + private val router: PermissionCommandRouter, + private val commandPermissions: PermissionCommandPermissions, + private val isAuthorized: (CommandSource, String) -> Boolean, +) : SimpleCommand { + override fun execute(invocation: SimpleCommand.Invocation) { + val source = invocation.source() + proxy.scheduler + .buildTask( + plugin, + Runnable { sendResult(source, router.execute(invocation.arguments())) }, + ) + .schedule() + } + + override fun suggest(invocation: SimpleCommand.Invocation): List = + router.suggest(invocation.arguments()) + + override fun hasPermission(invocation: SimpleCommand.Invocation): Boolean = + isAuthorized(invocation.source(), commandPermissions.forArguments(invocation.arguments())) + + private fun sendResult(source: CommandSource, result: PermissionCommandResult) { + when (result) { + is PermissionCommandResult.Success -> + result.lines.forEach { source.sendMessage(Component.text(it)) } + is PermissionCommandResult.Failure -> source.sendMessage(Component.text(result.message)) + } + } + + companion object { + fun findOnlinePlayer(proxy: ProxyServer, identifier: String): OnlinePermissionPlayer? { + val player = + runCatching { UUID.fromString(identifier) } + .getOrNull() + ?.let { proxy.getPlayer(it).orElse(null) } + ?: proxy.getPlayer(identifier).orElse(null) + ?: return null + return OnlinePermissionPlayer(player.uniqueId, player.username) + } + + fun onlinePlayers(proxy: ProxyServer): Collection = + proxy.allPlayers.map { player -> + OnlinePermissionPlayer(player.uniqueId, player.username) + } + + fun isPlayerAuthorized( + source: CommandSource, + permissions: gg.grounds.permissions.Permissions, + permission: String, + ): Boolean = + when (source) { + is Player -> permissions.hasPermission(source.uniqueId, permission) + else -> true + } + } +} diff --git a/velocity/src/main/resources/META-INF/grounds-permissions.json b/velocity/src/main/resources/META-INF/grounds-permissions.json new file mode 100644 index 0000000..16dea99 --- /dev/null +++ b/velocity/src/main/resources/META-INF/grounds-permissions.json @@ -0,0 +1,40 @@ +{ + "permissions": [ + { + "key": "grounds.permissions.command", + "label": "Permissions commands", + "description": "Allows access to the permissions command help.", + "supportedScopes": ["GLOBAL"] + }, + { + "key": "grounds.permissions.command.status", + "label": "Permissions status", + "description": "Allows viewing the permissions plugin status.", + "supportedScopes": ["GLOBAL"] + }, + { + "key": "grounds.permissions.command.user.info", + "label": "Player permission snapshot info", + "description": "Allows viewing a loaded player permission snapshot.", + "supportedScopes": ["GLOBAL"] + }, + { + "key": "grounds.permissions.command.user.check", + "label": "Player permission check", + "description": "Allows checking a loaded player's effective permission.", + "supportedScopes": ["GLOBAL"] + }, + { + "key": "grounds.permissions.command.user.refresh", + "label": "Player permission snapshot refresh", + "description": "Allows refreshing one online player's permission snapshot.", + "supportedScopes": ["GLOBAL"] + }, + { + "key": "grounds.permissions.command.refresh", + "label": "Permission snapshot refresh", + "description": "Allows refreshing one or all online player permission snapshots.", + "supportedScopes": ["GLOBAL"] + } + ] +} diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt new file mode 100644 index 0000000..76366cd --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt @@ -0,0 +1,336 @@ +package gg.grounds.permissions.velocity + +import gg.grounds.permissions.InMemoryPermissionSnapshots +import gg.grounds.permissions.PermissionEffect +import gg.grounds.permissions.PermissionGrant +import gg.grounds.permissions.PermissionGrantSource +import gg.grounds.permissions.PermissionScope +import gg.grounds.permissions.PermissionSnapshot +import gg.grounds.permissions.RoleMetadata +import gg.grounds.permissions.SnapshotPermissions +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PermissionCommandServiceTest { + private val now: Instant = Instant.parse("2026-07-09T10:00:00Z") + private val clock: Clock = Clock.fixed(now, ZoneOffset.UTC) + private val playerId: UUID = UUID.fromString("f39f6265-f481-4c90-8dd8-4d334b82f937") + private val snapshots = InMemoryPermissionSnapshots() + private val permissions = SnapshotPermissions(snapshots, clock = clock) + + @Test + fun `status returns the configured runtime details`() { + val service = service() + + val result = service.status() + + assertEquals( + PermissionCommandResult.Success( + listOf( + "version=0.1.0", + "target=permissions:9090", + "serverType=lobby", + "serverId=proxy-1", + ) + ), + result, + ) + } + + @Test + fun `info returns loaded snapshot details`() { + snapshots.put(snapshot(roleKeys = setOf("moderator"))) + val service = service() + + val result = service.info(playerId) + + val success = result as? PermissionCommandResult.Success + assertTrue(success != null) + assertEquals( + listOf( + "playerId=$playerId", + "policyVersion=7", + "roles=moderator", + "allow=chat.send", + "deny=chat.moderation.*", + ), + success!!.lines, + ) + } + + @Test + fun `check evaluates the loaded snapshot at the requested scope`() { + snapshots.put(snapshot()) + val service = service() + + val result = service.check(playerId, "chat.send", PermissionCheckScopeArgument.global()) + + assertEquals(PermissionCommandResult.Success(listOf("allowed=true")), result) + } + + @Test + fun `check rejects a permission overridden by a deny grant`() { + snapshots.put(snapshot()) + val service = service() + + val result = + service.check(playerId, "chat.moderation.kick", PermissionCheckScopeArgument.global()) + + assertEquals(PermissionCommandResult.Success(listOf("allowed=false")), result) + } + + @Test + fun `check reports a player without a loaded snapshot`() { + val service = service() + + val result = service.check(playerId, "chat.send", PermissionCheckScopeArgument.global()) + + assertEquals( + PermissionCommandResult.Failure("No permission snapshot is loaded for this player."), + result, + ) + } + + @Test + fun `refresh reloads and stores the latest snapshot`() { + val refreshed = snapshot(policyVersion = 8) + val service = service { _, _ -> PermissionLoginResult.allowed(refreshed) } + + val result = service.refresh(playerId, "Alex") + + assertEquals(PermissionCommandResult.Success(listOf("policyVersion=8")), result) + assertEquals(refreshed, snapshots.get(playerId)) + } + + @Test + fun `refresh reports unavailable snapshots`() { + val service = service { _, _ -> PermissionLoginResult.denied() } + + val result = service.refresh(playerId, "Alex") + + assertEquals( + PermissionCommandResult.Failure( + "Permissions are currently unavailable. Please try again later." + ), + result, + ) + } + + @Test + fun `scope parser accepts global server type and server scopes`() { + assertEquals( + PermissionCheckScopeArgument.global(), + PermissionCheckScopeArgument.parse("global"), + ) + assertEquals( + PermissionCheckScopeArgument(serverType = "lobby"), + PermissionCheckScopeArgument.parse("server-type=lobby"), + ) + assertEquals( + PermissionCheckScopeArgument(server = "lobby-1"), + PermissionCheckScopeArgument.parse("server=lobby-1"), + ) + } + + @Test + fun `scope parser rejects invalid values`() { + assertFalse(PermissionCheckScopeArgument.parseOrNull("world=lobby") != null) + } + + @Test + fun `router resolves an online player for snapshot info`() { + snapshots.put(snapshot(roleKeys = setOf("moderator"))) + val router = router() + + val result = router.execute(arrayOf("user", "Alex", "info")) + + assertEquals( + PermissionCommandResult.Success( + listOf( + "playerId=$playerId", + "policyVersion=7", + "roles=moderator", + "allow=chat.send", + "deny=chat.moderation.*", + ) + ), + result, + ) + } + + @Test + fun `router rejects a player who is not online`() { + val router = router() + + val result = router.execute(arrayOf("user", "Ada", "info")) + + assertEquals(PermissionCommandResult.Failure("Player is not online: Ada"), result) + } + + @Test + fun `router refreshes every online player`() { + val secondPlayer = OnlinePermissionPlayer(UUID.randomUUID(), "Ada") + val refreshed = snapshot(policyVersion = 9) + val router = + router( + players = listOf(OnlinePermissionPlayer(playerId, "Alex"), secondPlayer), + refresh = { id, _ -> + if (id == playerId) PermissionLoginResult.allowed(refreshed) + else PermissionLoginResult.denied() + }, + ) + + val result = router.execute(arrayOf("refresh", "all")) + + assertEquals(PermissionCommandResult.Success(listOf("refreshed=1", "failed=1")), result) + assertEquals(refreshed, snapshots.get(playerId)) + } + + @Test + fun `router reports syntax for unsupported commands`() { + val router = router() + + val result = router.execute(arrayOf("user", "Alex", "permission", "set", "chat.send")) + + assertEquals( + PermissionCommandResult.Failure( + "Usage: /perm user permission check [scope]" + ), + result, + ) + } + + @Test + fun `router suggests commands players and scope values`() { + val router = router() + + assertEquals(listOf("help", "refresh", "status", "user"), router.suggest(emptyArray())) + assertEquals(listOf("Alex"), router.suggest(arrayOf("user", "al"))) + assertEquals( + listOf("info", "permission", "refresh"), + router.suggest(arrayOf("user", "Alex", "")), + ) + assertEquals( + listOf("global", "server=", "server-type="), + router.suggest(arrayOf("user", "Alex", "permission", "check", "chat.send", "")), + ) + assertEquals(listOf("Alex", "all"), router.suggest(arrayOf("refresh", ""))) + } + + @Test + fun `command permissions map every supported command path`() { + val manifest = + PermissionManifest.load( + """ + { + "permissions": [ + { "key": "grounds.permissions.command", "label": "Permissions commands", "description": "Allows command help.", "supportedScopes": ["GLOBAL"] }, + { "key": "grounds.permissions.command.status", "label": "Permissions status", "description": "Allows status.", "supportedScopes": ["GLOBAL"] }, + { "key": "grounds.permissions.command.user.info", "label": "Player snapshot info", "description": "Allows info.", "supportedScopes": ["GLOBAL"] }, + { "key": "grounds.permissions.command.user.check", "label": "Player permission check", "description": "Allows checks.", "supportedScopes": ["GLOBAL"] }, + { "key": "grounds.permissions.command.user.refresh", "label": "Player snapshot refresh", "description": "Allows player refresh.", "supportedScopes": ["GLOBAL"] }, + { "key": "grounds.permissions.command.refresh", "label": "Snapshot refresh", "description": "Allows bulk refresh.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + .byteInputStream() + ) + val permissions = PermissionCommandPermissions.fromManifest(manifest) + + assertEquals("grounds.permissions.command", permissions.forArguments(emptyArray())) + assertEquals( + "grounds.permissions.command.status", + permissions.forArguments(arrayOf("status")), + ) + assertEquals( + "grounds.permissions.command.user.check", + permissions.forArguments(arrayOf("user", "Alex", "permission", "check", "chat.send")), + ) + assertEquals( + "grounds.permissions.command.refresh", + permissions.forArguments(arrayOf("refresh", "all")), + ) + } + + @Test + fun `packaged command permissions are complete`() { + val permissions = + PermissionCommandPermissions.fromManifest(PermissionManifest.loadResource()) + + assertEquals("grounds.permissions.command", permissions.forArguments(emptyArray())) + assertEquals( + "grounds.permissions.command.user.info", + permissions.forArguments(arrayOf("user", "Alex", "info")), + ) + } + + private fun service( + refresh: (UUID, String) -> PermissionLoginResult = { _, _ -> + PermissionLoginResult.denied() + } + ): PermissionCommandService = + PermissionCommandService( + snapshots = snapshots, + permissions = permissions, + refreshSnapshot = refresh, + status = + PermissionCommandStatus( + version = "0.1.0", + grpcTarget = "permissions:9090", + context = PermissionSnapshotContext(serverType = "lobby", serverId = "proxy-1"), + ), + ) + + private fun router( + players: List = listOf(OnlinePermissionPlayer(playerId, "Alex")), + refresh: (UUID, String) -> PermissionLoginResult = { _, _ -> + PermissionLoginResult.denied() + }, + ): PermissionCommandRouter { + val byName = players.associateBy { it.username.lowercase() } + return PermissionCommandRouter( + service = service(refresh), + findOnlinePlayer = { name -> byName[name.lowercase()] }, + onlinePlayers = { players }, + ) + } + + private fun snapshot( + policyVersion: Long = 7, + roleKeys: Set = setOf("member"), + ): PermissionSnapshot = + PermissionSnapshot( + playerId = playerId, + policyVersion = policyVersion, + issuedAt = now.minusSeconds(60), + refreshAfter = now.plusSeconds(300), + expiresAt = now.plusSeconds(3600), + allowPatterns = + listOf( + PermissionGrant( + effect = PermissionEffect.ALLOW, + pattern = "chat.send", + scope = PermissionScope.global(), + source = PermissionGrantSource.ROLE, + ) + ), + denyPatterns = + listOf( + PermissionGrant( + effect = PermissionEffect.DENY, + pattern = "chat.moderation.*", + scope = PermissionScope.global(), + source = PermissionGrantSource.ROLE, + ) + ), + roleKeys = roleKeys, + roleMetadata = listOf(RoleMetadata(key = "moderator", name = "Moderator")), + ) +} diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionLoginListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionLoginListenerTest.kt index 4f45372..8ebabc3 100644 --- a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionLoginListenerTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionLoginListenerTest.kt @@ -29,7 +29,7 @@ class PermissionLoginListenerTest { client = FakeClient(PermissionSnapshotFetchResult.Success(snapshot)), ) - val result = listener.loadSnapshot(playerId, "lukas") + val result = listener.loadSnapshot(playerId, "Alex") assertTrue(result.allowed) assertEquals(snapshot, result.snapshot) @@ -51,7 +51,7 @@ class PermissionLoginListenerTest { client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable")), ) - val result = listener.loadSnapshot(playerId, "lukas") + val result = listener.loadSnapshot(playerId, "Alex") assertTrue(result.allowed) assertEquals(snapshot, result.snapshot) @@ -64,7 +64,7 @@ class PermissionLoginListenerTest { val listener = listener(client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable"))) - val result = listener.loadSnapshot(playerId, "lukas") + val result = listener.loadSnapshot(playerId, "Alex") assertFalse(result.allowed) } @@ -80,7 +80,7 @@ class PermissionLoginListenerTest { client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable")), ) - val result = listener.loadSnapshot(playerId, "lukas") + val result = listener.loadSnapshot(playerId, "Alex") assertFalse(result.allowed) } @@ -92,7 +92,7 @@ class PermissionLoginListenerTest { val context = PermissionSnapshotContext(serverType = "lobby", serverId = "proxy-1") val listener = listener(client = client, context = context) - listener.loadSnapshot(playerId, "lukas") + listener.loadSnapshot(playerId, "Alex") assertSame(context, client.lastContext) }