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,20 @@
package gg.grounds.permissions.minestom

import gg.grounds.permissions.Permissions
import net.minestom.server.command.builder.condition.CommandCondition
import net.minestom.server.entity.Player

/**
* Minestom has no permission API; this is the one entry point gamemodes need to gate a command on a
* permission node, e.g. `myCommand.condition =
* permissions.commandCondition("grounds.lobby.command.foo")`.
*
* Non-player senders (e.g. the console) are always allowed.
*/
fun Permissions.commandCondition(permission: String): CommandCondition =
CommandCondition { sender, _ ->
when (sender) {
is Player -> hasPermission(sender.uuid, permission)
else -> true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package gg.grounds.permissions.minestom

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.SnapshotPermissions
import java.net.SocketAddress
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import net.kyori.adventure.identity.Identity
import net.minestom.server.MinecraftServer
import net.minestom.server.command.CommandSender
import net.minestom.server.entity.Player
import net.minestom.server.network.packet.server.SendablePacket
import net.minestom.server.network.player.GameProfile
import net.minestom.server.network.player.PlayerConnection
import net.minestom.server.tag.TagHandler
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test

class PermissionCommandConditionTest {
private val now: Instant = Instant.parse("2026-07-09T10:00:00Z")
private val clock: Clock = Clock.fixed(now, ZoneOffset.UTC)
private val permission = "grounds.lobby.command.foo"

@Test
fun `allows a player holding the permission`() {
val playerId = UUID.randomUUID()
val permissions =
SnapshotPermissions(mapOf(playerId to snapshot(playerId, permission)), clock = clock)
val condition = permissions.commandCondition(permission)

assertTrue(condition.canUse(fakePlayer(playerId), "foo"))
}

@Test
fun `denies a player without the permission`() {
val playerId = UUID.randomUUID()
val permissions = SnapshotPermissions(emptyMap(), clock = clock)
val condition = permissions.commandCondition(permission)

assertFalse(condition.canUse(fakePlayer(playerId), "foo"))
}

@Test
fun `allows a non-player sender`() {
val permissions = SnapshotPermissions(emptyMap(), clock = clock)
val condition = permissions.commandCondition(permission)

assertTrue(condition.canUse(FakeConsoleSender(), "foo"))
}

private fun snapshot(playerId: UUID, permission: String): PermissionSnapshot =
PermissionSnapshot(
playerId = playerId,
policyVersion = 1,
issuedAt = now.minusSeconds(30),
refreshAfter = now.plusSeconds(300),
expiresAt = now.plusSeconds(3600),
allowPatterns =
listOf(
PermissionGrant(
effect = PermissionEffect.ALLOW,
pattern = permission,
scope = PermissionScope.global(),
source = PermissionGrantSource.ROLE,
)
),
denyPatterns = emptyList(),
roleKeys = emptySet(),
roleMetadata = emptyList(),
)

private fun fakePlayer(uuid: UUID): Player = Player(FakeConnection(), GameProfile(uuid, "Alex"))

private class FakeConnection : PlayerConnection() {
override fun sendPacket(packet: SendablePacket) {}

override fun getRemoteAddress(): SocketAddress? = null
}

private class FakeConsoleSender : CommandSender {
private val handler = TagHandler.newHandler()

override fun tagHandler(): TagHandler = handler

override fun identity(): Identity = Identity.nil()
}

companion object {
// Player's static initializer reaches into MinecraftServer's dimension type registry, so
// constructing one in a test requires the server to be booted first.
@JvmStatic
@BeforeAll
fun bootMinestom() {
MinecraftServer.init()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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.permission.PermissionsSetupEvent
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent
import com.velocitypowered.api.plugin.Plugin
Expand All @@ -13,6 +14,7 @@ import gg.grounds.BuildInfo
import gg.grounds.permissions.InMemoryPermissionSnapshots
import gg.grounds.permissions.PermissionCheckScope
import gg.grounds.permissions.PermissionSnapshotRefreshSweep
import gg.grounds.permissions.Permissions
import gg.grounds.permissions.SnapshotPermissions
import gg.grounds.permissions.catalog.PermissionManifest
import gg.grounds.permissions.catalog.PermissionManifestCollector
Expand Down Expand Up @@ -43,6 +45,7 @@ constructor(
private var catalogClient: PermissionCatalogClient? = null
private var commandMeta: CommandMeta? = null
private var refreshTask: ScheduledTask? = null
private var permissions: Permissions? = null

init {
logger.info("Initialized plugin (plugin=plugin-permissions, version={})", BuildInfo.VERSION)
Expand Down Expand Up @@ -100,6 +103,7 @@ constructor(

val permissions =
SnapshotPermissions(snapshots, defaultScope = config.context.toCheckScope())
this.permissions = permissions
loadCommandPermissions()?.let { commandPermissions ->
val router =
PermissionCommandRouter(
Expand Down Expand Up @@ -161,6 +165,11 @@ constructor(
)
}

@Subscribe
fun onPermissionsSetup(event: PermissionsSetupEvent) {
permissions?.let { event.provider = SnapshotPermissionProvider(it, event.provider) }
}

@Subscribe
fun onShutdown(event: ProxyShutdownEvent) {
commandMeta?.let(proxy.commandManager::unregister)
Expand All @@ -171,6 +180,7 @@ constructor(
client = null
catalogClient?.close()
catalogClient = null
permissions = null
}

private fun registerProviders() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package gg.grounds.permissions.velocity

import com.velocitypowered.api.permission.PermissionFunction
import com.velocitypowered.api.permission.PermissionProvider
import com.velocitypowered.api.permission.PermissionSubject
import com.velocitypowered.api.permission.Tristate
import com.velocitypowered.api.proxy.Player
import gg.grounds.permissions.Permissions

/**
* Bridges the loaded permission snapshot into Velocity's native [PermissionProvider] API, so that
* [com.velocitypowered.api.command.CommandSource.hasPermission] sees the same decisions as this
* plugin's own `/permissions` commands.
*
* [fallback] is the provider that was installed before this one took over. Non-player subjects
* (e.g. the console) always fall through to it unchanged, and any permission this provider does not
* ALLOW also falls through to it rather than being modeled as a hard DENY.
*/
class SnapshotPermissionProvider(
private val permissions: Permissions,
private val fallback: PermissionProvider,
) : PermissionProvider {
override fun createFunction(subject: PermissionSubject): PermissionFunction {
val fallbackFunction = fallback.createFunction(subject)
val player = subject as? Player ?: return fallbackFunction
return PermissionFunction { permission ->
if (permissions.hasPermission(player.uniqueId, permission)) Tristate.TRUE
else fallbackFunction.getPermissionValue(permission)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package gg.grounds.permissions.velocity

import com.velocitypowered.api.permission.PermissionFunction
import com.velocitypowered.api.permission.PermissionProvider
import com.velocitypowered.api.permission.PermissionSubject
import com.velocitypowered.api.permission.Tristate
import com.velocitypowered.api.proxy.Player
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.SnapshotPermissions
import java.lang.reflect.Proxy
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.assertSame
import org.junit.jupiter.api.Test

class SnapshotPermissionProviderTest {
private val now: Instant = Instant.parse("2026-07-09T10:00:00Z")
private val clock: Clock = Clock.fixed(now, ZoneOffset.UTC)
private val permission = "grounds.lobby.command.foo"

@Test
fun `player holding the permission gets Tristate TRUE`() {
val playerId = UUID.randomUUID()
val permissions =
SnapshotPermissions(mapOf(playerId to snapshot(playerId, permission)), clock = clock)
val fallback = FakePermissionProvider(PermissionFunction { Tristate.UNDEFINED })
val provider = SnapshotPermissionProvider(permissions, fallback)

val function = provider.createFunction(fakePlayer(playerId))

assertEquals(Tristate.TRUE, function.getPermissionValue(permission))
}

@Test
fun `player without the permission falls through to the fallback function`() {
val playerId = UUID.randomUUID()
val permissions = SnapshotPermissions(emptyMap(), clock = clock)
val fallback = FakePermissionProvider(PermissionFunction { Tristate.UNDEFINED })
val provider = SnapshotPermissionProvider(permissions, fallback)

val function = provider.createFunction(fakePlayer(playerId))

assertEquals(Tristate.UNDEFINED, function.getPermissionValue(permission))
}

@Test
fun `non-player subject gets the fallback function unchanged`() {
val permissions = SnapshotPermissions(emptyMap(), clock = clock)
val fallbackFunction = PermissionFunction { Tristate.FALSE }
val fallback = FakePermissionProvider(fallbackFunction)
val provider = SnapshotPermissionProvider(permissions, fallback)
val console = FakePermissionSubject(Tristate.TRUE)

val function = provider.createFunction(console)

assertSame(fallbackFunction, function)
}

private fun snapshot(playerId: UUID, permission: String): PermissionSnapshot =
PermissionSnapshot(
playerId = playerId,
policyVersion = 1,
issuedAt = now.minusSeconds(30),
refreshAfter = now.plusSeconds(300),
expiresAt = now.plusSeconds(3600),
allowPatterns =
listOf(
PermissionGrant(
effect = PermissionEffect.ALLOW,
pattern = permission,
scope = PermissionScope.global(),
source = PermissionGrantSource.ROLE,
)
),
denyPatterns = emptyList(),
roleKeys = emptySet(),
roleMetadata = emptyList(),
)

/**
* Velocity's [Player] interface has dozens of members we don't care about; delegate all of them
* to a proxy that fails loudly if touched, and override only [Player.getUniqueId].
*/
private fun fakePlayer(uniqueId: UUID): Player {
val unimplemented =
Proxy.newProxyInstance(Player::class.java.classLoader, arrayOf(Player::class.java)) {
_,
method,
_ ->
throw UnsupportedOperationException("Unexpected call: ${method.name}")
} as Player
return object : Player by unimplemented {
override fun getUniqueId(): UUID = uniqueId
}
}

private class FakePermissionProvider(private val function: PermissionFunction) :
PermissionProvider {
override fun createFunction(subject: PermissionSubject): PermissionFunction = function
}

private class FakePermissionSubject(private val value: Tristate) : PermissionSubject {
override fun getPermissionValue(permission: String): Tristate = value
}
}