diff --git a/README.md b/README.md index c23b04b..c630745 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,42 @@ The plugin requires the gRPC target to be provided via environment variable: export PLAYER_PRESENCE_GRPC_TARGET="dns:///service-player.api.svc.cluster.local:9000" ``` +Permissions use the same target by default but can be overridden: + +```bash +export PERMISSIONS_GRPC_TARGET="dns:///service-player.api.svc.cluster.local:9000" +export PERMISSIONS_CACHE_REFRESH_SECONDS="30" +# Optional: gRPC target for permissions events (defaults to PERMISSIONS_GRPC_TARGET if unset) +export PERMISSIONS_EVENTS_GRPC_TARGET="dns:///service-player.api.svc.cluster.local:9000" +# Optional: unique identifier for this server instance when handling permissions events +export PERMISSIONS_EVENTS_SERVER_ID="velocity-1" +``` + Messages are configured in `velocity/src/main/resources/messages.yml` (copied to the plugin data directory on first run). +## Commands + +```text +/permissions help +/permissions refresh [player|uuid] +/permissions player info +/permissions player check +/permissions player refresh +/permissions player permission add [duration] +/permissions player permission remove +/permissions player group add [duration] +/permissions player group remove +/permissions group list +/permissions group create +/permissions group info +/permissions group delete +/permissions group permission add [duration] +/permissions group permission remove +``` + +Durations use a single suffix: `30m`, `1h`, `7d`, `2w` (seconds `s`, minutes `m`, hours `h`, days `d`, weeks `w`). + ## Development Run in dev mode with live reload using DevSpace in a Kubernetes cluster: diff --git a/common/build.gradle.kts b/common/build.gradle.kts index cce2133..1835e04 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -10,4 +10,7 @@ repositories { } } -dependencies { protobuf("gg.grounds:library-grpc-contracts-player:0.1.0") } +dependencies { + protobuf("gg.grounds:library-grpc-contracts-player:0.1.0") + protobuf("gg.grounds:library-grpc-contracts-permission:feat-perm-protos-SNAPSHOT") +} diff --git a/common/src/main/kotlin/gg/grounds/grpc/BaseGrpcClient.kt b/common/src/main/kotlin/gg/grounds/grpc/BaseGrpcClient.kt new file mode 100644 index 0000000..d9ea5bb --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/grpc/BaseGrpcClient.kt @@ -0,0 +1,32 @@ +package gg.grounds.grpc + +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import java.util.concurrent.TimeUnit + +abstract class BaseGrpcClient(protected val channel: ManagedChannel) : AutoCloseable { + override fun close() { + closeChannel(channel) + } + + companion object { + fun createChannel(target: String): ManagedChannel { + val channelBuilder = ManagedChannelBuilder.forTarget(target) + channelBuilder.usePlaintext() + return channelBuilder.build() + } + + fun closeChannel(channel: ManagedChannel) { + channel.shutdown() + try { + if (!channel.awaitTermination(3, TimeUnit.SECONDS)) { + channel.shutdownNow() + channel.awaitTermination(3, TimeUnit.SECONDS) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + channel.shutdownNow() + } + } + } +} diff --git a/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsAdminClient.kt b/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsAdminClient.kt new file mode 100644 index 0000000..a43912a --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsAdminClient.kt @@ -0,0 +1,86 @@ +package gg.grounds.permissions + +import gg.grounds.grpc.BaseGrpcClient +import gg.grounds.grpc.permissions.AddGroupPermissionsReply +import gg.grounds.grpc.permissions.AddGroupPermissionsRequest +import gg.grounds.grpc.permissions.AddPlayerGroupsReply +import gg.grounds.grpc.permissions.AddPlayerGroupsRequest +import gg.grounds.grpc.permissions.AddPlayerPermissionsReply +import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest +import gg.grounds.grpc.permissions.CreateGroupReply +import gg.grounds.grpc.permissions.CreateGroupRequest +import gg.grounds.grpc.permissions.DeleteGroupReply +import gg.grounds.grpc.permissions.DeleteGroupRequest +import gg.grounds.grpc.permissions.GetGroupReply +import gg.grounds.grpc.permissions.GetGroupRequest +import gg.grounds.grpc.permissions.ListGroupsReply +import gg.grounds.grpc.permissions.ListGroupsRequest +import gg.grounds.grpc.permissions.PermissionsAdminServiceGrpc +import gg.grounds.grpc.permissions.RemoveGroupPermissionsReply +import gg.grounds.grpc.permissions.RemoveGroupPermissionsRequest +import gg.grounds.grpc.permissions.RemovePlayerGroupsReply +import gg.grounds.grpc.permissions.RemovePlayerGroupsRequest +import gg.grounds.grpc.permissions.RemovePlayerPermissionsReply +import gg.grounds.grpc.permissions.RemovePlayerPermissionsRequest +import io.grpc.ManagedChannel +import java.util.concurrent.TimeUnit + +class GrpcPermissionsAdminClient +private constructor( + channel: ManagedChannel, + private val stub: PermissionsAdminServiceGrpc.PermissionsAdminServiceBlockingStub, +) : BaseGrpcClient(channel) { + fun createGroup(request: CreateGroupRequest): CreateGroupReply = + stub.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS).createGroup(request) + + fun deleteGroup(request: DeleteGroupRequest): DeleteGroupReply = + stub.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS).deleteGroup(request) + + fun getGroup(request: GetGroupRequest): GetGroupReply = + stub.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS).getGroup(request) + + fun listGroups(request: ListGroupsRequest): ListGroupsReply = + stub.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS).listGroups(request) + + fun addGroupPermissions(request: AddGroupPermissionsRequest): AddGroupPermissionsReply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .addGroupPermissions(request) + + fun removeGroupPermissions( + request: RemoveGroupPermissionsRequest + ): RemoveGroupPermissionsReply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .removeGroupPermissions(request) + + fun addPlayerPermissions(request: AddPlayerPermissionsRequest): AddPlayerPermissionsReply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .addPlayerPermissions(request) + + fun removePlayerPermissions( + request: RemovePlayerPermissionsRequest + ): RemovePlayerPermissionsReply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .removePlayerPermissions(request) + + fun addPlayerGroups(request: AddPlayerGroupsRequest): AddPlayerGroupsReply = + stub.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS).addPlayerGroups(request) + + fun removePlayerGroups(request: RemovePlayerGroupsRequest): RemovePlayerGroupsReply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .removePlayerGroups(request) + + companion object { + fun create(target: String): GrpcPermissionsAdminClient { + val channel = createChannel(target) + val stub = PermissionsAdminServiceGrpc.newBlockingStub(channel) + return GrpcPermissionsAdminClient(channel, stub) + } + + private const val DEFAULT_TIMEOUT_MS = 2000L + } +} diff --git a/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsClient.kt b/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsClient.kt new file mode 100644 index 0000000..a252d6e --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsClient.kt @@ -0,0 +1,64 @@ +package gg.grounds.permissions + +import gg.grounds.grpc.BaseGrpcClient +import gg.grounds.grpc.permissions.CheckPlayerPermissionReply +import gg.grounds.grpc.permissions.CheckPlayerPermissionRequest +import gg.grounds.grpc.permissions.GetPlayerPermissionsReply +import gg.grounds.grpc.permissions.GetPlayerPermissionsRequest +import gg.grounds.grpc.permissions.PermissionsServiceGrpc +import io.grpc.ManagedChannel +import io.grpc.Status +import io.grpc.StatusRuntimeException +import java.util.UUID +import java.util.concurrent.TimeUnit + +class GrpcPermissionsClient +private constructor( + channel: ManagedChannel, + private val stub: PermissionsServiceGrpc.PermissionsServiceBlockingStub, +) : BaseGrpcClient(channel) { + fun getPlayerPermissions( + playerId: UUID, + includeEffectivePermissions: Boolean = true, + includeDirectPermissions: Boolean = true, + includeGroups: Boolean = true, + ): GetPlayerPermissionsReply { + return stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .getPlayerPermissions( + GetPlayerPermissionsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setIncludeEffectivePermissions(includeEffectivePermissions) + .setIncludeDirectPermissions(includeDirectPermissions) + .setIncludeGroups(includeGroups) + .build() + ) + } + + fun checkPlayerPermission(playerId: UUID, permission: String): CheckPlayerPermissionReply { + return stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .checkPlayerPermission( + CheckPlayerPermissionRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setPermission(permission) + .build() + ) + } + + companion object { + fun create(target: String): GrpcPermissionsClient { + val channel = createChannel(target) + val stub = PermissionsServiceGrpc.newBlockingStub(channel) + return GrpcPermissionsClient(channel, stub) + } + + fun isServiceUnavailable(status: Status.Code): Boolean = + status == Status.Code.UNAVAILABLE || status == Status.Code.DEADLINE_EXCEEDED + + fun isServiceUnavailable(error: StatusRuntimeException): Boolean = + isServiceUnavailable(error.status.code) + + private const val DEFAULT_TIMEOUT_MS = 2000L + } +} diff --git a/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsEventsClient.kt b/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsEventsClient.kt new file mode 100644 index 0000000..b6ddbd7 --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/permissions/GrpcPermissionsEventsClient.kt @@ -0,0 +1,29 @@ +package gg.grounds.permissions + +import gg.grounds.grpc.BaseGrpcClient +import gg.grounds.grpc.permissions.PermissionsChangeEvent +import gg.grounds.grpc.permissions.PermissionsEventsServiceGrpc +import gg.grounds.grpc.permissions.SubscribePermissionsChangesRequest +import io.grpc.ManagedChannel +import io.grpc.stub.StreamObserver + +class GrpcPermissionsEventsClient +private constructor( + channel: ManagedChannel, + private val stub: PermissionsEventsServiceGrpc.PermissionsEventsServiceStub, +) : BaseGrpcClient(channel) { + fun subscribe( + request: SubscribePermissionsChangesRequest, + observer: StreamObserver, + ) { + stub.subscribePermissionsChanges(request, observer) + } + + companion object { + fun create(target: String): GrpcPermissionsEventsClient { + val channel = createChannel(target) + val stub = PermissionsEventsServiceGrpc.newStub(channel) + return GrpcPermissionsEventsClient(channel, stub) + } + } +} diff --git a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt b/common/src/main/kotlin/gg/grounds/presence/GrpcPlayerPresenceClient.kt similarity index 77% rename from common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt rename to common/src/main/kotlin/gg/grounds/presence/GrpcPlayerPresenceClient.kt index 5a34d1e..aad9fae 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/GrpcPlayerPresenceClient.kt +++ b/common/src/main/kotlin/gg/grounds/presence/GrpcPlayerPresenceClient.kt @@ -1,11 +1,11 @@ -package gg.grounds.player.presence +package gg.grounds.presence +import gg.grounds.grpc.BaseGrpcClient import gg.grounds.grpc.player.PlayerLoginRequest import gg.grounds.grpc.player.PlayerLogoutReply import gg.grounds.grpc.player.PlayerLogoutRequest import gg.grounds.grpc.player.PlayerPresenceServiceGrpc import io.grpc.ManagedChannel -import io.grpc.ManagedChannelBuilder import io.grpc.Status import io.grpc.StatusRuntimeException import java.util.UUID @@ -13,9 +13,9 @@ import java.util.concurrent.TimeUnit class GrpcPlayerPresenceClient private constructor( - private val channel: ManagedChannel, + channel: ManagedChannel, private val stub: PlayerPresenceServiceGrpc.PlayerPresenceServiceBlockingStub, -) : AutoCloseable { +) : BaseGrpcClient(channel) { fun tryLogin(playerId: UUID): PlayerLoginResult { return try { val reply = @@ -49,24 +49,9 @@ private constructor( } } - 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): GrpcPlayerPresenceClient { - val channelBuilder = ManagedChannelBuilder.forTarget(target) - channelBuilder.usePlaintext() - val channel = channelBuilder.build() + val channel = createChannel(target) val stub = PlayerPresenceServiceGrpc.newBlockingStub(channel) return GrpcPlayerPresenceClient(channel, stub) } diff --git a/common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt b/common/src/main/kotlin/gg/grounds/presence/PlayerLoginResult.kt similarity index 89% rename from common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt rename to common/src/main/kotlin/gg/grounds/presence/PlayerLoginResult.kt index e08adc3..91e4547 100644 --- a/common/src/main/kotlin/gg/grounds/player/presence/PlayerLoginResult.kt +++ b/common/src/main/kotlin/gg/grounds/presence/PlayerLoginResult.kt @@ -1,4 +1,4 @@ -package gg.grounds.player.presence +package gg.grounds.presence import gg.grounds.grpc.player.PlayerLoginReply diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index e835e17..95e5023 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -5,4 +5,5 @@ dependencies { implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.3") implementation("tools.jackson.module:jackson-module-kotlin:3.0.3") implementation("io.grpc:grpc-netty-shaded:1.78.0") + implementation("io.grpc:grpc-stub:1.78.0") } diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index e5f3bab..4993da7 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -7,9 +7,17 @@ import com.velocitypowered.api.event.proxy.ProxyShutdownEvent import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer +import gg.grounds.config.EnvironmentConfig import gg.grounds.config.MessagesConfigLoader -import gg.grounds.listener.PlayerConnectionListener +import gg.grounds.permissions.PermissionsCache +import gg.grounds.permissions.commands.PermissionsCommand +import gg.grounds.permissions.listener.PermissionsConnectionListener +import gg.grounds.permissions.listener.PermissionsSetupListener +import gg.grounds.permissions.services.PermissionsAdminService +import gg.grounds.permissions.services.PermissionsEventsSubscriber +import gg.grounds.permissions.services.PermissionsService import gg.grounds.presence.PlayerPresenceService +import gg.grounds.presence.listener.PlayerConnectionListener import io.grpc.LoadBalancerRegistry import io.grpc.NameResolverRegistry import io.grpc.internal.DnsNameResolverProvider @@ -33,6 +41,11 @@ constructor( @param:DataDirectory private val dataDirectory: Path, ) { private val playerPresenceService = PlayerPresenceService() + private val permissionsService = PermissionsService(logger) + private val permissionsCache = PermissionsCache(permissionsService, logger) + private val permissionsAdminService = PermissionsAdminService(logger) + private val permissionsEventsSubscriber = PermissionsEventsSubscriber(logger, permissionsCache) + private val environmentConfig = EnvironmentConfig() init { logger.info("Initialized plugin (plugin=plugin-player, version={})", BuildInfo.VERSION) @@ -43,8 +56,21 @@ constructor( registerProviders() val messages = MessagesConfigLoader(logger, dataDirectory).loadOrCreate() - val target = resolveTarget() - playerPresenceService.configure(target) + val presenceTarget = environmentConfig.presenceTarget() + val permissionsTarget = environmentConfig.permissionsTarget(presenceTarget) + val permissionsEventsTarget = environmentConfig.permissionsEventsTarget(permissionsTarget) + playerPresenceService.configure(presenceTarget) + permissionsService.configure(permissionsTarget) + permissionsAdminService.configure(permissionsTarget) + permissionsCache.startAutoRefresh( + proxy, + this, + environmentConfig.permissionsRefreshIntervalSeconds(), + ) + permissionsEventsSubscriber.configure( + permissionsEventsTarget, + environmentConfig.permissionsEventsServerId(), + ) proxy.eventManager.register( this, @@ -54,13 +80,37 @@ constructor( messages = messages, ), ) + proxy.eventManager.register( + this, + PermissionsConnectionListener(logger = logger, permissionsCache = permissionsCache), + ) + proxy.eventManager.register( + this, + PermissionsSetupListener(permissionsCache = permissionsCache), + ) + val permissionsCommand = + PermissionsCommand(proxy, permissionsCache, permissionsService, permissionsAdminService) + .create() + proxy.commandManager.register( + proxy.commandManager.metaBuilder(permissionsCommand).build(), + permissionsCommand, + ) - logger.info("Configured player presence gRPC client (target={})", target) + logger.info("Configured player presence gRPC client (target={})", presenceTarget) + logger.info("Configured permissions gRPC client (target={})", permissionsTarget) + logger.info( + "Configured permissions events gRPC client (target={})", + permissionsEventsTarget, + ) } @Subscribe fun onShutdown(event: ProxyShutdownEvent) { playerPresenceService.close() + permissionsCache.close() + permissionsService.close() + permissionsAdminService.close() + permissionsEventsSubscriber.close() } /** @@ -73,9 +123,4 @@ constructor( NameResolverRegistry.getDefaultRegistry().register(DnsNameResolverProvider()) LoadBalancerRegistry.getDefaultRegistry().register(PickFirstLoadBalancerProvider()) } - - private fun resolveTarget(): String { - return System.getenv("PLAYER_PRESENCE_GRPC_TARGET")?.takeIf { it.isNotBlank() } - ?: error("Missing required environment variable PLAYER_PRESENCE_GRPC_TARGET") - } } diff --git a/velocity/src/main/kotlin/gg/grounds/config/EnvironmentConfig.kt b/velocity/src/main/kotlin/gg/grounds/config/EnvironmentConfig.kt new file mode 100644 index 0000000..f227230 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/config/EnvironmentConfig.kt @@ -0,0 +1,41 @@ +package gg.grounds.config + +class EnvironmentConfig { + fun presenceTarget(): String { + return requireEnv("PLAYER_PRESENCE_GRPC_TARGET") + } + + fun permissionsTarget(presenceTarget: String): String { + return env("PERMISSIONS_GRPC_TARGET") ?: presenceTarget + } + + fun permissionsEventsTarget(permissionsTarget: String): String { + return env("PERMISSIONS_EVENTS_GRPC_TARGET") ?: permissionsTarget + } + + fun permissionsEventsServerId(): String? { + return env("PERMISSIONS_EVENTS_SERVER_ID") + } + + fun permissionsRefreshIntervalSeconds( + defaultSeconds: Long = DEFAULT_PERMISSIONS_REFRESH_SECONDS + ): Long { + val rawValue = env("PERMISSIONS_CACHE_REFRESH_SECONDS").orEmpty().trim() + val parsed = rawValue.toLongOrNull() + return when { + parsed == null -> defaultSeconds + parsed <= 0 -> 0L + else -> parsed + } + } + + private fun env(name: String): String? = System.getenv(name)?.takeIf { it.isNotBlank() } + + private fun requireEnv(name: String): String { + return env(name) ?: error("Missing required environment variable $name") + } + + companion object { + private const val DEFAULT_PERMISSIONS_REFRESH_SECONDS = 30L + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/CombinedPermissionsProvider.kt b/velocity/src/main/kotlin/gg/grounds/permissions/CombinedPermissionsProvider.kt new file mode 100644 index 0000000..0aca658 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/CombinedPermissionsProvider.kt @@ -0,0 +1,26 @@ +package gg.grounds.permissions + +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 + +class CombinedPermissionsProvider( + private val permissionsCache: PermissionsCache, + private val fallbackProvider: PermissionProvider, +) : PermissionProvider { + override fun createFunction(subject: PermissionSubject): PermissionFunction { + val fallbackFunction = fallbackProvider.createFunction(subject) + val player = subject as? Player ?: return fallbackFunction + val cachedFunction = permissionsCache.createPermissionFunction(player.uniqueId) + return PermissionFunction { permission -> + val cachedValue = cachedFunction.getPermissionValue(permission) + if (cachedValue == Tristate.UNDEFINED) { + fallbackFunction.getPermissionValue(permission) + } else { + cachedValue + } + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/PermissionsCache.kt b/velocity/src/main/kotlin/gg/grounds/permissions/PermissionsCache.kt new file mode 100644 index 0000000..0249c18 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/PermissionsCache.kt @@ -0,0 +1,238 @@ +package gg.grounds.permissions + +import com.velocitypowered.api.permission.PermissionFunction +import com.velocitypowered.api.permission.Tristate +import com.velocitypowered.api.proxy.ProxyServer +import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.grpc.permissions.EffectivePermissionDelta +import gg.grounds.grpc.permissions.GroupMembershipDelta +import gg.grounds.grpc.permissions.PermissionDelta +import gg.grounds.grpc.permissions.PermissionsChangeEvent +import gg.grounds.grpc.permissions.PlayerPermissions +import gg.grounds.permissions.services.PermissionsService +import java.time.Instant +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import org.slf4j.Logger + +data class CachedPermissions( + val playerId: UUID, + val groupMemberships: Set, + val directPermissions: Set, + val effectivePermissions: Set, + val updatedAt: Instant, +) + +data class GroupMembership(val groupName: String, val expiresAt: Instant?) + +data class PermissionGrant(val permission: String, val expiresAt: Instant?) + +class PermissionsCache( + private val permissionsService: PermissionsService, + private val logger: Logger, +) : AutoCloseable { + private val cache = ConcurrentHashMap() + private var refreshTask: ScheduledTask? = null + + fun cachePlayer(playerId: UUID): Boolean { + val permissions = permissionsService.fetchPlayerPermissions(playerId) ?: return false + cache[playerId] = permissions.toCached() + return true + } + + fun refreshPlayer(playerId: UUID): Boolean { + val permissions = permissionsService.fetchPlayerPermissions(playerId) ?: return false + cache[playerId] = permissions.toCached() + return true + } + + fun applyChangeEvent(event: PermissionsChangeEvent): Boolean { + val playerId = + try { + UUID.fromString(event.playerId) + } catch (error: IllegalArgumentException) { + return false + } + return applyDeltas( + playerId, + event.directPermissionDeltasList, + event.groupMembershipDeltasList, + event.effectivePermissionDeltasList, + ) + } + + fun applyDeltas( + playerId: UUID, + directDeltas: List, + groupDeltas: List, + effectiveDeltas: List, + ): Boolean { + return cache.compute(playerId) { _, cached -> + if (cached == null) { + return@compute null + } + + val directPermissions = + cached.directPermissions.associateBy { it.permission }.toMutableMap() + directDeltas.forEach { delta -> + when (delta.action) { + PermissionDelta.Action.ADD -> { + directPermissions[delta.permission] = + PermissionGrant( + delta.permission, + delta.expiresAt + .takeIf { it.seconds != 0L || it.nanos != 0 } + ?.let { Instant.ofEpochSecond(it.seconds, it.nanos.toLong()) }, + ) + } + PermissionDelta.Action.REMOVE -> directPermissions.remove(delta.permission) + PermissionDelta.Action.ACTION_UNSPECIFIED -> Unit + PermissionDelta.Action.UNRECOGNIZED -> Unit + } + } + + val groupMemberships = + cached.groupMemberships.associateBy { it.groupName }.toMutableMap() + groupDeltas.forEach { delta -> + when (delta.action) { + GroupMembershipDelta.Action.ADD -> { + groupMemberships[delta.groupName] = + GroupMembership( + delta.groupName, + delta.expiresAt + .takeIf { it.seconds != 0L || it.nanos != 0 } + ?.let { Instant.ofEpochSecond(it.seconds, it.nanos.toLong()) }, + ) + } + GroupMembershipDelta.Action.REMOVE -> groupMemberships.remove(delta.groupName) + GroupMembershipDelta.Action.ACTION_UNSPECIFIED -> Unit + GroupMembershipDelta.Action.UNRECOGNIZED -> Unit + } + } + + val effectivePermissions = cached.effectivePermissions.toMutableSet() + effectiveDeltas.forEach { delta -> + when (delta.action) { + EffectivePermissionDelta.Action.ADD -> + effectivePermissions.add(delta.permission) + EffectivePermissionDelta.Action.REMOVE -> + effectivePermissions.remove(delta.permission) + EffectivePermissionDelta.Action.ACTION_UNSPECIFIED -> Unit + EffectivePermissionDelta.Action.UNRECOGNIZED -> Unit + } + } + + cached.copy( + groupMemberships = groupMemberships.values.toSet(), + directPermissions = directPermissions.values.toSet(), + effectivePermissions = effectivePermissions, + updatedAt = Instant.now(), + ) + } != null + } + + fun removePlayer(playerId: UUID) { + cache.remove(playerId) + } + + fun get(playerId: UUID): CachedPermissions? = cache[playerId] + + fun checkCached(playerId: UUID, permission: String): Boolean? { + val cached = cache[playerId] ?: return null + return cached.effectivePermissions.contains(permission) + } + + fun createPermissionFunction(playerId: UUID): PermissionFunction { + return PermissionFunction { permission -> + if (permission.isBlank()) { + return@PermissionFunction Tristate.UNDEFINED + } + val cached = cache[playerId] ?: return@PermissionFunction Tristate.UNDEFINED + if (cached.effectivePermissions.contains(permission)) Tristate.TRUE + else Tristate.UNDEFINED + } + } + + fun startAutoRefresh(proxy: ProxyServer, plugin: Any, refreshIntervalSeconds: Long) { + if (refreshIntervalSeconds <= 0) { + logger.info( + "Permissions cache refresh disabled (intervalSeconds={})", + refreshIntervalSeconds, + ) + return + } + refreshTask?.cancel() + refreshTask = + proxy.scheduler + .buildTask(plugin, Runnable { refreshOnlinePlayers(proxy) }) + .repeat(refreshIntervalSeconds, TimeUnit.SECONDS) + .schedule() + logger.info( + "Permissions cache refresh scheduled (intervalSeconds={})", + refreshIntervalSeconds, + ) + } + + fun refreshOnlinePlayers(proxy: ProxyServer) { + val players = proxy.allPlayers + if (players.isEmpty()) { + return + } + players.forEach { player -> + if (!refreshPlayer(player.uniqueId)) { + logger.warn( + "Failed to refresh cached permissions (playerId={}, username={})", + player.uniqueId, + player.username, + ) + } + } + } + + override fun close() { + refreshTask?.cancel() + refreshTask = null + cache.clear() + } + + private fun PlayerPermissions.toCached(): CachedPermissions { + return CachedPermissions( + playerId = UUID.fromString(playerId), + groupMemberships = + groupMembershipsList + .map { + GroupMembership( + it.groupName, + if (it.hasExpiresAt()) { + Instant.ofEpochSecond( + it.expiresAt.seconds, + it.expiresAt.nanos.toLong(), + ) + } else { + null + }, + ) + } + .toSet(), + directPermissions = + directPermissionGrantsList + .map { + PermissionGrant( + it.permission, + if (it.hasExpiresAt()) { + Instant.ofEpochSecond( + it.expiresAt.seconds, + it.expiresAt.nanos.toLong(), + ) + } else { + null + }, + ) + } + .toSet(), + effectivePermissions = effectivePermissionsList.toSet(), + updatedAt = Instant.now(), + ) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsAdminCommandHandler.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsAdminCommandHandler.kt new file mode 100644 index 0000000..0f5aa43 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsAdminCommandHandler.kt @@ -0,0 +1,16 @@ +package gg.grounds.permissions.commands + +import com.velocitypowered.api.command.CommandSource +import net.kyori.adventure.text.Component + +class PermissionsAdminCommandHandler(private val context: PermissionsCommandContext) { + fun refreshAll(source: CommandSource): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to refresh all players.")) + return false + } + context.refreshOnlinePlayers() + source.sendMessage(Component.text("Permissions refreshed for online players.")) + return true + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommand.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommand.kt new file mode 100644 index 0000000..7f19663 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommand.kt @@ -0,0 +1,426 @@ +package gg.grounds.permissions.commands + +import com.mojang.brigadier.Command +import com.mojang.brigadier.arguments.StringArgumentType +import com.velocitypowered.api.command.BrigadierCommand +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.ProxyServer +import gg.grounds.permissions.PermissionsCache +import gg.grounds.permissions.services.PermissionsAdminService +import gg.grounds.permissions.services.PermissionsService +import java.util.UUID +import net.kyori.adventure.text.Component + +class PermissionsCommand( + proxy: ProxyServer, + permissionsCache: PermissionsCache, + permissionsService: PermissionsService, + permissionsAdminService: PermissionsAdminService, +) { + private val context = + PermissionsCommandContext( + proxy, + permissionsCache, + permissionsService, + permissionsAdminService, + ) + private val adminHandler = PermissionsAdminCommandHandler(context) + private val playerHandler = PermissionsPlayerCommandHandler(context) + private val groupHandler = PermissionsGroupCommandHandler(context) + private val suggestions = PermissionsSuggestions(proxy, permissionsAdminService) + + fun create(): BrigadierCommand { + return BrigadierCommand( + BrigadierCommand.literalArgumentBuilder("permissions") + .executes { context -> + PermissionsCommandMessages.sendUsage(context.source) + Command.SINGLE_SUCCESS + } + .then( + BrigadierCommand.literalArgumentBuilder("help").executes { context -> + PermissionsCommandMessages.sendHelp(context.source) + Command.SINGLE_SUCCESS + } + ) + .then(buildRefresh()) + .then(buildPlayer()) + .then(buildGroup()) + ) + } + + private fun buildRefresh() = + BrigadierCommand.literalArgumentBuilder("refresh") + .executes { context -> result(adminHandler.refreshAll(context.source)) } + .then( + BrigadierCommand.requiredArgumentBuilder("player", StringArgumentType.word()) + .suggests(suggestions.player()) + .executes { context -> + val playerId = + resolvePlayerIdOrSend( + context.source, + StringArgumentType.getString(context, PLAYER_ARGUMENT), + ) ?: return@executes 0 + result(playerHandler.refresh(context.source, playerId)) + } + ) + + private fun buildPlayer() = + BrigadierCommand.literalArgumentBuilder("player") + .then( + BrigadierCommand.requiredArgumentBuilder("player", StringArgumentType.word()) + .suggests(suggestions.player()) + .then( + BrigadierCommand.literalArgumentBuilder("info").executes { context -> + val playerId = resolvePlayerIdOrSend(context) ?: return@executes 0 + result(playerHandler.info(context.source, playerId)) + } + ) + .then( + BrigadierCommand.literalArgumentBuilder("check") + .then( + BrigadierCommand.requiredArgumentBuilder( + "permission", + StringArgumentType.word(), + ) + .suggests(suggestions.permission()) + .executes { context -> + val playerId = + resolvePlayerIdOrSend(context) ?: return@executes 0 + val permission = + StringArgumentType.getString(context, "permission") + result( + playerHandler.check( + context.source, + playerId, + permission, + ) + ) + } + ) + ) + .then( + BrigadierCommand.literalArgumentBuilder("refresh").executes { context -> + val playerId = resolvePlayerIdOrSend(context) ?: return@executes 0 + result(playerHandler.refresh(context.source, playerId)) + } + ) + .then( + BrigadierCommand.literalArgumentBuilder("permission") + .requires { source -> PermissionsCommandAccess.isAdmin(source) } + .then( + BrigadierCommand.literalArgumentBuilder("add") + .then( + BrigadierCommand.requiredArgumentBuilder( + "permission", + StringArgumentType.word(), + ) + .suggests(suggestions.permission()) + .executes { context -> + val playerId = + resolvePlayerIdOrSend(context) + ?: return@executes 0 + val permission = + StringArgumentType.getString( + context, + "permission", + ) + result( + playerHandler.permissionAdd( + context.source, + playerId, + permission, + null, + ) + ) + } + .then( + BrigadierCommand.requiredArgumentBuilder( + "duration", + StringArgumentType.word(), + ) + .suggests(suggestions.duration()) + .executes { context -> + val playerId = + resolvePlayerIdOrSend(context) + ?: return@executes 0 + val permission = + StringArgumentType.getString( + context, + "permission", + ) + val duration = + StringArgumentType.getString( + context, + "duration", + ) + result( + playerHandler.permissionAdd( + context.source, + playerId, + permission, + duration, + ) + ) + } + ) + ) + ) + .then( + BrigadierCommand.literalArgumentBuilder("remove") + .then( + BrigadierCommand.requiredArgumentBuilder( + "permission", + StringArgumentType.word(), + ) + .suggests(suggestions.permission()) + .executes { context -> + val playerId = + resolvePlayerIdOrSend(context) + ?: return@executes 0 + val permission = + StringArgumentType.getString( + context, + "permission", + ) + result( + playerHandler.permissionRemove( + context.source, + playerId, + permission, + ) + ) + } + ) + ) + ) + .then( + BrigadierCommand.literalArgumentBuilder("group") + .requires { source -> PermissionsCommandAccess.isAdmin(source) } + .then( + BrigadierCommand.literalArgumentBuilder("add") + .then( + BrigadierCommand.requiredArgumentBuilder( + "group", + StringArgumentType.word(), + ) + .suggests(suggestions.group()) + .executes { context -> + val playerId = + resolvePlayerIdOrSend(context) + ?: return@executes 0 + val groupName = + StringArgumentType.getString(context, "group") + result( + playerHandler.groupAdd( + context.source, + playerId, + groupName, + null, + ) + ) + } + .then( + BrigadierCommand.requiredArgumentBuilder( + "duration", + StringArgumentType.word(), + ) + .suggests(suggestions.duration()) + .executes { context -> + val playerId = + resolvePlayerIdOrSend(context) + ?: return@executes 0 + val groupName = + StringArgumentType.getString( + context, + "group", + ) + val duration = + StringArgumentType.getString( + context, + "duration", + ) + result( + playerHandler.groupAdd( + context.source, + playerId, + groupName, + duration, + ) + ) + } + ) + ) + ) + .then( + BrigadierCommand.literalArgumentBuilder("remove") + .then( + BrigadierCommand.requiredArgumentBuilder( + "group", + StringArgumentType.word(), + ) + .suggests(suggestions.group()) + .executes { context -> + val playerId = + resolvePlayerIdOrSend(context) + ?: return@executes 0 + val groupName = + StringArgumentType.getString(context, "group") + result( + playerHandler.groupRemove( + context.source, + playerId, + groupName, + ) + ) + } + ) + ) + ) + ) + + private fun buildGroup() = + BrigadierCommand.literalArgumentBuilder("group") + .requires { source -> PermissionsCommandAccess.isAdmin(source) } + .then( + BrigadierCommand.literalArgumentBuilder("list").executes { context -> + result(groupHandler.list(context.source)) + } + ) + .then( + BrigadierCommand.requiredArgumentBuilder("group", StringArgumentType.word()) + .suggests(suggestions.group()) + .then( + BrigadierCommand.literalArgumentBuilder("create").executes { context -> + val groupName = StringArgumentType.getString(context, "group") + result(groupHandler.create(context.source, groupName)) + } + ) + .then( + BrigadierCommand.literalArgumentBuilder("info").executes { context -> + val groupName = StringArgumentType.getString(context, "group") + result(groupHandler.info(context.source, groupName)) + } + ) + .then( + BrigadierCommand.literalArgumentBuilder("delete").executes { context -> + val groupName = StringArgumentType.getString(context, "group") + result(groupHandler.delete(context.source, groupName)) + } + ) + .then( + BrigadierCommand.literalArgumentBuilder("permission") + .then( + BrigadierCommand.literalArgumentBuilder("add") + .then( + BrigadierCommand.requiredArgumentBuilder( + "permission", + StringArgumentType.word(), + ) + .suggests(suggestions.permission()) + .executes { context -> + val groupName = + StringArgumentType.getString(context, "group") + val permission = + StringArgumentType.getString( + context, + "permission", + ) + result( + groupHandler.permissionAdd( + context.source, + groupName, + permission, + null, + ) + ) + } + .then( + BrigadierCommand.requiredArgumentBuilder( + "duration", + StringArgumentType.word(), + ) + .suggests(suggestions.duration()) + .executes { context -> + val groupName = + StringArgumentType.getString( + context, + "group", + ) + val permission = + StringArgumentType.getString( + context, + "permission", + ) + val duration = + StringArgumentType.getString( + context, + "duration", + ) + result( + groupHandler.permissionAdd( + context.source, + groupName, + permission, + duration, + ) + ) + } + ) + ) + ) + .then( + BrigadierCommand.literalArgumentBuilder("remove") + .then( + BrigadierCommand.requiredArgumentBuilder( + "permission", + StringArgumentType.word(), + ) + .suggests(suggestions.permission()) + .executes { context -> + val groupName = + StringArgumentType.getString(context, "group") + val permission = + StringArgumentType.getString( + context, + "permission", + ) + result( + groupHandler.permissionRemove( + context.source, + groupName, + permission, + ) + ) + } + ) + ) + ) + ) + + private fun resolvePlayerIdOrSend( + context: com.mojang.brigadier.context.CommandContext + ): UUID? { + val raw = StringArgumentType.getString(context, PLAYER_ARGUMENT) + return resolvePlayerIdOrSend(context.source, raw) + } + + private fun resolvePlayerIdOrSend(source: CommandSource, raw: String): UUID? { + val playerId = context.resolvePlayerId(source, raw) + if (playerId == null) { + source.sendMessage(Component.text("Unknown player or UUID.")) + } + return playerId + } + + private fun result(success: Boolean): Int { + return if (success) { + Command.SINGLE_SUCCESS + } else { + 0 + } + } + + companion object { + private const val PLAYER_ARGUMENT = "player" + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandAccess.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandAccess.kt new file mode 100644 index 0000000..32d34af --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandAccess.kt @@ -0,0 +1,21 @@ +package gg.grounds.permissions.commands + +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.Player +import java.util.UUID + +object PermissionsCommandAccess { + private const val ADMIN_PERMISSION = "grounds.permissions.admin" + + /** + * Non-player sources (e.g. console) are treated as admins by default. Player sources must have + * [ADMIN_PERMISSION] to be considered admin. + */ + fun isAdmin(source: CommandSource): Boolean { + return source !is Player || source.hasPermission(ADMIN_PERMISSION) + } + + fun canAccessPlayer(source: CommandSource, playerId: UUID): Boolean { + return isAdmin(source) || (source is Player && source.uniqueId == playerId) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandContext.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandContext.kt new file mode 100644 index 0000000..83dffad --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandContext.kt @@ -0,0 +1,52 @@ +package gg.grounds.permissions.commands + +import com.google.protobuf.Timestamp +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.ProxyServer +import gg.grounds.permissions.CachedPermissions +import gg.grounds.permissions.PermissionsCache +import gg.grounds.permissions.services.PermissionsAdminService +import gg.grounds.permissions.services.PermissionsService +import java.util.UUID +import net.kyori.adventure.text.Component + +class PermissionsCommandContext( + val proxy: ProxyServer, + val permissionsCache: PermissionsCache, + val permissionsService: PermissionsService, + val permissionsAdminService: PermissionsAdminService, +) { + fun resolvePlayerId(source: CommandSource, raw: String): UUID? { + return PermissionsCommandParser.resolvePlayerId(proxy, source, raw) + } + + fun refreshOnlinePlayers() { + permissionsCache.refreshOnlinePlayers(proxy) + } + + fun refreshPlayer(playerId: UUID): Boolean { + return permissionsCache.refreshPlayer(playerId) + } + + fun getOrRefreshCached(source: CommandSource, playerId: UUID): CachedPermissions? { + permissionsCache.get(playerId)?.let { + return it + } + if (!permissionsCache.refreshPlayer(playerId)) { + source.sendMessage(Component.text("Failed to load permissions.")) + return null + } + return permissionsCache.get(playerId) + } + + fun parseExpiryOrReport(source: CommandSource, rawDuration: String?): Timestamp? { + if (rawDuration == null) { + return null + } + val expiresAt = PermissionsCommandParser.parseExpiresAt(rawDuration) + if (expiresAt == null) { + source.sendMessage(Component.text("Invalid duration format.")) + } + return expiresAt + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandMessages.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandMessages.kt new file mode 100644 index 0000000..ff6e7d0 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandMessages.kt @@ -0,0 +1,97 @@ +package gg.grounds.permissions.commands + +import com.velocitypowered.api.command.CommandSource +import net.kyori.adventure.text.Component + +object PermissionsCommandMessages { + fun sendUsage(source: CommandSource) { + source.sendMessage( + Component.text( + "Usage: /permissions help | " + + "/permissions refresh [player|uuid] | " + + "/permissions player [permission] | " + + "/permissions player permission [duration] | " + + "/permissions player group [duration] | " + + "/permissions group list | " + + "/permissions group | " + + "/permissions group permission [duration]" + ) + ) + } + + fun sendHelp(source: CommandSource) { + val message = + Component.text("Permissions commands:") + .append(Component.newline()) + .append(Component.text("/permissions help - Show this help.")) + .append(Component.newline()) + .append( + Component.text( + "/permissions refresh [player|uuid] - Refresh cached permissions." + ) + ) + .append(Component.newline()) + .append( + Component.text("/permissions player info - Show player info.") + ) + .append(Component.newline()) + .append( + Component.text( + "/permissions player check - Check a permission." + ) + ) + .append(Component.newline()) + .append( + Component.text( + "/permissions player refresh - Refresh a player's cache." + ) + ) + .append(Component.newline()) + .append( + Component.text( + "/permissions player permission add [duration] - Add a player permission." + ) + ) + .append(Component.newline()) + .append( + Component.text( + "/permissions player permission remove - Remove a player permission." + ) + ) + .append(Component.newline()) + .append( + Component.text( + "/permissions player group add [duration] - Add a player group." + ) + ) + .append(Component.newline()) + .append( + Component.text( + "/permissions player group remove - Remove a player group." + ) + ) + .append(Component.newline()) + .append(Component.text("/permissions group list - List available groups.")) + .append(Component.newline()) + .append(Component.text("/permissions group create - Create a group.")) + .append(Component.newline()) + .append(Component.text("/permissions group info - Show group info.")) + .append(Component.newline()) + .append(Component.text("/permissions group delete - Delete a group.")) + .append(Component.newline()) + .append( + Component.text( + "/permissions group permission add [duration] - Add a group permission." + ) + ) + .append(Component.newline()) + .append( + Component.text( + "/permissions group permission remove - Remove a group permission." + ) + ) + .append(Component.newline()) + .append(Component.text("Durations: 30m, 1h, 7d, 2w (s/m/h/d/w).")) + source.sendMessage(message) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandParser.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandParser.kt new file mode 100644 index 0000000..d06db09 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsCommandParser.kt @@ -0,0 +1,51 @@ +package gg.grounds.permissions.commands + +import com.google.protobuf.Timestamp +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.ProxyServer +import java.time.Duration +import java.time.Instant +import java.util.UUID + +object PermissionsCommandParser { + fun resolvePlayerId(proxy: ProxyServer, source: CommandSource, raw: String): UUID? { + val asUuid = runCatching { UUID.fromString(raw) }.getOrNull() + if (asUuid != null) { + return asUuid + } + return proxy.getPlayer(raw).map { it.uniqueId }.orElse(null) + } + + fun parseExpiresAt(rawDuration: String?): Timestamp? { + if (rawDuration == null) { + return null + } + val duration = parseDuration(rawDuration) ?: return null + val expiresAt = Instant.now().plus(duration) + return Timestamp.newBuilder() + .setSeconds(expiresAt.epochSecond) + .setNanos(expiresAt.nano) + .build() + } + + private fun parseDuration(raw: String): Duration? { + val trimmed = raw.trim().lowercase() + if (trimmed.isEmpty()) { + return null + } + val valuePart = trimmed.dropLast(1) + val unit = trimmed.last() + val amount = valuePart.toLongOrNull() ?: return null + if (amount <= 0) { + return null + } + return when (unit) { + 's' -> Duration.ofSeconds(amount) + 'm' -> Duration.ofMinutes(amount) + 'h' -> Duration.ofHours(amount) + 'd' -> Duration.ofDays(amount) + 'w' -> Duration.ofDays(amount * 7) + else -> null + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsGroupCommandHandler.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsGroupCommandHandler.kt new file mode 100644 index 0000000..2493e24 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsGroupCommandHandler.kt @@ -0,0 +1,170 @@ +package gg.grounds.permissions.commands + +import com.velocitypowered.api.command.CommandSource +import gg.grounds.grpc.permissions.AddGroupPermissionsRequest +import gg.grounds.grpc.permissions.CreateGroupRequest +import gg.grounds.grpc.permissions.DeleteGroupRequest +import gg.grounds.grpc.permissions.GetGroupRequest +import gg.grounds.grpc.permissions.ListGroupsRequest +import gg.grounds.grpc.permissions.PermissionGrant +import gg.grounds.grpc.permissions.RemoveGroupPermissionsRequest +import net.kyori.adventure.text.Component + +class PermissionsGroupCommandHandler(private val context: PermissionsCommandContext) { + fun create(source: CommandSource, groupName: String): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage groups.")) + return false + } + val reply = + context.permissionsAdminService.createGroup( + CreateGroupRequest.newBuilder().setGroupName(groupName).build() + ) + ?: run { + source.sendMessage(Component.text("Failed to create group.")) + return false + } + source.sendMessage( + Component.text("Create group '$groupName' result: ${reply.applyResult.name}") + ) + return true + } + + fun list(source: CommandSource): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage groups.")) + return false + } + val reply = + context.permissionsAdminService.listGroups( + ListGroupsRequest.newBuilder().setIncludePermissions(false).build() + ) + ?: run { + source.sendMessage(Component.text("Failed to list groups.")) + return false + } + val groups = + reply.groupsList.map { it.groupName }.sorted().takeIf { it.isNotEmpty() } + ?: listOf("") + source.sendMessage(Component.text("Groups: ${groups.joinToString(", ")}")) + return true + } + + fun info(source: CommandSource, groupName: String): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage groups.")) + return false + } + val reply = + context.permissionsAdminService.getGroup( + GetGroupRequest.newBuilder().setGroupName(groupName).build() + ) + ?: run { + source.sendMessage(Component.text("Failed to load group.")) + return false + } + if (!reply.hasGroup()) { + source.sendMessage(Component.text("Group '$groupName' not found.")) + return false + } + val group = reply.group + val permissions = + group.permissionGrantsList.map { it.permission }.sorted().takeIf { it.isNotEmpty() } + ?: listOf("") + source.sendMessage( + Component.text( + "Group ${group.groupName} info: permissions=${permissions.joinToString(", ")}" + ) + ) + return true + } + + fun delete(source: CommandSource, groupName: String): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage groups.")) + return false + } + val reply = + context.permissionsAdminService.deleteGroup( + DeleteGroupRequest.newBuilder().setGroupName(groupName).build() + ) + ?: run { + source.sendMessage(Component.text("Failed to delete group.")) + return false + } + source.sendMessage( + Component.text("Delete group '$groupName' result: ${reply.applyResult.name}") + ) + context.refreshOnlinePlayers() + return true + } + + fun permissionAdd( + source: CommandSource, + groupName: String, + permission: String, + durationArg: String?, + ): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage groups.")) + return false + } + if (permission.isBlank()) { + source.sendMessage(Component.text("Group permission must be provided.")) + return false + } + val expiresAt = context.parseExpiryOrReport(source, durationArg) + if (durationArg != null && expiresAt == null) { + return false + } + val grantBuilder = PermissionGrant.newBuilder().setPermission(permission) + expiresAt?.let { grantBuilder.setExpiresAt(it) } + val reply = + context.permissionsAdminService.addGroupPermissions( + AddGroupPermissionsRequest.newBuilder() + .setGroupName(groupName) + .addPermissionGrants(grantBuilder.build()) + .build() + ) + ?: run { + source.sendMessage(Component.text("Failed to add group permission.")) + return false + } + source.sendMessage( + Component.text( + "Add permission '$permission' to group '$groupName' result: ${reply.applyResult.name}" + ) + ) + context.refreshOnlinePlayers() + return true + } + + fun permissionRemove(source: CommandSource, groupName: String, permission: String): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage groups.")) + return false + } + if (permission.isBlank()) { + source.sendMessage(Component.text("Group permission must be provided.")) + return false + } + val reply = + context.permissionsAdminService.removeGroupPermissions( + RemoveGroupPermissionsRequest.newBuilder() + .setGroupName(groupName) + .addPermissions(permission) + .build() + ) + ?: run { + source.sendMessage(Component.text("Failed to remove group permission.")) + return false + } + source.sendMessage( + Component.text( + "Remove permission '$permission' from group '$groupName' result: ${reply.applyResult.name}" + ) + ) + context.refreshOnlinePlayers() + return true + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsPlayerCommandHandler.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsPlayerCommandHandler.kt new file mode 100644 index 0000000..28eda58 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsPlayerCommandHandler.kt @@ -0,0 +1,217 @@ +package gg.grounds.permissions.commands + +import com.velocitypowered.api.command.CommandSource +import gg.grounds.grpc.permissions.AddPlayerGroupsRequest +import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest +import gg.grounds.grpc.permissions.PermissionGrant +import gg.grounds.grpc.permissions.PlayerGroupMembership +import gg.grounds.grpc.permissions.RemovePlayerGroupsRequest +import gg.grounds.grpc.permissions.RemovePlayerPermissionsRequest +import java.util.UUID +import net.kyori.adventure.text.Component + +class PermissionsPlayerCommandHandler(private val context: PermissionsCommandContext) { + fun info(source: CommandSource, playerId: UUID): Boolean { + if (!PermissionsCommandAccess.canAccessPlayer(source, playerId)) { + source.sendMessage(Component.text("You do not have permission to list other players.")) + return false + } + + val cached = context.getOrRefreshCached(source, playerId) ?: return false + val directPermissions = + cached.directPermissions.map { it.permission }.sorted().takeIf { it.isNotEmpty() } + ?: listOf("") + val groups = + cached.groupMemberships.map { it.groupName }.sorted().takeIf { it.isNotEmpty() } + ?: listOf("") + val effectivePermissions = + cached.effectivePermissions.sorted().takeIf { it.isNotEmpty() } ?: listOf("") + source.sendMessage( + Component.text( + "Player $playerId info: groups=${groups.joinToString(", ")}, " + + "direct=${directPermissions.joinToString(", ")}, " + + "effective=${effectivePermissions.joinToString(", ")}" + ) + ) + return true + } + + fun check(source: CommandSource, playerId: UUID, permission: String): Boolean { + if (!PermissionsCommandAccess.canAccessPlayer(source, playerId)) { + source.sendMessage(Component.text("You do not have permission to check other players.")) + return false + } + if (permission.isBlank()) { + source.sendMessage(Component.text("Permission must be provided.")) + return false + } + val allowed = + context.permissionsCache.checkCached(playerId, permission) + ?: context.permissionsService.checkPlayerPermission(playerId, permission) + ?: run { + source.sendMessage(Component.text("Failed to check permission.")) + return false + } + source.sendMessage( + Component.text( + "Permission '$permission' for $playerId: ${if (allowed) "allowed" else "denied"}" + ) + ) + return true + } + + fun refresh(source: CommandSource, playerId: UUID): Boolean { + if (!PermissionsCommandAccess.canAccessPlayer(source, playerId)) { + source.sendMessage( + Component.text("You do not have permission to refresh other players.") + ) + return false + } + + if (context.refreshPlayer(playerId)) { + source.sendMessage(Component.text("Permissions refreshed for $playerId.")) + return true + } else { + source.sendMessage(Component.text("Failed to refresh permissions for $playerId.")) + return false + } + } + + fun permissionAdd( + source: CommandSource, + playerId: UUID, + permission: String, + durationArg: String?, + ): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage players.")) + return false + } + if (permission.isBlank()) { + source.sendMessage(Component.text("Permission must be provided.")) + return false + } + val expiresAt = context.parseExpiryOrReport(source, durationArg) + if (durationArg != null && expiresAt == null) { + return false + } + val grantBuilder = PermissionGrant.newBuilder().setPermission(permission) + expiresAt?.let { grantBuilder.setExpiresAt(it) } + val reply = + context.permissionsAdminService.addPlayerPermissions( + AddPlayerPermissionsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addPermissionGrants(grantBuilder.build()) + .build() + ) + ?: run { + source.sendMessage(Component.text("Failed to add player permission.")) + return false + } + source.sendMessage( + Component.text( + "Add permission '$permission' to player $playerId result: ${reply.applyResult.name}" + ) + ) + context.refreshPlayer(playerId) + return true + } + + fun permissionRemove(source: CommandSource, playerId: UUID, permission: String): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage players.")) + return false + } + if (permission.isBlank()) { + source.sendMessage(Component.text("Permission must be provided.")) + return false + } + val reply = + context.permissionsAdminService.removePlayerPermissions( + RemovePlayerPermissionsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addPermissions(permission) + .build() + ) + ?: run { + source.sendMessage(Component.text("Failed to remove player permission.")) + return false + } + source.sendMessage( + Component.text( + "Remove permission '$permission' from player $playerId result: ${reply.applyResult.name}" + ) + ) + context.refreshPlayer(playerId) + return true + } + + fun groupAdd( + source: CommandSource, + playerId: UUID, + groupName: String, + durationArg: String?, + ): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage players.")) + return false + } + if (groupName.isBlank()) { + source.sendMessage(Component.text("Group name must be provided.")) + return false + } + val expiresAt = context.parseExpiryOrReport(source, durationArg) + if (durationArg != null && expiresAt == null) { + return false + } + val membershipBuilder = PlayerGroupMembership.newBuilder().setGroupName(groupName) + expiresAt?.let { membershipBuilder.setExpiresAt(it) } + val reply = + context.permissionsAdminService.addPlayerGroups( + AddPlayerGroupsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addGroupMemberships(membershipBuilder.build()) + .build() + ) + ?: run { + source.sendMessage(Component.text("Failed to add player group.")) + return false + } + source.sendMessage( + Component.text( + "Add group '$groupName' to player $playerId result: ${reply.applyResult.name}" + ) + ) + context.refreshPlayer(playerId) + return true + } + + fun groupRemove(source: CommandSource, playerId: UUID, groupName: String): Boolean { + if (!PermissionsCommandAccess.isAdmin(source)) { + source.sendMessage(Component.text("You do not have permission to manage players.")) + return false + } + if (groupName.isBlank()) { + source.sendMessage(Component.text("Group name must be provided.")) + return false + } + val reply = + context.permissionsAdminService.removePlayerGroups( + RemovePlayerGroupsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addGroupNames(groupName) + .build() + ) + ?: run { + source.sendMessage(Component.text("Failed to remove player group.")) + return false + } + source.sendMessage( + Component.text( + "Remove group '$groupName' from player $playerId result: ${reply.applyResult.name}" + ) + ) + context.refreshPlayer(playerId) + return true + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsSuggestions.kt b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsSuggestions.kt new file mode 100644 index 0000000..14461ed --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/commands/PermissionsSuggestions.kt @@ -0,0 +1,84 @@ +package gg.grounds.permissions.commands + +import com.mojang.brigadier.suggestion.SuggestionProvider +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.ProxyServer +import gg.grounds.grpc.permissions.ListGroupsRequest +import gg.grounds.permissions.services.PermissionsAdminService +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +class PermissionsSuggestions( + private val proxy: ProxyServer, + permissionsAdminService: PermissionsAdminService, +) { + private val groupNameCache = GroupNameCache(permissionsAdminService) + + fun player(): SuggestionProvider = SuggestionProvider { _, builder -> + proxy.allPlayers.forEach { player -> builder.suggest(player.username) } + builder.buildFuture() + } + + fun group(): SuggestionProvider = SuggestionProvider { _, builder -> + val prefix = builder.remaining + groupNameCache.suggest(prefix).thenApply { groups -> + groups.forEach { builder.suggest(it) } + builder.build() + } + } + + fun permission(): SuggestionProvider = SuggestionProvider { _, builder -> + PERMISSION_SAMPLES.filter { it.startsWith(builder.remaining, ignoreCase = true) } + .forEach { builder.suggest(it) } + builder.buildFuture() + } + + fun duration(): SuggestionProvider = SuggestionProvider { _, builder -> + DURATION_SAMPLES.filter { it.startsWith(builder.remaining, ignoreCase = true) } + .forEach { builder.suggest(it) } + builder.buildFuture() + } + + private class GroupNameCache(private val permissionsAdminService: PermissionsAdminService) { + private val names = AtomicReference>(emptyList()) + private val lastRefreshMillis = AtomicLong(0) + private val inFlight = AtomicBoolean(false) + + fun suggest(prefix: String): CompletableFuture> { + val now = System.currentTimeMillis() + val cached = names.get() + if (now - lastRefreshMillis.get() < CACHE_TTL_MILLIS && cached.isNotEmpty()) { + return CompletableFuture.completedFuture( + cached.filter { it.startsWith(prefix, ignoreCase = true) } + ) + } + if (!inFlight.compareAndSet(false, true)) { + return CompletableFuture.completedFuture( + cached.filter { it.startsWith(prefix, ignoreCase = true) } + ) + } + return CompletableFuture.supplyAsync { + val reply = + permissionsAdminService.listGroups( + ListGroupsRequest.newBuilder().setIncludePermissions(false).build() + ) + val updated = reply?.groupsList?.map { it.groupName } ?: cached + names.set(updated) + lastRefreshMillis.set(System.currentTimeMillis()) + updated.filter { it.startsWith(prefix, ignoreCase = true) } + } + .whenComplete { _, _ -> inFlight.set(false) } + } + + companion object { + private const val CACHE_TTL_MILLIS = 30000L + } + } + + companion object { + private val PERMISSION_SAMPLES = listOf("grounds.permissions.admin") + private val DURATION_SAMPLES = listOf("30m", "1h", "12h", "1d", "7d") + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/listener/PermissionsConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/permissions/listener/PermissionsConnectionListener.kt new file mode 100644 index 0000000..9cf8c25 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/listener/PermissionsConnectionListener.kt @@ -0,0 +1,32 @@ +package gg.grounds.permissions.listener + +import com.velocitypowered.api.event.EventTask +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PostLoginEvent +import gg.grounds.permissions.PermissionsCache +import org.slf4j.Logger + +class PermissionsConnectionListener( + private val logger: Logger, + private val permissionsCache: PermissionsCache, +) { + @Subscribe + fun onPostLogin(event: PostLoginEvent): EventTask { + val player = event.player + return EventTask.async { + if (!permissionsCache.cachePlayer(player.uniqueId)) { + logger.warn( + "Failed to load permissions on join (playerId={}, username={})", + player.uniqueId, + player.username, + ) + } + } + } + + @Subscribe + fun onDisconnect(event: DisconnectEvent): EventTask { + return EventTask.async { permissionsCache.removePlayer(event.player.uniqueId) } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/listener/PermissionsSetupListener.kt b/velocity/src/main/kotlin/gg/grounds/permissions/listener/PermissionsSetupListener.kt new file mode 100644 index 0000000..1630dc3 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/listener/PermissionsSetupListener.kt @@ -0,0 +1,13 @@ +package gg.grounds.permissions.listener + +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.permission.PermissionsSetupEvent +import gg.grounds.permissions.CombinedPermissionsProvider +import gg.grounds.permissions.PermissionsCache + +class PermissionsSetupListener(private val permissionsCache: PermissionsCache) { + @Subscribe + fun onPermissionsSetup(event: PermissionsSetupEvent) { + event.provider = CombinedPermissionsProvider(permissionsCache, event.provider) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsAdminService.kt b/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsAdminService.kt new file mode 100644 index 0000000..1d59965 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsAdminService.kt @@ -0,0 +1,90 @@ +package gg.grounds.permissions.services + +import gg.grounds.grpc.permissions.AddGroupPermissionsReply +import gg.grounds.grpc.permissions.AddGroupPermissionsRequest +import gg.grounds.grpc.permissions.AddPlayerGroupsReply +import gg.grounds.grpc.permissions.AddPlayerGroupsRequest +import gg.grounds.grpc.permissions.AddPlayerPermissionsReply +import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest +import gg.grounds.grpc.permissions.CreateGroupReply +import gg.grounds.grpc.permissions.CreateGroupRequest +import gg.grounds.grpc.permissions.DeleteGroupReply +import gg.grounds.grpc.permissions.DeleteGroupRequest +import gg.grounds.grpc.permissions.GetGroupReply +import gg.grounds.grpc.permissions.GetGroupRequest +import gg.grounds.grpc.permissions.ListGroupsReply +import gg.grounds.grpc.permissions.ListGroupsRequest +import gg.grounds.grpc.permissions.RemoveGroupPermissionsReply +import gg.grounds.grpc.permissions.RemoveGroupPermissionsRequest +import gg.grounds.grpc.permissions.RemovePlayerGroupsReply +import gg.grounds.grpc.permissions.RemovePlayerGroupsRequest +import gg.grounds.grpc.permissions.RemovePlayerPermissionsReply +import gg.grounds.grpc.permissions.RemovePlayerPermissionsRequest +import gg.grounds.permissions.GrpcPermissionsAdminClient +import io.grpc.StatusRuntimeException +import org.slf4j.Logger + +class PermissionsAdminService(private val logger: Logger) : AutoCloseable { + private lateinit var client: GrpcPermissionsAdminClient + + fun configure(target: String) { + close() + client = GrpcPermissionsAdminClient.create(target) + } + + fun createGroup(request: CreateGroupRequest): CreateGroupReply? = + call("create group") { client.createGroup(request) } + + fun deleteGroup(request: DeleteGroupRequest): DeleteGroupReply? = + call("delete group") { client.deleteGroup(request) } + + fun getGroup(request: GetGroupRequest): GetGroupReply? = + call("get group") { client.getGroup(request) } + + fun listGroups(request: ListGroupsRequest): ListGroupsReply? = + call("list groups") { client.listGroups(request) } + + fun addGroupPermissions(request: AddGroupPermissionsRequest): AddGroupPermissionsReply? = + call("add group permissions") { client.addGroupPermissions(request) } + + fun removeGroupPermissions( + request: RemoveGroupPermissionsRequest + ): RemoveGroupPermissionsReply? = + call("remove group permissions") { client.removeGroupPermissions(request) } + + fun addPlayerPermissions(request: AddPlayerPermissionsRequest): AddPlayerPermissionsReply? = + call("add player permissions") { client.addPlayerPermissions(request) } + + fun removePlayerPermissions( + request: RemovePlayerPermissionsRequest + ): RemovePlayerPermissionsReply? = + call("remove player permissions") { client.removePlayerPermissions(request) } + + fun addPlayerGroups(request: AddPlayerGroupsRequest): AddPlayerGroupsReply? = + call("add player groups") { client.addPlayerGroups(request) } + + fun removePlayerGroups(request: RemovePlayerGroupsRequest): RemovePlayerGroupsReply? = + call("remove player groups") { client.removePlayerGroups(request) } + + override fun close() { + if (this::client.isInitialized) { + client.close() + } + } + + private fun call(action: String, block: () -> T): T? { + return try { + block() + } catch (e: StatusRuntimeException) { + logFailure(action, e.status.toString()) + null + } catch (e: RuntimeException) { + logFailure(action, e.message ?: e::class.java.name) + null + } + } + + private fun logFailure(action: String, reason: String) { + logger.warn("Permissions admin service failed (action={}, reason={})", action, reason) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsEventsSubscriber.kt b/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsEventsSubscriber.kt new file mode 100644 index 0000000..fa67600 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsEventsSubscriber.kt @@ -0,0 +1,175 @@ +package gg.grounds.permissions.services + +import gg.grounds.grpc.permissions.PermissionsChangeEvent +import gg.grounds.grpc.permissions.SubscribePermissionsChangesRequest +import gg.grounds.permissions.GrpcPermissionsEventsClient +import gg.grounds.permissions.PermissionsCache +import io.grpc.StatusRuntimeException +import io.grpc.stub.StreamObserver +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import org.slf4j.Logger + +class PermissionsEventsSubscriber( + private val logger: Logger, + private val permissionsCache: PermissionsCache, +) : AutoCloseable { + private val executor: ScheduledExecutorService = + Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "permissions-events-subscriber").apply { isDaemon = true } + } + private val closed = AtomicBoolean(false) + private val reconnectDelayMs = AtomicLong(MIN_RECONNECT_DELAY_MS) + private val streamGeneration = AtomicLong(0) + private var lastEventId: String? = null + private var serverId: String? = null + private lateinit var client: GrpcPermissionsEventsClient + private var reconnectFuture: ScheduledFuture<*>? = null + + fun configure(target: String, serverId: String?) { + closed.set(false) + streamGeneration.incrementAndGet() + reconnectFuture?.cancel(false) + reconnectFuture = null + if (this::client.isInitialized) { + client.close() + } + this.serverId = serverId + client = GrpcPermissionsEventsClient.create(target) + startStream(streamGeneration.get()) + } + + private fun startStream(generation: Long) { + if (closed.get()) { + return + } + if (generation != streamGeneration.get()) { + return + } + val requestBuilder = SubscribePermissionsChangesRequest.newBuilder() + serverId?.let { requestBuilder.serverId = it } + lastEventId?.let { requestBuilder.lastEventId = it } + val request = requestBuilder.build() + client.subscribe(request, StreamObserverImpl()) + logger.info( + "Permissions event stream connected (serverId={}, lastEventId={})", + serverId, + lastEventId ?: "none", + ) + } + + private fun scheduleReconnect(reason: String) { + if (closed.get()) { + return + } + val delayMs = reconnectDelayMs.get() + val generation = streamGeneration.get() + logger.warn( + "Permissions event stream disconnected (reason={}, retryInMs={})", + reason, + delayMs, + ) + reconnectFuture?.cancel(false) + reconnectFuture = + executor.schedule({ startStream(generation) }, delayMs, TimeUnit.MILLISECONDS) + val nextDelay = (delayMs * 2).coerceAtMost(MAX_RECONNECT_DELAY_MS) + reconnectDelayMs.set(nextDelay) + } + + private fun resetBackoff() { + reconnectDelayMs.set(MIN_RECONNECT_DELAY_MS) + } + + override fun close() { + closed.set(true) + reconnectFuture?.cancel(false) + reconnectFuture = null + if (this::client.isInitialized) { + client.close() + } + executor.shutdownNow() + } + + private inner class StreamObserverImpl : StreamObserver { + override fun onNext(event: PermissionsChangeEvent) { + resetBackoff() + lastEventId = event.eventId + val playerId = + try { + UUID.fromString(event.playerId) + } catch (error: IllegalArgumentException) { + logger.warn( + "Permissions change event rejected (eventId={}, reason=invalid_player_id)", + event.eventId, + ) + return + } + if (permissionsCache.get(playerId) == null) { + logger.debug( + "Permissions change event skipped (playerId={}, eventId={}, reason=player_not_cached)", + playerId, + event.eventId, + ) + return + } + if (event.requiresFullRefresh) { + val refreshed = permissionsCache.refreshPlayer(playerId) + if (!refreshed) { + logger.warn( + "Permissions refresh failed (playerId={}, eventId={}, reason=refresh_failed)", + playerId, + event.eventId, + ) + return + } + logger.debug( + "Permissions refresh applied (playerId={}, eventId={}, reason={})", + playerId, + event.eventId, + event.reason, + ) + return + } + val applied = permissionsCache.applyChangeEvent(event) + if (!applied) { + logger.warn( + "Permissions change event rejected (playerId={}, eventId={}, reason=apply_failed)", + playerId, + event.eventId, + ) + return + } + logger.debug( + "Permissions change event applied (playerId={}, eventId={}, reason={})", + playerId, + event.eventId, + event.reason, + ) + } + + override fun onError(error: Throwable) { + val reason = + if (error is StatusRuntimeException) { + val description = error.status.description ?: "none" + "${error.status.code.name}:${description}" + } else { + error.message ?: error::class.java.name + } + scheduleReconnect(reason) + } + + override fun onCompleted() { + scheduleReconnect("completed") + } + } + + companion object { + private const val MIN_RECONNECT_DELAY_MS = 1000L + private const val MAX_RECONNECT_DELAY_MS = 30000L + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsService.kt b/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsService.kt new file mode 100644 index 0000000..35fa56c --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/services/PermissionsService.kt @@ -0,0 +1,83 @@ +package gg.grounds.permissions.services + +import gg.grounds.grpc.permissions.PlayerPermissions +import gg.grounds.permissions.GrpcPermissionsClient +import io.grpc.StatusRuntimeException +import java.util.UUID +import org.slf4j.Logger + +class PermissionsService(private val logger: Logger) : AutoCloseable { + private lateinit var client: GrpcPermissionsClient + + fun configure(target: String) { + close() + client = GrpcPermissionsClient.create(target) + } + + fun fetchPlayerPermissions(playerId: UUID): PlayerPermissions? { + return try { + client.getPlayerPermissions(playerId).player + } catch (e: StatusRuntimeException) { + logStatusFailure(playerId, null, e) + null + } catch (e: RuntimeException) { + logFailure( + playerId, + null, + "Permissions service request failed", + e.message ?: e::class.java.name, + ) + null + } + } + + fun checkPlayerPermission(playerId: UUID, permission: String): Boolean? { + return try { + client.checkPlayerPermission(playerId, permission).allowed + } catch (e: StatusRuntimeException) { + logStatusFailure(playerId, permission, e) + null + } catch (e: RuntimeException) { + logFailure( + playerId, + permission, + "Permissions service request failed", + e.message ?: e::class.java.name, + ) + null + } + } + + override fun close() { + if (this::client.isInitialized) { + client.close() + } + } + + private fun logStatusFailure( + playerId: UUID, + permission: String?, + exception: StatusRuntimeException, + ) { + val message = + if (GrpcPermissionsClient.isServiceUnavailable(exception)) { + "Permissions service unavailable" + } else { + "Permissions service request failed" + } + logFailure(playerId, permission, message, exception.status) + } + + private fun logFailure(playerId: UUID, permission: String?, message: String, reason: Any) { + if (permission == null) { + logger.warn("$message (playerId={}, reason={})", playerId, reason) + } else { + logger.warn( + "$message (playerId={}, permission={}, reason={})", + playerId, + permission, + reason, + ) + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt index ba6fa37..1e6a022 100644 --- a/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/PlayerPresenceService.kt @@ -1,8 +1,6 @@ package gg.grounds.presence import gg.grounds.grpc.player.PlayerLogoutReply -import gg.grounds.player.presence.GrpcPlayerPresenceClient -import gg.grounds.player.presence.PlayerLoginResult import java.util.UUID class PlayerPresenceService : AutoCloseable { diff --git a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/presence/listener/PlayerConnectionListener.kt similarity index 98% rename from velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt rename to velocity/src/main/kotlin/gg/grounds/presence/listener/PlayerConnectionListener.kt index be56f94..d63ebdb 100644 --- a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/presence/listener/PlayerConnectionListener.kt @@ -1,4 +1,4 @@ -package gg.grounds.listener +package gg.grounds.presence.listener import com.velocitypowered.api.event.EventTask import com.velocitypowered.api.event.Subscribe @@ -8,7 +8,7 @@ import com.velocitypowered.api.util.UuidUtils import gg.grounds.config.MessagesConfig import gg.grounds.grpc.player.LoginStatus import gg.grounds.grpc.player.PlayerLoginReply -import gg.grounds.player.presence.PlayerLoginResult +import gg.grounds.presence.PlayerLoginResult import gg.grounds.presence.PlayerPresenceService import java.util.UUID import net.kyori.adventure.text.Component diff --git a/velocity/src/main/resources/messages.yml b/velocity/src/main/resources/messages.yml index ce7ef24..edf5086 100644 --- a/velocity/src/main/resources/messages.yml +++ b/velocity/src/main/resources/messages.yml @@ -1,4 +1,4 @@ -serviceUnavailable: "Login service unavailable" -alreadyOnline: "You are already online." +serviceUnavailable: "Grounds Login services are currently unavailable" +alreadyOnline: "You are already online on Grounds network." invalidRequest: "Invalid login request." genericError: "Unable to create player session."