diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCommandCondition.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCommandCondition.kt new file mode 100644 index 0000000..ae38704 --- /dev/null +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCommandCondition.kt @@ -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 + } + } diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionCommandConditionTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionCommandConditionTest.kt new file mode 100644 index 0000000..783b7b1 --- /dev/null +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionCommandConditionTest.kt @@ -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() + } + } +} 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 e2395b5..8358d68 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt @@ -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 @@ -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 @@ -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) @@ -100,6 +103,7 @@ constructor( val permissions = SnapshotPermissions(snapshots, defaultScope = config.context.toCheckScope()) + this.permissions = permissions loadCommandPermissions()?.let { commandPermissions -> val router = PermissionCommandRouter( @@ -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) @@ -171,6 +180,7 @@ constructor( client = null catalogClient?.close() catalogClient = null + permissions = null } private fun registerProviders() { diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/SnapshotPermissionProvider.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/SnapshotPermissionProvider.kt new file mode 100644 index 0000000..fb38f65 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/SnapshotPermissionProvider.kt @@ -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) + } + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/SnapshotPermissionProviderTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/SnapshotPermissionProviderTest.kt new file mode 100644 index 0000000..dd00a8e --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/SnapshotPermissionProviderTest.kt @@ -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 + } +}