From 73c43110bea75244704f3f3f21c92faccb90b860 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 8 Mar 2026 21:03:03 +0100 Subject: [PATCH 1/5] feat: add player permissions endpoints # Conflicts: # build.gradle.kts # src/main/kotlin/gg/grounds/api/PlayerPresenceGrpcService.kt # src/main/kotlin/gg/grounds/persistence/PlayerSessionRepository.kt --- build.gradle.kts | 1 + devspace.yaml | 5 +- .../api/permissions/ApplyResultMapper.kt | 16 ++ .../PermissionsAdminGrpcService.kt | 92 +++++++ .../PermissionsPluginGrpcService.kt | 111 ++++++++ .../api/permissions/PermissionsProtoMapper.kt | 24 ++ .../permissions/PermissionsRequestParser.kt | 16 ++ .../permissions/admin/GroupAdminHandler.kt | 163 ++++++++++++ .../admin/PlayerGroupsAdminHandler.kt | 98 ++++++++ .../admin/PlayerPermissionsAdminHandler.kt | 100 ++++++++ .../kotlin/gg/grounds/domain/ApplyOutcome.kt | 9 + .../gg/grounds/domain/PermissionGroup.kt | 3 + .../grounds/domain/PlayerPermissionsData.kt | 10 + .../permissions/BatchUpdateHelper.kt | 9 + .../permissions/GroupRepository.kt | 236 ++++++++++++++++++ .../permissions/PermissionsQueryRepository.kt | 155 ++++++++++++ .../permissions/PlayerGroupRepository.kt | 104 ++++++++ .../permissions/PlayerPermissionRepository.kt | 104 ++++++++ src/main/resources/application.properties | 2 +- .../db/migration/V2__create_permissions.sql | 25 ++ .../PermissionsPluginGrpcServiceTest.kt | 150 +++++++++++ .../admin/GroupAdminHandlerTest.kt | 71 ++++++ .../admin/PlayerGroupsAdminHandlerTest.kt | 64 +++++ .../PlayerPermissionsAdminHandlerTest.kt | 66 +++++ 24 files changed, 1631 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/gg/grounds/api/permissions/ApplyResultMapper.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/PermissionsAdminGrpcService.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcService.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt create mode 100644 src/main/kotlin/gg/grounds/domain/ApplyOutcome.kt create mode 100644 src/main/kotlin/gg/grounds/domain/PermissionGroup.kt create mode 100644 src/main/kotlin/gg/grounds/domain/PlayerPermissionsData.kt create mode 100644 src/main/kotlin/gg/grounds/persistence/permissions/BatchUpdateHelper.kt create mode 100644 src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt create mode 100644 src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt create mode 100644 src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt create mode 100644 src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt create mode 100644 src/main/resources/db/migration/V2__create_permissions.sql create mode 100644 src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt create mode 100644 src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt create mode 100644 src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt create mode 100644 src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index c1b75cb..2fc9837 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation("io.quarkus:quarkus-kotlin") implementation("io.quarkus:quarkus-scheduler") implementation("gg.grounds:library-grpc-contracts-player:0.2.0") + implementation("gg.grounds:library-grpc-contracts-permission:local-SNAPSHOT") compileOnly("com.google.protobuf:protobuf-kotlin") diff --git a/devspace.yaml b/devspace.yaml index 60b8690..8a99709 100644 --- a/devspace.yaml +++ b/devspace.yaml @@ -12,7 +12,8 @@ deployments: values: deployment: image: - repository: service-player + repository: ghcr.io/groundsgg/service-player + tag: edge containerPort: 9000 env: - name: POSTGRES_URL @@ -25,7 +26,7 @@ deployments: dev: app: # Search for the container that runs this image - imageSelector: service-player + imageSelector: ghcr.io/groundsgg/service-player # Replace the container image with this image devImage: gradle:9.2.1-jdk25 # Sync files between the local filesystem and the development container diff --git a/src/main/kotlin/gg/grounds/api/permissions/ApplyResultMapper.kt b/src/main/kotlin/gg/grounds/api/permissions/ApplyResultMapper.kt new file mode 100644 index 0000000..ea23639 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/ApplyResultMapper.kt @@ -0,0 +1,16 @@ +package gg.grounds.api.permissions + +import gg.grounds.domain.ApplyOutcome +import gg.grounds.grpc.permissions.ApplyResult + +object ApplyResultMapper { + fun toProto(outcome: ApplyOutcome): ApplyResult { + return when (outcome) { + ApplyOutcome.NO_CHANGE -> ApplyResult.NO_CHANGE + ApplyOutcome.CREATED -> ApplyResult.CREATED + ApplyOutcome.UPDATED -> ApplyResult.UPDATED + ApplyOutcome.DELETED -> ApplyResult.DELETED + ApplyOutcome.ERROR -> ApplyResult.APPLY_RESULT_UNSPECIFIED + } + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/PermissionsAdminGrpcService.kt b/src/main/kotlin/gg/grounds/api/permissions/PermissionsAdminGrpcService.kt new file mode 100644 index 0000000..b3443f3 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/PermissionsAdminGrpcService.kt @@ -0,0 +1,92 @@ +package gg.grounds.api.permissions + +import gg.grounds.api.permissions.admin.GroupAdminHandler +import gg.grounds.api.permissions.admin.PlayerGroupsAdminHandler +import gg.grounds.api.permissions.admin.PlayerPermissionsAdminHandler +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.PermissionsAdminService +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.quarkus.grpc.GrpcService +import io.smallrye.common.annotation.Blocking +import io.smallrye.mutiny.Uni +import jakarta.inject.Inject + +@GrpcService +@Blocking +class PermissionsAdminGrpcService +@Inject +constructor( + private val groupAdminHandler: GroupAdminHandler, + private val playerPermissionsAdminHandler: PlayerPermissionsAdminHandler, + private val playerGroupsAdminHandler: PlayerGroupsAdminHandler, +) : PermissionsAdminService { + override fun createGroup(request: CreateGroupRequest): Uni { + return Uni.createFrom().item { groupAdminHandler.createGroup(request) } + } + + override fun deleteGroup(request: DeleteGroupRequest): Uni { + return Uni.createFrom().item { groupAdminHandler.deleteGroup(request) } + } + + override fun getGroup(request: GetGroupRequest): Uni { + return Uni.createFrom().item { groupAdminHandler.getGroup(request) } + } + + override fun listGroups(request: ListGroupsRequest): Uni { + return Uni.createFrom().item { groupAdminHandler.listGroups(request) } + } + + override fun addGroupPermissions( + request: AddGroupPermissionsRequest + ): Uni { + return Uni.createFrom().item { groupAdminHandler.addGroupPermissions(request) } + } + + override fun removeGroupPermissions( + request: RemoveGroupPermissionsRequest + ): Uni { + return Uni.createFrom().item { groupAdminHandler.removeGroupPermissions(request) } + } + + override fun addPlayerPermissions( + request: AddPlayerPermissionsRequest + ): Uni { + return Uni.createFrom().item { playerPermissionsAdminHandler.addPlayerPermissions(request) } + } + + override fun removePlayerPermissions( + request: RemovePlayerPermissionsRequest + ): Uni { + return Uni.createFrom().item { + playerPermissionsAdminHandler.removePlayerPermissions(request) + } + } + + override fun addPlayerGroups(request: AddPlayerGroupsRequest): Uni { + return Uni.createFrom().item { playerGroupsAdminHandler.addPlayerGroups(request) } + } + + override fun removePlayerGroups( + request: RemovePlayerGroupsRequest + ): Uni { + return Uni.createFrom().item { playerGroupsAdminHandler.removePlayerGroups(request) } + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcService.kt b/src/main/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcService.kt new file mode 100644 index 0000000..c00c0cb --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcService.kt @@ -0,0 +1,111 @@ +package gg.grounds.api.permissions + +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.PermissionsService +import gg.grounds.persistence.permissions.PermissionsQueryRepository +import io.grpc.Status +import io.quarkus.grpc.GrpcService +import io.smallrye.common.annotation.Blocking +import io.smallrye.mutiny.Uni +import jakarta.inject.Inject +import org.jboss.logging.Logger + +@GrpcService +@Blocking +class PermissionsPluginGrpcService +@Inject +constructor(private val queryRepository: PermissionsQueryRepository) : PermissionsService { + override fun getPlayerPermissions( + request: GetPlayerPermissionsRequest + ): Uni { + return Uni.createFrom().item { handleGetPlayerPermissions(request) } + } + + override fun checkPlayerPermission( + request: CheckPlayerPermissionRequest + ): Uni { + return Uni.createFrom().item { handleCheckPlayerPermission(request) } + } + + private fun handleGetPlayerPermissions( + request: GetPlayerPermissionsRequest + ): GetPlayerPermissionsReply { + val rawPlayerId = request.playerId?.trim().orEmpty() + val playerId = + PermissionsRequestParser.parsePlayerId(rawPlayerId) + ?: run { + LOG.warnf( + "Get player permissions request rejected (playerId=%s, reason=invalid_player_id)", + rawPlayerId, + ) + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() + } + + val data = + queryRepository.getPlayerPermissions( + playerId, + request.includeEffectivePermissions, + request.includeDirectPermissions, + request.includeGroups, + ) + ?: throw Status.INTERNAL.withDescription("Failed to load player permissions") + .asRuntimeException() + + LOG.debugf( + "Fetch player permissions completed (playerId=%s, includeEffectivePermissions=%s, includeDirectPermissions=%s, includeGroups=%s)", + playerId, + request.includeEffectivePermissions, + request.includeDirectPermissions, + request.includeGroups, + ) + return GetPlayerPermissionsReply.newBuilder() + .setPlayer(PermissionsProtoMapper.toProto(data)) + .build() + } + + private fun handleCheckPlayerPermission( + request: CheckPlayerPermissionRequest + ): CheckPlayerPermissionReply { + val rawPlayerId = request.playerId?.trim().orEmpty() + val playerId = + PermissionsRequestParser.parsePlayerId(rawPlayerId) + ?: run { + LOG.warnf( + "Check player permission request rejected (playerId=%s, reason=invalid_player_id)", + rawPlayerId, + ) + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() + } + val permission = request.permission?.trim().orEmpty() + if (permission.isEmpty()) { + LOG.warnf( + "Check player permission request rejected (playerId=%s, reason=empty_permission)", + playerId, + ) + throw Status.INVALID_ARGUMENT.withDescription("permission must be provided") + .asRuntimeException() + } + + val allowed = + queryRepository.checkPlayerPermission(playerId, permission) + ?: throw Status.INTERNAL.withDescription("Failed to check player permission") + .asRuntimeException() + + LOG.debugf( + "Check player permission completed (playerId=%s, permission=%s, allowed=%s)", + playerId, + permission, + allowed, + ) + return CheckPlayerPermissionReply.newBuilder().setAllowed(allowed).build() + } + + companion object { + private val LOG = Logger.getLogger(PermissionsPluginGrpcService::class.java) + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt b/src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt new file mode 100644 index 0000000..c124f95 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt @@ -0,0 +1,24 @@ +package gg.grounds.api.permissions + +import gg.grounds.domain.PermissionGroup +import gg.grounds.domain.PlayerPermissionsData +import gg.grounds.grpc.permissions.Group +import gg.grounds.grpc.permissions.PlayerPermissions + +object PermissionsProtoMapper { + fun toProto(data: PlayerPermissionsData): PlayerPermissions { + return PlayerPermissions.newBuilder() + .setPlayerId(data.playerId.toString()) + .addAllGroupNames(data.groupNames) + .addAllDirectPermissions(data.directPermissions) + .addAllEffectivePermissions(data.effectivePermissions) + .build() + } + + fun toProto(group: PermissionGroup): Group { + return Group.newBuilder() + .setGroupName(group.name) + .addAllPermissions(group.permissions) + .build() + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt b/src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt new file mode 100644 index 0000000..2fa9e52 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt @@ -0,0 +1,16 @@ +package gg.grounds.api.permissions + +import java.util.UUID + +object PermissionsRequestParser { + fun parsePlayerId(value: String?): UUID? { + return value + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { runCatching { UUID.fromString(it) }.getOrNull() } + } + + fun sanitize(values: List): Set { + return values.mapNotNull { it.trim().takeIf { name -> name.isNotEmpty() } }.toSet() + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt new file mode 100644 index 0000000..b2126f1 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt @@ -0,0 +1,163 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.api.permissions.ApplyResultMapper +import gg.grounds.api.permissions.PermissionsProtoMapper +import gg.grounds.api.permissions.PermissionsRequestParser +import gg.grounds.domain.ApplyOutcome +import gg.grounds.grpc.permissions.AddGroupPermissionsReply +import gg.grounds.grpc.permissions.AddGroupPermissionsRequest +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.persistence.permissions.GroupRepository +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import org.jboss.logging.Logger + +@ApplicationScoped +class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRepository) { + fun createGroup(request: CreateGroupRequest): CreateGroupReply { + val groupName = request.groupName?.trim().orEmpty() + if (groupName.isEmpty()) { + LOG.warnf("Create group request rejected (reason=empty_group_name)") + return CreateGroupReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + + val outcome = groupRepository.createGroup(groupName) + if (outcome != ApplyOutcome.ERROR) { + LOG.infof( + "Create permission group completed (groupName=%s, outcome=%s)", + groupName, + outcome, + ) + } + return CreateGroupReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(outcome)) + .build() + } + + fun deleteGroup(request: DeleteGroupRequest): DeleteGroupReply { + val groupName = request.groupName?.trim().orEmpty() + if (groupName.isEmpty()) { + LOG.warnf("Delete group request rejected (reason=empty_group_name)") + return DeleteGroupReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + + val outcome = groupRepository.deleteGroup(groupName) + if (outcome != ApplyOutcome.ERROR) { + LOG.infof( + "Delete permission group completed (groupName=%s, outcome=%s)", + groupName, + outcome, + ) + } + return DeleteGroupReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(outcome)) + .build() + } + + fun getGroup(request: GetGroupRequest): GetGroupReply { + val groupName = request.groupName?.trim().orEmpty() + if (groupName.isEmpty()) { + LOG.warnf("Get group request rejected (reason=empty_group_name)") + return GetGroupReply.getDefaultInstance() + } + + val group = + groupRepository.getGroup(groupName, includePermissions = true) + ?: run { + LOG.debugf( + "Fetch permission group completed (groupName=%s, result=not_found)", + groupName, + ) + return GetGroupReply.getDefaultInstance() + } + LOG.debugf("Fetch permission group completed (groupName=%s, result=found)", groupName) + return GetGroupReply.newBuilder().setGroup(PermissionsProtoMapper.toProto(group)).build() + } + + fun listGroups(request: ListGroupsRequest): ListGroupsReply { + val groups = groupRepository.listGroups(request.includePermissions) + LOG.debugf( + "Listed permission groups successfully (includePermissions=%s, groupCount=%d)", + request.includePermissions, + groups.size, + ) + val builder = ListGroupsReply.newBuilder() + groups.forEach { group -> builder.addGroups(PermissionsProtoMapper.toProto(group)) } + return builder.build() + } + + fun addGroupPermissions(request: AddGroupPermissionsRequest): AddGroupPermissionsReply { + val groupName = request.groupName?.trim().orEmpty() + val permissions = PermissionsRequestParser.sanitize(request.permissionsList) + if (groupName.isEmpty() || permissions.isEmpty()) { + LOG.warnf( + "Add group permissions request rejected (groupName=%s, permissionsCount=%d, reason=invalid_input)", + groupName, + permissions.size, + ) + return AddGroupPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + + val outcome = groupRepository.addGroupPermissions(groupName, permissions) + if (outcome != ApplyOutcome.ERROR) { + LOG.infof( + "Add group permissions completed (groupName=%s, permissionsCount=%d, outcome=%s)", + groupName, + permissions.size, + outcome, + ) + } + return AddGroupPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(outcome)) + .build() + } + + fun removeGroupPermissions( + request: RemoveGroupPermissionsRequest + ): RemoveGroupPermissionsReply { + val groupName = request.groupName?.trim().orEmpty() + val permissions = PermissionsRequestParser.sanitize(request.permissionsList) + if (groupName.isEmpty() || permissions.isEmpty()) { + LOG.warnf( + "Remove group permissions request rejected (groupName=%s, permissionsCount=%d, reason=invalid_input)", + groupName, + permissions.size, + ) + return RemoveGroupPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + + val outcome = groupRepository.removeGroupPermissions(groupName, permissions) + if (outcome != ApplyOutcome.ERROR) { + LOG.infof( + "Remove group permissions completed (groupName=%s, permissionsCount=%d, outcome=%s)", + groupName, + permissions.size, + outcome, + ) + } + return RemoveGroupPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(outcome)) + .build() + } + + companion object { + private val LOG = Logger.getLogger(GroupAdminHandler::class.java) + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt new file mode 100644 index 0000000..5d04c2a --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt @@ -0,0 +1,98 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.api.permissions.ApplyResultMapper +import gg.grounds.api.permissions.PermissionsRequestParser +import gg.grounds.domain.ApplyOutcome +import gg.grounds.grpc.permissions.AddPlayerGroupsReply +import gg.grounds.grpc.permissions.AddPlayerGroupsRequest +import gg.grounds.grpc.permissions.RemovePlayerGroupsReply +import gg.grounds.grpc.permissions.RemovePlayerGroupsRequest +import gg.grounds.persistence.permissions.PlayerGroupRepository +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import org.jboss.logging.Logger + +@ApplicationScoped +class PlayerGroupsAdminHandler +@Inject +constructor(private val playerGroupRepository: PlayerGroupRepository) { + fun addPlayerGroups(request: AddPlayerGroupsRequest): AddPlayerGroupsReply { + val rawPlayerId = request.playerId?.trim().orEmpty() + val playerId = + PermissionsRequestParser.parsePlayerId(rawPlayerId) + ?: run { + LOG.warnf( + "Add player groups request rejected (playerId=%s, reason=invalid_player_id)", + rawPlayerId, + ) + return AddPlayerGroupsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + val groupNames = PermissionsRequestParser.sanitize(request.groupNamesList) + if (groupNames.isEmpty()) { + LOG.warnf( + "Add player groups request rejected (playerId=%s, reason=empty_group_names)", + playerId, + ) + return AddPlayerGroupsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + + val outcome = playerGroupRepository.addPlayerGroups(playerId, groupNames) + if (outcome != ApplyOutcome.ERROR) { + LOG.infof( + "Add player groups completed (playerId=%s, groupCount=%d, outcome=%s)", + playerId, + groupNames.size, + outcome, + ) + } + return AddPlayerGroupsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(outcome)) + .build() + } + + fun removePlayerGroups(request: RemovePlayerGroupsRequest): RemovePlayerGroupsReply { + val rawPlayerId = request.playerId?.trim().orEmpty() + val playerId = + PermissionsRequestParser.parsePlayerId(rawPlayerId) + ?: run { + LOG.warnf( + "Remove player groups request rejected (playerId=%s, reason=invalid_player_id)", + rawPlayerId, + ) + return RemovePlayerGroupsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + val groupNames = PermissionsRequestParser.sanitize(request.groupNamesList) + if (groupNames.isEmpty()) { + LOG.warnf( + "Remove player groups request rejected (playerId=%s, reason=empty_group_names)", + playerId, + ) + return RemovePlayerGroupsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + + val outcome = playerGroupRepository.removePlayerGroups(playerId, groupNames) + if (outcome != ApplyOutcome.ERROR) { + LOG.infof( + "Remove player groups completed (playerId=%s, groupCount=%d, outcome=%s)", + playerId, + groupNames.size, + outcome, + ) + } + return RemovePlayerGroupsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(outcome)) + .build() + } + + companion object { + private val LOG = Logger.getLogger(PlayerGroupsAdminHandler::class.java) + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt new file mode 100644 index 0000000..cb0a5a9 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt @@ -0,0 +1,100 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.api.permissions.ApplyResultMapper +import gg.grounds.api.permissions.PermissionsRequestParser +import gg.grounds.domain.ApplyOutcome +import gg.grounds.grpc.permissions.AddPlayerPermissionsReply +import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest +import gg.grounds.grpc.permissions.RemovePlayerPermissionsReply +import gg.grounds.grpc.permissions.RemovePlayerPermissionsRequest +import gg.grounds.persistence.permissions.PlayerPermissionRepository +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import org.jboss.logging.Logger + +@ApplicationScoped +class PlayerPermissionsAdminHandler +@Inject +constructor(private val playerPermissionRepository: PlayerPermissionRepository) { + fun addPlayerPermissions(request: AddPlayerPermissionsRequest): AddPlayerPermissionsReply { + val rawPlayerId = request.playerId?.trim().orEmpty() + val playerId = + PermissionsRequestParser.parsePlayerId(rawPlayerId) + ?: run { + LOG.warnf( + "Add player permissions request rejected (playerId=%s, reason=invalid_player_id)", + rawPlayerId, + ) + return AddPlayerPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + val permissions = PermissionsRequestParser.sanitize(request.permissionsList) + if (permissions.isEmpty()) { + LOG.warnf( + "Add player permissions request rejected (playerId=%s, reason=empty_permissions)", + playerId, + ) + return AddPlayerPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + + val outcome = playerPermissionRepository.addPlayerPermissions(playerId, permissions) + if (outcome != ApplyOutcome.ERROR) { + LOG.infof( + "Add player permissions completed (playerId=%s, permissionsCount=%d, outcome=%s)", + playerId, + permissions.size, + outcome, + ) + } + return AddPlayerPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(outcome)) + .build() + } + + fun removePlayerPermissions( + request: RemovePlayerPermissionsRequest + ): RemovePlayerPermissionsReply { + val rawPlayerId = request.playerId?.trim().orEmpty() + val playerId = + PermissionsRequestParser.parsePlayerId(rawPlayerId) + ?: run { + LOG.warnf( + "Remove player permissions request rejected (playerId=%s, reason=invalid_player_id)", + rawPlayerId, + ) + return RemovePlayerPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + val permissions = PermissionsRequestParser.sanitize(request.permissionsList) + if (permissions.isEmpty()) { + LOG.warnf( + "Remove player permissions request rejected (playerId=%s, reason=empty_permissions)", + playerId, + ) + return RemovePlayerPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) + .build() + } + + val outcome = playerPermissionRepository.removePlayerPermissions(playerId, permissions) + if (outcome != ApplyOutcome.ERROR) { + LOG.infof( + "Remove player permissions completed (playerId=%s, permissionsCount=%d, outcome=%s)", + playerId, + permissions.size, + outcome, + ) + } + return RemovePlayerPermissionsReply.newBuilder() + .setApplyResult(ApplyResultMapper.toProto(outcome)) + .build() + } + + companion object { + private val LOG = Logger.getLogger(PlayerPermissionsAdminHandler::class.java) + } +} diff --git a/src/main/kotlin/gg/grounds/domain/ApplyOutcome.kt b/src/main/kotlin/gg/grounds/domain/ApplyOutcome.kt new file mode 100644 index 0000000..1fa50f5 --- /dev/null +++ b/src/main/kotlin/gg/grounds/domain/ApplyOutcome.kt @@ -0,0 +1,9 @@ +package gg.grounds.domain + +enum class ApplyOutcome { + NO_CHANGE, + CREATED, + UPDATED, + DELETED, + ERROR, +} diff --git a/src/main/kotlin/gg/grounds/domain/PermissionGroup.kt b/src/main/kotlin/gg/grounds/domain/PermissionGroup.kt new file mode 100644 index 0000000..01fca07 --- /dev/null +++ b/src/main/kotlin/gg/grounds/domain/PermissionGroup.kt @@ -0,0 +1,3 @@ +package gg.grounds.domain + +data class PermissionGroup(val name: String, val permissions: Set) diff --git a/src/main/kotlin/gg/grounds/domain/PlayerPermissionsData.kt b/src/main/kotlin/gg/grounds/domain/PlayerPermissionsData.kt new file mode 100644 index 0000000..0d6ef7e --- /dev/null +++ b/src/main/kotlin/gg/grounds/domain/PlayerPermissionsData.kt @@ -0,0 +1,10 @@ +package gg.grounds.domain + +import java.util.UUID + +data class PlayerPermissionsData( + val playerId: UUID, + val groupNames: Set, + val directPermissions: Set, + val effectivePermissions: Set, +) diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/BatchUpdateHelper.kt b/src/main/kotlin/gg/grounds/persistence/permissions/BatchUpdateHelper.kt new file mode 100644 index 0000000..f5dc8ba --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/BatchUpdateHelper.kt @@ -0,0 +1,9 @@ +package gg.grounds.persistence.permissions + +import java.sql.Statement + +object BatchUpdateHelper { + fun countSuccessful(results: IntArray): Int { + return results.count { it == Statement.SUCCESS_NO_INFO || it > 0 } + } +} diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt new file mode 100644 index 0000000..d6b60d4 --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt @@ -0,0 +1,236 @@ +package gg.grounds.persistence.permissions + +import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.PermissionGroup +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import java.sql.ResultSet +import java.sql.SQLException +import javax.sql.DataSource +import org.jboss.logging.Logger + +@ApplicationScoped +class GroupRepository @Inject constructor(private val dataSource: DataSource) { + fun createGroup(groupName: String): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(INSERT_GROUP).use { statement -> + statement.setString(1, groupName) + if (statement.executeUpdate() > 0) ApplyOutcome.CREATED + else ApplyOutcome.NO_CHANGE + } + } + } catch (error: SQLException) { + LOG.errorf(error, "Failed to create permission group (groupName=%s)", groupName) + ApplyOutcome.ERROR + } + } + + fun deleteGroup(groupName: String): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(DELETE_GROUP).use { statement -> + statement.setString(1, groupName) + if (statement.executeUpdate() > 0) ApplyOutcome.DELETED + else ApplyOutcome.NO_CHANGE + } + } + } catch (error: SQLException) { + LOG.errorf(error, "Failed to delete permission group (groupName=%s)", groupName) + ApplyOutcome.ERROR + } + } + + fun getGroup(groupName: String, includePermissions: Boolean): PermissionGroup? { + return try { + dataSource.connection.use { connection -> + if (!includePermissions) { + connection.prepareStatement(SELECT_GROUP).use { statement -> + statement.setString(1, groupName) + statement.executeQuery().use { resultSet -> + return if (resultSet.next()) PermissionGroup(groupName, emptySet()) + else null + } + } + } + + connection.prepareStatement(SELECT_GROUP_WITH_PERMISSIONS).use { statement -> + statement.setString(1, groupName) + statement.executeQuery().use { resultSet -> + buildGroupFromResult(groupName, resultSet) + } + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to load permission group (groupName=%s, includePermissions=%s)", + groupName, + includePermissions, + ) + null + } + } + + fun listGroups(includePermissions: Boolean): List { + return try { + dataSource.connection.use { connection -> + if (!includePermissions) { + connection.prepareStatement(SELECT_GROUPS).use { statement -> + statement.executeQuery().use { resultSet -> + val groups = mutableListOf() + while (resultSet.next()) { + groups.add(PermissionGroup(resultSet.getString("name"), emptySet())) + } + return groups + } + } + } + + connection.prepareStatement(SELECT_GROUPS_WITH_PERMISSIONS).use { statement -> + statement.executeQuery().use { resultSet -> buildGroupsFromResult(resultSet) } + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to list permission groups (includePermissions=%s)", + includePermissions, + ) + emptyList() + } + } + + fun addGroupPermissions(groupName: String, permissions: Collection): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(INSERT_GROUP_PERMISSION).use { statement -> + permissions.forEach { permission -> + statement.setString(1, groupName) + statement.setString(2, permission) + statement.addBatch() + } + val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 + if (updated) ApplyOutcome.UPDATED else ApplyOutcome.NO_CHANGE + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to add permissions to group (groupName=%s, permissionsCount=%d)", + groupName, + permissions.size, + ) + ApplyOutcome.ERROR + } + } + + fun removeGroupPermissions(groupName: String, permissions: Collection): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(DELETE_GROUP_PERMISSION).use { statement -> + permissions.forEach { permission -> + statement.setString(1, groupName) + statement.setString(2, permission) + statement.addBatch() + } + val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 + if (updated) ApplyOutcome.UPDATED else ApplyOutcome.NO_CHANGE + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to remove permissions from group (groupName=%s, permissionsCount=%d)", + groupName, + permissions.size, + ) + ApplyOutcome.ERROR + } + } + + private fun buildGroupFromResult(groupName: String, resultSet: ResultSet): PermissionGroup? { + val permissions = linkedSetOf() + var sawGroup = false + while (resultSet.next()) { + sawGroup = true + resultSet.getString("permission")?.let { permissions.add(it) } + } + return if (sawGroup) PermissionGroup(groupName, permissions) else null + } + + private fun buildGroupsFromResult(resultSet: ResultSet): List { + val groups = mutableListOf() + var currentName: String? = null + var currentPermissions = linkedSetOf() + while (resultSet.next()) { + val name = resultSet.getString("name") + if (currentName == null) { + currentName = name + } + if (name != currentName) { + groups.add(PermissionGroup(requireNotNull(currentName), currentPermissions)) + currentName = name + currentPermissions = linkedSetOf() + } + resultSet.getString("permission")?.let { currentPermissions.add(it) } + } + currentName?.let { groups.add(PermissionGroup(it, currentPermissions)) } + return groups + } + + companion object { + private val LOG = Logger.getLogger(GroupRepository::class.java) + + private const val INSERT_GROUP = + """ + INSERT INTO permission_groups (name) + VALUES (?) + ON CONFLICT (name) DO NOTHING + """ + private const val DELETE_GROUP = + """ + DELETE FROM permission_groups + WHERE name = ? + """ + private const val SELECT_GROUP = + """ + SELECT name + FROM permission_groups + WHERE name = ? + """ + private const val SELECT_GROUPS = + """ + SELECT name + FROM permission_groups + ORDER BY name + """ + private const val SELECT_GROUP_WITH_PERMISSIONS = + """ + SELECT pg.name, gp.permission + FROM permission_groups pg + LEFT JOIN group_permissions gp ON gp.group_name = pg.name + WHERE pg.name = ? + ORDER BY gp.permission + """ + private const val SELECT_GROUPS_WITH_PERMISSIONS = + """ + SELECT pg.name, gp.permission + FROM permission_groups pg + LEFT JOIN group_permissions gp ON gp.group_name = pg.name + ORDER BY pg.name, gp.permission + """ + private const val INSERT_GROUP_PERMISSION = + """ + INSERT INTO group_permissions (group_name, permission) + VALUES (?, ?) + ON CONFLICT (group_name, permission) DO NOTHING + """ + private const val DELETE_GROUP_PERMISSION = + """ + DELETE FROM group_permissions + WHERE group_name = ? + AND permission = ? + """ + } +} diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt new file mode 100644 index 0000000..7e5805f --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt @@ -0,0 +1,155 @@ +package gg.grounds.persistence.permissions + +import gg.grounds.domain.PlayerPermissionsData +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import java.sql.SQLException +import java.util.UUID +import javax.sql.DataSource +import org.jboss.logging.Logger + +@ApplicationScoped +class PermissionsQueryRepository @Inject constructor(private val dataSource: DataSource) { + fun getPlayerPermissions( + playerId: UUID, + includeEffectivePermissions: Boolean, + includeDirectPermissions: Boolean, + includeGroups: Boolean, + ): PlayerPermissionsData? { + return try { + dataSource.connection.use { connection -> + val shouldLoadGroups = includeGroups || includeEffectivePermissions + val shouldLoadDirect = includeDirectPermissions || includeEffectivePermissions + + val loadedGroupNames = + if (shouldLoadGroups) { + connection.prepareStatement(SELECT_PLAYER_GROUPS).use { statement -> + statement.setObject(1, playerId) + statement.executeQuery().use { resultSet -> + val names = linkedSetOf() + while (resultSet.next()) { + resultSet.getString("group_name")?.let { names.add(it) } + } + names + } + } + } else { + emptySet() + } + + val loadedDirectPermissions = + if (shouldLoadDirect) { + connection.prepareStatement(SELECT_PLAYER_PERMISSIONS).use { statement -> + statement.setObject(1, playerId) + statement.executeQuery().use { resultSet -> + val permissions = linkedSetOf() + while (resultSet.next()) { + resultSet.getString("permission")?.let { permissions.add(it) } + } + permissions + } + } + } else { + emptySet() + } + + val groupNames = if (includeGroups) loadedGroupNames else emptySet() + val directPermissions = + if (includeDirectPermissions) loadedDirectPermissions else emptySet() + + val effectivePermissions = + if (includeEffectivePermissions) { + val permissions = linkedSetOf() + permissions.addAll(loadedDirectPermissions) + connection.prepareStatement(SELECT_GROUP_PERMISSIONS_FOR_PLAYER).use { + statement -> + statement.setObject(1, playerId) + statement.executeQuery().use { resultSet -> + while (resultSet.next()) { + resultSet.getString("permission")?.let { permissions.add(it) } + } + } + } + permissions + } else { + emptySet() + } + + PlayerPermissionsData(playerId, groupNames, directPermissions, effectivePermissions) + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to load player permissions (playerId=%s, includeEffectivePermissions=%s, includeDirectPermissions=%s, includeGroups=%s)", + playerId, + includeEffectivePermissions, + includeDirectPermissions, + includeGroups, + ) + null + } + } + + fun checkPlayerPermission(playerId: UUID, permission: String): Boolean? { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(CHECK_PLAYER_PERMISSION).use { statement -> + statement.setObject(1, playerId) + statement.setString(2, permission) + statement.setObject(3, playerId) + statement.setString(4, permission) + statement.executeQuery().use { resultSet -> resultSet.next() } + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to check player permission (playerId=%s, permission=%s)", + playerId, + permission, + ) + null + } + } + + companion object { + private val LOG = Logger.getLogger(PermissionsQueryRepository::class.java) + + private const val SELECT_PLAYER_GROUPS = + """ + SELECT group_name + FROM player_groups + WHERE player_id = ? + ORDER BY group_name + """ + private const val SELECT_PLAYER_PERMISSIONS = + """ + SELECT permission + FROM player_permissions + WHERE player_id = ? + ORDER BY permission + """ + private const val SELECT_GROUP_PERMISSIONS_FOR_PLAYER = + """ + SELECT gp.permission + FROM group_permissions gp + JOIN player_groups pg ON gp.group_name = pg.group_name + WHERE pg.player_id = ? + ORDER BY gp.permission + """ + private const val CHECK_PLAYER_PERMISSION = + """ + SELECT 1 + FROM player_permissions + WHERE player_id = ? + AND permission = ? + UNION + SELECT 1 + FROM group_permissions gp + JOIN player_groups pg ON gp.group_name = pg.group_name + WHERE pg.player_id = ? + AND gp.permission = ? + LIMIT 1 + """ + } +} diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt new file mode 100644 index 0000000..8b85708 --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt @@ -0,0 +1,104 @@ +package gg.grounds.persistence.permissions + +import gg.grounds.domain.ApplyOutcome +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import java.sql.SQLException +import java.util.UUID +import javax.sql.DataSource +import org.jboss.logging.Logger + +@ApplicationScoped +class PlayerGroupRepository @Inject constructor(private val dataSource: DataSource) { + fun addPlayerGroups(playerId: UUID, groupNames: Collection): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(INSERT_PLAYER_GROUP).use { statement -> + groupNames.forEach { groupName -> + statement.setObject(1, playerId) + statement.setString(2, groupName) + statement.addBatch() + } + val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 + if (updated) ApplyOutcome.UPDATED else ApplyOutcome.NO_CHANGE + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to add player groups (playerId=%s, groupCount=%d)", + playerId, + groupNames.size, + ) + ApplyOutcome.ERROR + } + } + + fun removePlayerGroups(playerId: UUID, groupNames: Collection): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(DELETE_PLAYER_GROUP).use { statement -> + groupNames.forEach { groupName -> + statement.setObject(1, playerId) + statement.setString(2, groupName) + statement.addBatch() + } + val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 + if (updated) ApplyOutcome.UPDATED else ApplyOutcome.NO_CHANGE + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to remove player groups (playerId=%s, groupCount=%d)", + playerId, + groupNames.size, + ) + ApplyOutcome.ERROR + } + } + + fun getPlayerGroupNames(playerId: UUID): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_PLAYER_GROUPS).use { statement -> + statement.setObject(1, playerId) + statement.executeQuery().use { resultSet -> + val groupNames = linkedSetOf() + while (resultSet.next()) { + resultSet.getString("group_name")?.let { groupNames.add(it) } + } + groupNames + } + } + } + } catch (error: SQLException) { + LOG.errorf(error, "Failed to load player groups (playerId=%s)", playerId) + emptySet() + } + } + + companion object { + private val LOG = Logger.getLogger(PlayerGroupRepository::class.java) + + private const val INSERT_PLAYER_GROUP = + """ + INSERT INTO player_groups (player_id, group_name) + VALUES (?, ?) + ON CONFLICT (player_id, group_name) DO NOTHING + """ + private const val DELETE_PLAYER_GROUP = + """ + DELETE FROM player_groups + WHERE player_id = ? + AND group_name = ? + """ + private const val SELECT_PLAYER_GROUPS = + """ + SELECT group_name + FROM player_groups + WHERE player_id = ? + ORDER BY group_name + """ + } +} diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt new file mode 100644 index 0000000..b27cd73 --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt @@ -0,0 +1,104 @@ +package gg.grounds.persistence.permissions + +import gg.grounds.domain.ApplyOutcome +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import java.sql.SQLException +import java.util.UUID +import javax.sql.DataSource +import org.jboss.logging.Logger + +@ApplicationScoped +class PlayerPermissionRepository @Inject constructor(private val dataSource: DataSource) { + fun addPlayerPermissions(playerId: UUID, permissions: Collection): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(INSERT_PLAYER_PERMISSION).use { statement -> + permissions.forEach { permission -> + statement.setObject(1, playerId) + statement.setString(2, permission) + statement.addBatch() + } + val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 + if (updated) ApplyOutcome.UPDATED else ApplyOutcome.NO_CHANGE + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to add player permissions (playerId=%s, permissionsCount=%d)", + playerId, + permissions.size, + ) + ApplyOutcome.ERROR + } + } + + fun removePlayerPermissions(playerId: UUID, permissions: Collection): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(DELETE_PLAYER_PERMISSION).use { statement -> + permissions.forEach { permission -> + statement.setObject(1, playerId) + statement.setString(2, permission) + statement.addBatch() + } + val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 + if (updated) ApplyOutcome.UPDATED else ApplyOutcome.NO_CHANGE + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to remove player permissions (playerId=%s, permissionsCount=%d)", + playerId, + permissions.size, + ) + ApplyOutcome.ERROR + } + } + + fun getPlayerPermissions(playerId: UUID): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_PLAYER_PERMISSIONS).use { statement -> + statement.setObject(1, playerId) + statement.executeQuery().use { resultSet -> + val permissions = linkedSetOf() + while (resultSet.next()) { + resultSet.getString("permission")?.let { permissions.add(it) } + } + permissions + } + } + } + } catch (error: SQLException) { + LOG.errorf(error, "Failed to load player permissions (playerId=%s)", playerId) + emptySet() + } + } + + companion object { + private val LOG = Logger.getLogger(PlayerPermissionRepository::class.java) + + private const val INSERT_PLAYER_PERMISSION = + """ + INSERT INTO player_permissions (player_id, permission) + VALUES (?, ?) + ON CONFLICT (player_id, permission) DO NOTHING + """ + private const val DELETE_PLAYER_PERMISSION = + """ + DELETE FROM player_permissions + WHERE player_id = ? + AND permission = ? + """ + private const val SELECT_PLAYER_PERMISSIONS = + """ + SELECT permission + FROM player_permissions + WHERE player_id = ? + ORDER BY permission + """ + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1306eeb..a35234f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,7 +3,7 @@ quarkus.http.host=0.0.0.0 quarkus.http.port=9000 quarkus.log.level=${QUARKUS_LOG_LEVEL:DEBUG} -quarkus.generate-code.grpc.scan-for-proto=gg.grounds:library-grpc-contracts-player +quarkus.generate-code.grpc.scan-for-proto=gg.grounds:library-grpc-contracts-player,gg.grounds:library-grpc-contracts-permission quarkus.datasource.db-kind=postgresql quarkus.datasource.jdbc.url=${POSTGRES_URL:jdbc:postgresql://localhost:5432/app?currentSchema=player} diff --git a/src/main/resources/db/migration/V2__create_permissions.sql b/src/main/resources/db/migration/V2__create_permissions.sql new file mode 100644 index 0000000..13b68bf --- /dev/null +++ b/src/main/resources/db/migration/V2__create_permissions.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS permission_groups ( + name TEXT PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS group_permissions ( + group_name TEXT NOT NULL REFERENCES permission_groups(name) ON DELETE CASCADE, + permission TEXT NOT NULL, + PRIMARY KEY (group_name, permission) +); + +CREATE TABLE IF NOT EXISTS player_groups ( + player_id UUID NOT NULL, + group_name TEXT NOT NULL REFERENCES permission_groups(name) ON DELETE CASCADE +); + +ALTER TABLE player_groups DROP CONSTRAINT IF EXISTS player_groups_pkey; +ALTER TABLE player_groups ADD PRIMARY KEY (player_id, group_name); + +CREATE INDEX IF NOT EXISTS idx_player_groups_group_name ON player_groups(group_name); + +CREATE TABLE IF NOT EXISTS player_permissions ( + player_id UUID NOT NULL, + permission TEXT NOT NULL, + PRIMARY KEY (player_id, permission) +); diff --git a/src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt b/src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt new file mode 100644 index 0000000..0c21481 --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt @@ -0,0 +1,150 @@ +package gg.grounds.api.permissions + +import gg.grounds.domain.PlayerPermissionsData +import gg.grounds.grpc.permissions.CheckPlayerPermissionRequest +import gg.grounds.grpc.permissions.GetPlayerPermissionsRequest +import gg.grounds.persistence.permissions.PermissionsQueryRepository +import io.grpc.Status +import io.grpc.StatusRuntimeException +import java.util.UUID +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class PermissionsPluginGrpcServiceTest { + @Test + fun getPlayerPermissionsRejectsInvalidPlayerId() { + val repository = mock() + val service = PermissionsPluginGrpcService(repository) + val request = GetPlayerPermissionsRequest.newBuilder().setPlayerId("not-a-uuid").build() + + val exception = + assertThrows { + service.getPlayerPermissions(request).await().indefinitely() + } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("player_id must be a UUID", status.description) + verifyNoInteractions(repository) + } + + @Test + fun getPlayerPermissionsReturnsPermissions() { + val repository = mock() + val service = PermissionsPluginGrpcService(repository) + val playerId = UUID.randomUUID() + val data = + PlayerPermissionsData( + playerId, + setOf("group-a"), + setOf("permission.read"), + setOf("permission.read", "permission.write"), + ) + whenever(repository.getPlayerPermissions(playerId, true, false, true)).thenReturn(data) + val request = + GetPlayerPermissionsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setIncludeEffectivePermissions(true) + .setIncludeDirectPermissions(false) + .setIncludeGroups(true) + .build() + + val reply = service.getPlayerPermissions(request).await().indefinitely() + + assertEquals(playerId.toString(), reply.player.playerId) + assertEquals(setOf("group-a"), reply.player.groupNamesList.toSet()) + assertEquals(setOf("permission.read"), reply.player.directPermissionsList.toSet()) + assertEquals( + setOf("permission.read", "permission.write"), + reply.player.effectivePermissionsList.toSet(), + ) + verify(repository).getPlayerPermissions(playerId, true, false, true) + } + + @Test + fun getPlayerPermissionsFailsWhenRepositoryErrors() { + val repository = mock() + val service = PermissionsPluginGrpcService(repository) + val playerId = UUID.randomUUID() + whenever(repository.getPlayerPermissions(playerId, false, false, false)).thenReturn(null) + val request = + GetPlayerPermissionsRequest.newBuilder().setPlayerId(playerId.toString()).build() + + val exception = + assertThrows { + service.getPlayerPermissions(request).await().indefinitely() + } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INTERNAL, status.code) + assertEquals("Failed to load player permissions", status.description) + verify(repository).getPlayerPermissions(playerId, false, false, false) + } + + @Test + fun checkPlayerPermissionRejectsEmptyPermission() { + val repository = mock() + val service = PermissionsPluginGrpcService(repository) + val request = + CheckPlayerPermissionRequest.newBuilder() + .setPlayerId(UUID.randomUUID().toString()) + .setPermission(" ") + .build() + + val exception = + assertThrows { + service.checkPlayerPermission(request).await().indefinitely() + } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("permission must be provided", status.description) + verifyNoInteractions(repository) + } + + @Test + fun checkPlayerPermissionReturnsAllowed() { + val repository = mock() + val service = PermissionsPluginGrpcService(repository) + val playerId = UUID.randomUUID() + whenever(repository.checkPlayerPermission(playerId, "permission.read")).thenReturn(true) + val request = + CheckPlayerPermissionRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setPermission("permission.read") + .build() + + val reply = service.checkPlayerPermission(request).await().indefinitely() + + assertEquals(true, reply.allowed) + verify(repository).checkPlayerPermission(playerId, "permission.read") + } + + @Test + fun checkPlayerPermissionFailsWhenRepositoryErrors() { + val repository = mock() + val service = PermissionsPluginGrpcService(repository) + val playerId = UUID.randomUUID() + whenever(repository.checkPlayerPermission(playerId, "permission.read")).thenReturn(null) + val request = + CheckPlayerPermissionRequest.newBuilder() + .setPlayerId(playerId.toString()) + .setPermission("permission.read") + .build() + + val exception = + assertThrows { + service.checkPlayerPermission(request).await().indefinitely() + } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INTERNAL, status.code) + assertEquals("Failed to check player permission", status.description) + verify(repository).checkPlayerPermission(playerId, "permission.read") + } +} diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt new file mode 100644 index 0000000..4f08550 --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt @@ -0,0 +1,71 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.domain.ApplyOutcome +import gg.grounds.grpc.permissions.AddGroupPermissionsRequest +import gg.grounds.grpc.permissions.ApplyResult +import gg.grounds.grpc.permissions.CreateGroupRequest +import gg.grounds.grpc.permissions.RemoveGroupPermissionsRequest +import gg.grounds.persistence.permissions.GroupRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class GroupAdminHandlerTest { + @Test + fun createGroupRejectsEmptyName() { + val repository = mock() + val handler = GroupAdminHandler(repository) + val request = CreateGroupRequest.newBuilder().setGroupName(" ").build() + + val reply = handler.createGroup(request) + + assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + verifyNoInteractions(repository) + } + + @Test + fun createGroupReturnsOutcome() { + val repository = mock() + val handler = GroupAdminHandler(repository) + whenever(repository.createGroup("admins")).thenReturn(ApplyOutcome.CREATED) + val request = CreateGroupRequest.newBuilder().setGroupName("admins").build() + + val reply = handler.createGroup(request) + + assertEquals(ApplyResult.CREATED, reply.applyResult) + verify(repository).createGroup("admins") + } + + @Test + fun addGroupPermissionsRejectsEmptyPermissions() { + val repository = mock() + val handler = GroupAdminHandler(repository) + val request = AddGroupPermissionsRequest.newBuilder().setGroupName("admins").build() + + val reply = handler.addGroupPermissions(request) + + assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + verifyNoInteractions(repository) + } + + @Test + fun removeGroupPermissionsReturnsOutcome() { + val repository = mock() + val handler = GroupAdminHandler(repository) + whenever(repository.removeGroupPermissions("admins", setOf("permission.read"))) + .thenReturn(ApplyOutcome.UPDATED) + val request = + RemoveGroupPermissionsRequest.newBuilder() + .setGroupName("admins") + .addPermissions("permission.read") + .build() + + val reply = handler.removeGroupPermissions(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(repository).removeGroupPermissions("admins", setOf("permission.read")) + } +} diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt new file mode 100644 index 0000000..87525be --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt @@ -0,0 +1,64 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.domain.ApplyOutcome +import gg.grounds.grpc.permissions.AddPlayerGroupsRequest +import gg.grounds.grpc.permissions.ApplyResult +import gg.grounds.grpc.permissions.RemovePlayerGroupsRequest +import gg.grounds.persistence.permissions.PlayerGroupRepository +import java.util.UUID +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class PlayerGroupsAdminHandlerTest { + @Test + fun addPlayerGroupsRejectsInvalidPlayerId() { + val repository = mock() + val handler = PlayerGroupsAdminHandler(repository) + val request = + AddPlayerGroupsRequest.newBuilder() + .setPlayerId("not-a-uuid") + .addGroupNames("vip") + .build() + + val reply = handler.addPlayerGroups(request) + + assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + verifyNoInteractions(repository) + } + + @Test + fun addPlayerGroupsReturnsOutcome() { + val repository = mock() + val handler = PlayerGroupsAdminHandler(repository) + val playerId = UUID.randomUUID() + whenever(repository.addPlayerGroups(playerId, setOf("vip"))) + .thenReturn(ApplyOutcome.UPDATED) + val request = + AddPlayerGroupsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addGroupNames("vip") + .build() + + val reply = handler.addPlayerGroups(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(repository).addPlayerGroups(playerId, setOf("vip")) + } + + @Test + fun removePlayerGroupsRejectsEmptyGroupNames() { + val repository = mock() + val handler = PlayerGroupsAdminHandler(repository) + val request = + RemovePlayerGroupsRequest.newBuilder().setPlayerId(UUID.randomUUID().toString()).build() + + val reply = handler.removePlayerGroups(request) + + assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + verifyNoInteractions(repository) + } +} diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt new file mode 100644 index 0000000..fa5c9ea --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt @@ -0,0 +1,66 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.domain.ApplyOutcome +import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest +import gg.grounds.grpc.permissions.ApplyResult +import gg.grounds.grpc.permissions.RemovePlayerPermissionsRequest +import gg.grounds.persistence.permissions.PlayerPermissionRepository +import java.util.UUID +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class PlayerPermissionsAdminHandlerTest { + @Test + fun addPlayerPermissionsRejectsInvalidPlayerId() { + val repository = mock() + val handler = PlayerPermissionsAdminHandler(repository) + val request = + AddPlayerPermissionsRequest.newBuilder() + .setPlayerId("not-a-uuid") + .addPermissions("permission.read") + .build() + + val reply = handler.addPlayerPermissions(request) + + assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + verifyNoInteractions(repository) + } + + @Test + fun addPlayerPermissionsReturnsOutcome() { + val repository = mock() + val handler = PlayerPermissionsAdminHandler(repository) + val playerId = UUID.randomUUID() + whenever(repository.addPlayerPermissions(playerId, setOf("permission.read"))) + .thenReturn(ApplyOutcome.UPDATED) + val request = + AddPlayerPermissionsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addPermissions("permission.read") + .build() + + val reply = handler.addPlayerPermissions(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(repository).addPlayerPermissions(playerId, setOf("permission.read")) + } + + @Test + fun removePlayerPermissionsRejectsEmptyPermissions() { + val repository = mock() + val handler = PlayerPermissionsAdminHandler(repository) + val request = + RemovePlayerPermissionsRequest.newBuilder() + .setPlayerId(UUID.randomUUID().toString()) + .build() + + val reply = handler.removePlayerPermissions(request) + + assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + verifyNoInteractions(repository) + } +} From 5bab045ab7f442ad74dc1eecbf59c187fc6235c7 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 8 Mar 2026 21:03:33 +0100 Subject: [PATCH 2/5] feat: add expirable groups and permissions grants # Conflicts: # src/main/resources/application.properties --- .../api/permissions/ApplyResultMapper.kt | 2 +- .../api/permissions/PermissionsProtoMapper.kt | 38 ++++- .../permissions/PermissionsRequestParser.kt | 57 ++++++++ .../permissions/admin/GroupAdminHandler.kt | 134 ++++++++++++------ .../admin/PlayerGroupsAdminHandler.kt | 76 ++++++---- .../admin/PlayerPermissionsAdminHandler.kt | 76 ++++++---- .../gg/grounds/domain/PermissionGroup.kt | 3 - .../grounds/domain/PlayerPermissionsData.kt | 10 -- .../domain/{ => permissions}/ApplyOutcome.kt | 2 +- .../domain/permissions/PermissionGroup.kt | 7 + .../permissions/PlayerPermissionsData.kt | 15 ++ .../permissions/GroupRepository.kt | 57 +++++--- .../permissions/PermissionsQueryRepository.kt | 52 +++++-- .../PermissionsRepositoryException.kt | 4 + .../permissions/PlayerGroupRepository.kt | 50 ++----- .../permissions/PlayerPermissionRepository.kt | 50 ++----- .../db/migration/V2__create_permissions.sql | 11 +- .../PermissionsPluginGrpcServiceTest.kt | 28 +++- .../admin/GroupAdminHandlerTest.kt | 75 +++++++++- .../admin/PlayerGroupsAdminHandlerTest.kt | 94 ++++++++++-- .../PlayerPermissionsAdminHandlerTest.kt | 110 ++++++++++++-- 21 files changed, 693 insertions(+), 258 deletions(-) delete mode 100644 src/main/kotlin/gg/grounds/domain/PermissionGroup.kt delete mode 100644 src/main/kotlin/gg/grounds/domain/PlayerPermissionsData.kt rename src/main/kotlin/gg/grounds/domain/{ => permissions}/ApplyOutcome.kt (71%) create mode 100644 src/main/kotlin/gg/grounds/domain/permissions/PermissionGroup.kt create mode 100644 src/main/kotlin/gg/grounds/domain/permissions/PlayerPermissionsData.kt create mode 100644 src/main/kotlin/gg/grounds/persistence/permissions/PermissionsRepositoryException.kt diff --git a/src/main/kotlin/gg/grounds/api/permissions/ApplyResultMapper.kt b/src/main/kotlin/gg/grounds/api/permissions/ApplyResultMapper.kt index ea23639..f6504b1 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/ApplyResultMapper.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/ApplyResultMapper.kt @@ -1,6 +1,6 @@ package gg.grounds.api.permissions -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome import gg.grounds.grpc.permissions.ApplyResult object ApplyResultMapper { diff --git a/src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt b/src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt index c124f95..bde5509 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt @@ -1,16 +1,22 @@ package gg.grounds.api.permissions -import gg.grounds.domain.PermissionGroup -import gg.grounds.domain.PlayerPermissionsData +import com.google.protobuf.Timestamp +import gg.grounds.domain.permissions.GroupPermissionGrant +import gg.grounds.domain.permissions.PermissionGroup +import gg.grounds.domain.permissions.PlayerGroupMembership +import gg.grounds.domain.permissions.PlayerPermissionGrant +import gg.grounds.domain.permissions.PlayerPermissionsData import gg.grounds.grpc.permissions.Group +import gg.grounds.grpc.permissions.PermissionGrant as PermissionGrantProto +import gg.grounds.grpc.permissions.PlayerGroupMembership as PlayerGroupMembershipProto import gg.grounds.grpc.permissions.PlayerPermissions object PermissionsProtoMapper { fun toProto(data: PlayerPermissionsData): PlayerPermissions { return PlayerPermissions.newBuilder() .setPlayerId(data.playerId.toString()) - .addAllGroupNames(data.groupNames) - .addAllDirectPermissions(data.directPermissions) + .addAllGroupMemberships(data.groupMemberships.map { toProto(it) }) + .addAllDirectPermissionGrants(data.directPermissionGrants.map { toProto(it) }) .addAllEffectivePermissions(data.effectivePermissions) .build() } @@ -18,7 +24,29 @@ object PermissionsProtoMapper { fun toProto(group: PermissionGroup): Group { return Group.newBuilder() .setGroupName(group.name) - .addAllPermissions(group.permissions) + .addAllPermissionGrants(group.permissionGrants.map { toProto(it) }) .build() } + + private fun toProto(membership: PlayerGroupMembership): PlayerGroupMembershipProto { + val builder = PlayerGroupMembershipProto.newBuilder().setGroupName(membership.groupName) + membership.expiresAt?.let { builder.setExpiresAt(toTimestamp(it)) } + return builder.build() + } + + private fun toProto(grant: PlayerPermissionGrant): PermissionGrantProto { + val builder = PermissionGrantProto.newBuilder().setPermission(grant.permission) + grant.expiresAt?.let { builder.setExpiresAt(toTimestamp(it)) } + return builder.build() + } + + private fun toProto(grant: GroupPermissionGrant): PermissionGrantProto { + val builder = PermissionGrantProto.newBuilder().setPermission(grant.permission) + grant.expiresAt?.let { builder.setExpiresAt(toTimestamp(it)) } + return builder.build() + } + + private fun toTimestamp(instant: java.time.Instant): Timestamp { + return Timestamp.newBuilder().setSeconds(instant.epochSecond).setNanos(instant.nano).build() + } } diff --git a/src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt b/src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt index 2fa9e52..b0208e5 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt @@ -1,5 +1,12 @@ package gg.grounds.api.permissions +import com.google.protobuf.Timestamp +import gg.grounds.domain.permissions.GroupPermissionGrant +import gg.grounds.domain.permissions.PlayerGroupMembership +import gg.grounds.domain.permissions.PlayerPermissionGrant +import gg.grounds.grpc.permissions.PermissionGrant as PermissionGrantProto +import gg.grounds.grpc.permissions.PlayerGroupMembership as PlayerGroupMembershipProto +import java.time.Instant import java.util.UUID object PermissionsRequestParser { @@ -13,4 +20,54 @@ object PermissionsRequestParser { fun sanitize(values: List): Set { return values.mapNotNull { it.trim().takeIf { name -> name.isNotEmpty() } }.toSet() } + + fun sanitizeGroupMemberships( + values: List + ): Set { + val memberships = linkedMapOf() + values.forEach { membership -> + val groupName = membership.groupName?.trim().orEmpty() + if (groupName.isEmpty()) { + return@forEach + } + val expiresAt = if (membership.hasExpiresAt()) toInstant(membership.expiresAt) else null + memberships[groupName] = PlayerGroupMembership(groupName, expiresAt) + } + return memberships.values.toSet() + } + + fun sanitizePermissionGrants(values: List): Set { + return parsePermissionGrants(values) + .map { (permission, expiresAt) -> PlayerPermissionGrant(permission, expiresAt) } + .toSet() + } + + fun sanitizeGroupPermissionGrants( + values: List + ): Set { + return parsePermissionGrants(values) + .map { (permission, expiresAt) -> GroupPermissionGrant(permission, expiresAt) } + .toSet() + } + + private fun parsePermissionGrants(values: List): Map { + val grants = linkedMapOf() + values.forEach { grant -> + val permission = grant.permission?.trim().orEmpty() + if (permission.isEmpty()) { + return@forEach + } + val expiresAt = if (grant.hasExpiresAt()) toInstant(grant.expiresAt) else null + grants[permission] = expiresAt + } + return grants + } + + private fun toInstant(timestamp: Timestamp): Instant { + return Instant.ofEpochSecond(timestamp.seconds, timestamp.nanos.toLong()) + } + + fun hasPastExpiry(expiresAt: Instant?, now: Instant): Boolean { + return expiresAt != null && !expiresAt.isAfter(now) + } } diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt index b2126f1..9378a8c 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt @@ -3,7 +3,7 @@ package gg.grounds.api.permissions.admin import gg.grounds.api.permissions.ApplyResultMapper import gg.grounds.api.permissions.PermissionsProtoMapper import gg.grounds.api.permissions.PermissionsRequestParser -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome import gg.grounds.grpc.permissions.AddGroupPermissionsReply import gg.grounds.grpc.permissions.AddGroupPermissionsRequest import gg.grounds.grpc.permissions.CreateGroupReply @@ -17,8 +17,11 @@ import gg.grounds.grpc.permissions.ListGroupsRequest import gg.grounds.grpc.permissions.RemoveGroupPermissionsReply import gg.grounds.grpc.permissions.RemoveGroupPermissionsRequest import gg.grounds.persistence.permissions.GroupRepository +import gg.grounds.persistence.permissions.PermissionsRepositoryException +import io.grpc.Status import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject +import java.time.Instant import org.jboss.logging.Logger @ApplicationScoped @@ -27,19 +30,20 @@ class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRe val groupName = request.groupName?.trim().orEmpty() if (groupName.isEmpty()) { LOG.warnf("Create group request rejected (reason=empty_group_name)") - return CreateGroupReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("group_name must be provided") + .asRuntimeException() } val outcome = groupRepository.createGroup(groupName) - if (outcome != ApplyOutcome.ERROR) { - LOG.infof( - "Create permission group completed (groupName=%s, outcome=%s)", - groupName, - outcome, - ) + if (outcome == ApplyOutcome.ERROR) { + throw Status.INTERNAL.withDescription("Failed to create permission group") + .asRuntimeException() } + LOG.infof( + "Create permission group completed (groupName=%s, outcome=%s)", + groupName, + outcome, + ) return CreateGroupReply.newBuilder() .setApplyResult(ApplyResultMapper.toProto(outcome)) .build() @@ -49,19 +53,20 @@ class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRe val groupName = request.groupName?.trim().orEmpty() if (groupName.isEmpty()) { LOG.warnf("Delete group request rejected (reason=empty_group_name)") - return DeleteGroupReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("group_name must be provided") + .asRuntimeException() } val outcome = groupRepository.deleteGroup(groupName) - if (outcome != ApplyOutcome.ERROR) { - LOG.infof( - "Delete permission group completed (groupName=%s, outcome=%s)", - groupName, - outcome, - ) + if (outcome == ApplyOutcome.ERROR) { + throw Status.INTERNAL.withDescription("Failed to delete permission group") + .asRuntimeException() } + LOG.infof( + "Delete permission group completed (groupName=%s, outcome=%s)", + groupName, + outcome, + ) return DeleteGroupReply.newBuilder() .setApplyResult(ApplyResultMapper.toProto(outcome)) .build() @@ -71,24 +76,39 @@ class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRe val groupName = request.groupName?.trim().orEmpty() if (groupName.isEmpty()) { LOG.warnf("Get group request rejected (reason=empty_group_name)") - return GetGroupReply.getDefaultInstance() + throw Status.INVALID_ARGUMENT.withDescription("group_name must be provided") + .asRuntimeException() } val group = - groupRepository.getGroup(groupName, includePermissions = true) + try { + groupRepository.getGroup(groupName, includePermissions = true) + } catch (error: PermissionsRepositoryException) { + throw Status.INTERNAL.withDescription("Failed to load permission group") + .withCause(error) + .asRuntimeException() + } ?: run { LOG.debugf( "Fetch permission group completed (groupName=%s, result=not_found)", groupName, ) - return GetGroupReply.getDefaultInstance() + throw Status.NOT_FOUND.withDescription("permission group not found") + .asRuntimeException() } LOG.debugf("Fetch permission group completed (groupName=%s, result=found)", groupName) return GetGroupReply.newBuilder().setGroup(PermissionsProtoMapper.toProto(group)).build() } fun listGroups(request: ListGroupsRequest): ListGroupsReply { - val groups = groupRepository.listGroups(request.includePermissions) + val groups = + try { + groupRepository.listGroups(request.includePermissions) + } catch (error: PermissionsRepositoryException) { + throw Status.INTERNAL.withDescription("Failed to list permission groups") + .withCause(error) + .asRuntimeException() + } LOG.debugf( "Listed permission groups successfully (includePermissions=%s, groupCount=%d)", request.includePermissions, @@ -101,27 +121,44 @@ class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRe fun addGroupPermissions(request: AddGroupPermissionsRequest): AddGroupPermissionsReply { val groupName = request.groupName?.trim().orEmpty() - val permissions = PermissionsRequestParser.sanitize(request.permissionsList) - if (groupName.isEmpty() || permissions.isEmpty()) { + val permissionGrants = + PermissionsRequestParser.sanitizeGroupPermissionGrants(request.permissionGrantsList) + if (groupName.isEmpty() || permissionGrants.isEmpty()) { LOG.warnf( - "Add group permissions request rejected (groupName=%s, permissionsCount=%d, reason=invalid_input)", + "Add group permissions request rejected (groupName=%s, permissionGrantsCount=%d, reason=invalid_input)", groupName, - permissions.size, + permissionGrants.size, ) - return AddGroupPermissionsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription( + "group_name and permission_grants must be provided" + ) + .asRuntimeException() } - - val outcome = groupRepository.addGroupPermissions(groupName, permissions) - if (outcome != ApplyOutcome.ERROR) { - LOG.infof( - "Add group permissions completed (groupName=%s, permissionsCount=%d, outcome=%s)", + val now = Instant.now() + if ( + permissionGrants.any { grant -> + PermissionsRequestParser.hasPastExpiry(grant.expiresAt, now) + } + ) { + LOG.warnf( + "Add group permissions request rejected (groupName=%s, reason=expired_grant)", groupName, - permissions.size, - outcome, ) + throw Status.INVALID_ARGUMENT.withDescription("expires_at must be in the future") + .asRuntimeException() } + + val outcome = groupRepository.addGroupPermissions(groupName, permissionGrants) + if (outcome == ApplyOutcome.ERROR) { + throw Status.INTERNAL.withDescription("Failed to add group permissions") + .asRuntimeException() + } + LOG.infof( + "Add group permissions completed (groupName=%s, permissionGrantsCount=%d, outcome=%s)", + groupName, + permissionGrants.size, + outcome, + ) return AddGroupPermissionsReply.newBuilder() .setApplyResult(ApplyResultMapper.toProto(outcome)) .build() @@ -138,20 +175,23 @@ class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRe groupName, permissions.size, ) - return RemoveGroupPermissionsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription( + "group_name and permissions must be provided" + ) + .asRuntimeException() } val outcome = groupRepository.removeGroupPermissions(groupName, permissions) - if (outcome != ApplyOutcome.ERROR) { - LOG.infof( - "Remove group permissions completed (groupName=%s, permissionsCount=%d, outcome=%s)", - groupName, - permissions.size, - outcome, - ) + if (outcome == ApplyOutcome.ERROR) { + throw Status.INTERNAL.withDescription("Failed to remove group permissions") + .asRuntimeException() } + LOG.infof( + "Remove group permissions completed (groupName=%s, permissionsCount=%d, outcome=%s)", + groupName, + permissions.size, + outcome, + ) return RemoveGroupPermissionsReply.newBuilder() .setApplyResult(ApplyResultMapper.toProto(outcome)) .build() diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt index 5d04c2a..cb8bb30 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt @@ -2,14 +2,16 @@ package gg.grounds.api.permissions.admin import gg.grounds.api.permissions.ApplyResultMapper import gg.grounds.api.permissions.PermissionsRequestParser -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome import gg.grounds.grpc.permissions.AddPlayerGroupsReply import gg.grounds.grpc.permissions.AddPlayerGroupsRequest import gg.grounds.grpc.permissions.RemovePlayerGroupsReply import gg.grounds.grpc.permissions.RemovePlayerGroupsRequest import gg.grounds.persistence.permissions.PlayerGroupRepository +import io.grpc.Status import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject +import java.time.Instant import org.jboss.logging.Logger @ApplicationScoped @@ -25,30 +27,44 @@ constructor(private val playerGroupRepository: PlayerGroupRepository) { "Add player groups request rejected (playerId=%s, reason=invalid_player_id)", rawPlayerId, ) - return AddPlayerGroupsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() } - val groupNames = PermissionsRequestParser.sanitize(request.groupNamesList) - if (groupNames.isEmpty()) { + val groupMemberships = + PermissionsRequestParser.sanitizeGroupMemberships(request.groupMembershipsList) + if (groupMemberships.isEmpty()) { LOG.warnf( - "Add player groups request rejected (playerId=%s, reason=empty_group_names)", + "Add player groups request rejected (playerId=%s, reason=empty_group_memberships)", playerId, ) - return AddPlayerGroupsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("group_memberships must be provided") + .asRuntimeException() } - - val outcome = playerGroupRepository.addPlayerGroups(playerId, groupNames) - if (outcome != ApplyOutcome.ERROR) { - LOG.infof( - "Add player groups completed (playerId=%s, groupCount=%d, outcome=%s)", + if ( + groupMemberships.any { membership -> + val now = Instant.now() + PermissionsRequestParser.hasPastExpiry(membership.expiresAt, now) + } + ) { + LOG.warnf( + "Add player groups request rejected (playerId=%s, reason=expired_membership)", playerId, - groupNames.size, - outcome, ) + throw Status.INVALID_ARGUMENT.withDescription("expires_at must be in the future") + .asRuntimeException() } + + val outcome = playerGroupRepository.addPlayerGroups(playerId, groupMemberships) + if (outcome == ApplyOutcome.ERROR) { + throw Status.INTERNAL.withDescription("Failed to add player groups") + .asRuntimeException() + } + LOG.infof( + "Add player groups completed (playerId=%s, groupCount=%d, outcome=%s)", + playerId, + groupMemberships.size, + outcome, + ) return AddPlayerGroupsReply.newBuilder() .setApplyResult(ApplyResultMapper.toProto(outcome)) .build() @@ -63,9 +79,8 @@ constructor(private val playerGroupRepository: PlayerGroupRepository) { "Remove player groups request rejected (playerId=%s, reason=invalid_player_id)", rawPlayerId, ) - return RemovePlayerGroupsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() } val groupNames = PermissionsRequestParser.sanitize(request.groupNamesList) if (groupNames.isEmpty()) { @@ -73,20 +88,21 @@ constructor(private val playerGroupRepository: PlayerGroupRepository) { "Remove player groups request rejected (playerId=%s, reason=empty_group_names)", playerId, ) - return RemovePlayerGroupsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("group_names must be provided") + .asRuntimeException() } val outcome = playerGroupRepository.removePlayerGroups(playerId, groupNames) - if (outcome != ApplyOutcome.ERROR) { - LOG.infof( - "Remove player groups completed (playerId=%s, groupCount=%d, outcome=%s)", - playerId, - groupNames.size, - outcome, - ) + if (outcome == ApplyOutcome.ERROR) { + throw Status.INTERNAL.withDescription("Failed to remove player groups") + .asRuntimeException() } + LOG.infof( + "Remove player groups completed (playerId=%s, groupCount=%d, outcome=%s)", + playerId, + groupNames.size, + outcome, + ) return RemovePlayerGroupsReply.newBuilder() .setApplyResult(ApplyResultMapper.toProto(outcome)) .build() diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt index cb0a5a9..8fd41f2 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt @@ -2,14 +2,16 @@ package gg.grounds.api.permissions.admin import gg.grounds.api.permissions.ApplyResultMapper import gg.grounds.api.permissions.PermissionsRequestParser -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome import gg.grounds.grpc.permissions.AddPlayerPermissionsReply import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest import gg.grounds.grpc.permissions.RemovePlayerPermissionsReply import gg.grounds.grpc.permissions.RemovePlayerPermissionsRequest import gg.grounds.persistence.permissions.PlayerPermissionRepository +import io.grpc.Status import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject +import java.time.Instant import org.jboss.logging.Logger @ApplicationScoped @@ -25,30 +27,44 @@ constructor(private val playerPermissionRepository: PlayerPermissionRepository) "Add player permissions request rejected (playerId=%s, reason=invalid_player_id)", rawPlayerId, ) - return AddPlayerPermissionsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() } - val permissions = PermissionsRequestParser.sanitize(request.permissionsList) - if (permissions.isEmpty()) { + val permissionGrants = + PermissionsRequestParser.sanitizePermissionGrants(request.permissionGrantsList) + if (permissionGrants.isEmpty()) { LOG.warnf( - "Add player permissions request rejected (playerId=%s, reason=empty_permissions)", + "Add player permissions request rejected (playerId=%s, reason=empty_permission_grants)", playerId, ) - return AddPlayerPermissionsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("permission_grants must be provided") + .asRuntimeException() } - - val outcome = playerPermissionRepository.addPlayerPermissions(playerId, permissions) - if (outcome != ApplyOutcome.ERROR) { - LOG.infof( - "Add player permissions completed (playerId=%s, permissionsCount=%d, outcome=%s)", + val now = Instant.now() + if ( + permissionGrants.any { grant -> + PermissionsRequestParser.hasPastExpiry(grant.expiresAt, now) + } + ) { + LOG.warnf( + "Add player permissions request rejected (playerId=%s, reason=expired_grant)", playerId, - permissions.size, - outcome, ) + throw Status.INVALID_ARGUMENT.withDescription("expires_at must be in the future") + .asRuntimeException() } + + val outcome = playerPermissionRepository.addPlayerPermissions(playerId, permissionGrants) + if (outcome == ApplyOutcome.ERROR) { + throw Status.INTERNAL.withDescription("Failed to add player permissions") + .asRuntimeException() + } + LOG.infof( + "Add player permissions completed (playerId=%s, permissionsCount=%d, outcome=%s)", + playerId, + permissionGrants.size, + outcome, + ) return AddPlayerPermissionsReply.newBuilder() .setApplyResult(ApplyResultMapper.toProto(outcome)) .build() @@ -65,9 +81,8 @@ constructor(private val playerPermissionRepository: PlayerPermissionRepository) "Remove player permissions request rejected (playerId=%s, reason=invalid_player_id)", rawPlayerId, ) - return RemovePlayerPermissionsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() } val permissions = PermissionsRequestParser.sanitize(request.permissionsList) if (permissions.isEmpty()) { @@ -75,20 +90,21 @@ constructor(private val playerPermissionRepository: PlayerPermissionRepository) "Remove player permissions request rejected (playerId=%s, reason=empty_permissions)", playerId, ) - return RemovePlayerPermissionsReply.newBuilder() - .setApplyResult(ApplyResultMapper.toProto(ApplyOutcome.ERROR)) - .build() + throw Status.INVALID_ARGUMENT.withDescription("permissions must be provided") + .asRuntimeException() } val outcome = playerPermissionRepository.removePlayerPermissions(playerId, permissions) - if (outcome != ApplyOutcome.ERROR) { - LOG.infof( - "Remove player permissions completed (playerId=%s, permissionsCount=%d, outcome=%s)", - playerId, - permissions.size, - outcome, - ) + if (outcome == ApplyOutcome.ERROR) { + throw Status.INTERNAL.withDescription("Failed to remove player permissions") + .asRuntimeException() } + LOG.infof( + "Remove player permissions completed (playerId=%s, permissionsCount=%d, outcome=%s)", + playerId, + permissions.size, + outcome, + ) return RemovePlayerPermissionsReply.newBuilder() .setApplyResult(ApplyResultMapper.toProto(outcome)) .build() diff --git a/src/main/kotlin/gg/grounds/domain/PermissionGroup.kt b/src/main/kotlin/gg/grounds/domain/PermissionGroup.kt deleted file mode 100644 index 01fca07..0000000 --- a/src/main/kotlin/gg/grounds/domain/PermissionGroup.kt +++ /dev/null @@ -1,3 +0,0 @@ -package gg.grounds.domain - -data class PermissionGroup(val name: String, val permissions: Set) diff --git a/src/main/kotlin/gg/grounds/domain/PlayerPermissionsData.kt b/src/main/kotlin/gg/grounds/domain/PlayerPermissionsData.kt deleted file mode 100644 index 0d6ef7e..0000000 --- a/src/main/kotlin/gg/grounds/domain/PlayerPermissionsData.kt +++ /dev/null @@ -1,10 +0,0 @@ -package gg.grounds.domain - -import java.util.UUID - -data class PlayerPermissionsData( - val playerId: UUID, - val groupNames: Set, - val directPermissions: Set, - val effectivePermissions: Set, -) diff --git a/src/main/kotlin/gg/grounds/domain/ApplyOutcome.kt b/src/main/kotlin/gg/grounds/domain/permissions/ApplyOutcome.kt similarity index 71% rename from src/main/kotlin/gg/grounds/domain/ApplyOutcome.kt rename to src/main/kotlin/gg/grounds/domain/permissions/ApplyOutcome.kt index 1fa50f5..c4dbc49 100644 --- a/src/main/kotlin/gg/grounds/domain/ApplyOutcome.kt +++ b/src/main/kotlin/gg/grounds/domain/permissions/ApplyOutcome.kt @@ -1,4 +1,4 @@ -package gg.grounds.domain +package gg.grounds.domain.permissions enum class ApplyOutcome { NO_CHANGE, diff --git a/src/main/kotlin/gg/grounds/domain/permissions/PermissionGroup.kt b/src/main/kotlin/gg/grounds/domain/permissions/PermissionGroup.kt new file mode 100644 index 0000000..214ae9a --- /dev/null +++ b/src/main/kotlin/gg/grounds/domain/permissions/PermissionGroup.kt @@ -0,0 +1,7 @@ +package gg.grounds.domain.permissions + +import java.time.Instant + +data class PermissionGroup(val name: String, val permissionGrants: Set) + +data class GroupPermissionGrant(val permission: String, val expiresAt: Instant?) diff --git a/src/main/kotlin/gg/grounds/domain/permissions/PlayerPermissionsData.kt b/src/main/kotlin/gg/grounds/domain/permissions/PlayerPermissionsData.kt new file mode 100644 index 0000000..11bd718 --- /dev/null +++ b/src/main/kotlin/gg/grounds/domain/permissions/PlayerPermissionsData.kt @@ -0,0 +1,15 @@ +package gg.grounds.domain.permissions + +import java.time.Instant +import java.util.UUID + +data class PlayerPermissionsData( + val playerId: UUID, + val groupMemberships: Set, + val directPermissionGrants: Set, + val effectivePermissions: Set, +) + +data class PlayerGroupMembership(val groupName: String, val expiresAt: Instant?) + +data class PlayerPermissionGrant(val permission: String, val expiresAt: Instant?) diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt index d6b60d4..748def4 100644 --- a/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt +++ b/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt @@ -1,11 +1,13 @@ package gg.grounds.persistence.permissions -import gg.grounds.domain.ApplyOutcome -import gg.grounds.domain.PermissionGroup +import gg.grounds.domain.permissions.ApplyOutcome +import gg.grounds.domain.permissions.GroupPermissionGrant +import gg.grounds.domain.permissions.PermissionGroup import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import java.sql.ResultSet import java.sql.SQLException +import java.sql.Timestamp import javax.sql.DataSource import org.jboss.logging.Logger @@ -68,7 +70,7 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { groupName, includePermissions, ) - null + throw PermissionsRepositoryException("Failed to load permission group", error) } } @@ -97,17 +99,21 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { "Failed to list permission groups (includePermissions=%s)", includePermissions, ) - emptyList() + throw PermissionsRepositoryException("Failed to list permission groups", error) } } - fun addGroupPermissions(groupName: String, permissions: Collection): ApplyOutcome { + fun addGroupPermissions( + groupName: String, + permissionGrants: Collection, + ): ApplyOutcome { return try { dataSource.connection.use { connection -> connection.prepareStatement(INSERT_GROUP_PERMISSION).use { statement -> - permissions.forEach { permission -> + permissionGrants.forEach { grant -> statement.setString(1, groupName) - statement.setString(2, permission) + statement.setString(2, grant.permission) + statement.setTimestamp(3, grant.expiresAt?.let { Timestamp.from(it) }) statement.addBatch() } val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 @@ -117,9 +123,9 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { } catch (error: SQLException) { LOG.errorf( error, - "Failed to add permissions to group (groupName=%s, permissionsCount=%d)", + "Failed to add permissions to group (groupName=%s, permissionGrantsCount=%d)", groupName, - permissions.size, + permissionGrants.size, ) ApplyOutcome.ERROR } @@ -150,11 +156,18 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { } private fun buildGroupFromResult(groupName: String, resultSet: ResultSet): PermissionGroup? { - val permissions = linkedSetOf() + val permissions = linkedSetOf() var sawGroup = false while (resultSet.next()) { sawGroup = true - resultSet.getString("permission")?.let { permissions.add(it) } + resultSet.getString("permission")?.let { permission -> + permissions.add( + GroupPermissionGrant( + permission, + resultSet.getTimestamp("expires_at")?.toInstant(), + ) + ) + } } return if (sawGroup) PermissionGroup(groupName, permissions) else null } @@ -162,7 +175,7 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { private fun buildGroupsFromResult(resultSet: ResultSet): List { val groups = mutableListOf() var currentName: String? = null - var currentPermissions = linkedSetOf() + var currentPermissions = linkedSetOf() while (resultSet.next()) { val name = resultSet.getString("name") if (currentName == null) { @@ -173,7 +186,14 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { currentName = name currentPermissions = linkedSetOf() } - resultSet.getString("permission")?.let { currentPermissions.add(it) } + resultSet.getString("permission")?.let { permission -> + currentPermissions.add( + GroupPermissionGrant( + permission, + resultSet.getTimestamp("expires_at")?.toInstant(), + ) + ) + } } currentName?.let { groups.add(PermissionGroup(it, currentPermissions)) } return groups @@ -207,7 +227,7 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { """ private const val SELECT_GROUP_WITH_PERMISSIONS = """ - SELECT pg.name, gp.permission + SELECT pg.name, gp.permission, gp.expires_at FROM permission_groups pg LEFT JOIN group_permissions gp ON gp.group_name = pg.name WHERE pg.name = ? @@ -215,16 +235,17 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { """ private const val SELECT_GROUPS_WITH_PERMISSIONS = """ - SELECT pg.name, gp.permission + SELECT pg.name, gp.permission, gp.expires_at FROM permission_groups pg LEFT JOIN group_permissions gp ON gp.group_name = pg.name ORDER BY pg.name, gp.permission """ private const val INSERT_GROUP_PERMISSION = """ - INSERT INTO group_permissions (group_name, permission) - VALUES (?, ?) - ON CONFLICT (group_name, permission) DO NOTHING + INSERT INTO group_permissions (group_name, permission, expires_at) + VALUES (?, ?, ?) + ON CONFLICT (group_name, permission) + DO UPDATE SET expires_at = EXCLUDED.expires_at """ private const val DELETE_GROUP_PERMISSION = """ diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt index 7e5805f..8f04f9e 100644 --- a/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt @@ -1,6 +1,8 @@ package gg.grounds.persistence.permissions -import gg.grounds.domain.PlayerPermissionsData +import gg.grounds.domain.permissions.PlayerGroupMembership +import gg.grounds.domain.permissions.PlayerPermissionGrant +import gg.grounds.domain.permissions.PlayerPermissionsData import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import java.sql.SQLException @@ -26,9 +28,16 @@ class PermissionsQueryRepository @Inject constructor(private val dataSource: Dat connection.prepareStatement(SELECT_PLAYER_GROUPS).use { statement -> statement.setObject(1, playerId) statement.executeQuery().use { resultSet -> - val names = linkedSetOf() + val names = linkedSetOf() while (resultSet.next()) { - resultSet.getString("group_name")?.let { names.add(it) } + resultSet.getString("group_name")?.let { groupName -> + names.add( + PlayerGroupMembership( + groupName, + resultSet.getTimestamp("expires_at")?.toInstant(), + ) + ) + } } names } @@ -42,9 +51,16 @@ class PermissionsQueryRepository @Inject constructor(private val dataSource: Dat connection.prepareStatement(SELECT_PLAYER_PERMISSIONS).use { statement -> statement.setObject(1, playerId) statement.executeQuery().use { resultSet -> - val permissions = linkedSetOf() + val permissions = linkedSetOf() while (resultSet.next()) { - resultSet.getString("permission")?.let { permissions.add(it) } + resultSet.getString("permission")?.let { permission -> + permissions.add( + PlayerPermissionGrant( + permission, + resultSet.getTimestamp("expires_at")?.toInstant(), + ) + ) + } } permissions } @@ -53,14 +69,16 @@ class PermissionsQueryRepository @Inject constructor(private val dataSource: Dat emptySet() } - val groupNames = if (includeGroups) loadedGroupNames else emptySet() - val directPermissions = + val groupMemberships = if (includeGroups) loadedGroupNames else emptySet() + val directPermissionGrants = if (includeDirectPermissions) loadedDirectPermissions else emptySet() val effectivePermissions = if (includeEffectivePermissions) { val permissions = linkedSetOf() - permissions.addAll(loadedDirectPermissions) + permissions.addAll( + loadedDirectPermissions.map { grant -> grant.permission } + ) connection.prepareStatement(SELECT_GROUP_PERMISSIONS_FOR_PLAYER).use { statement -> statement.setObject(1, playerId) @@ -75,7 +93,12 @@ class PermissionsQueryRepository @Inject constructor(private val dataSource: Dat emptySet() } - PlayerPermissionsData(playerId, groupNames, directPermissions, effectivePermissions) + PlayerPermissionsData( + playerId, + groupMemberships, + directPermissionGrants, + effectivePermissions, + ) } } catch (error: SQLException) { LOG.errorf( @@ -117,16 +140,18 @@ class PermissionsQueryRepository @Inject constructor(private val dataSource: Dat private const val SELECT_PLAYER_GROUPS = """ - SELECT group_name + SELECT group_name, expires_at FROM player_groups WHERE player_id = ? + AND (expires_at IS NULL OR expires_at > now()) ORDER BY group_name """ private const val SELECT_PLAYER_PERMISSIONS = """ - SELECT permission + SELECT permission, expires_at FROM player_permissions WHERE player_id = ? + AND (expires_at IS NULL OR expires_at > now()) ORDER BY permission """ private const val SELECT_GROUP_PERMISSIONS_FOR_PLAYER = @@ -135,6 +160,8 @@ class PermissionsQueryRepository @Inject constructor(private val dataSource: Dat FROM group_permissions gp JOIN player_groups pg ON gp.group_name = pg.group_name WHERE pg.player_id = ? + AND (pg.expires_at IS NULL OR pg.expires_at > now()) + AND (gp.expires_at IS NULL OR gp.expires_at > now()) ORDER BY gp.permission """ private const val CHECK_PLAYER_PERMISSION = @@ -143,12 +170,15 @@ class PermissionsQueryRepository @Inject constructor(private val dataSource: Dat FROM player_permissions WHERE player_id = ? AND permission = ? + AND (expires_at IS NULL OR expires_at > now()) UNION SELECT 1 FROM group_permissions gp JOIN player_groups pg ON gp.group_name = pg.group_name WHERE pg.player_id = ? AND gp.permission = ? + AND (pg.expires_at IS NULL OR pg.expires_at > now()) + AND (gp.expires_at IS NULL OR gp.expires_at > now()) LIMIT 1 """ } diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsRepositoryException.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsRepositoryException.kt new file mode 100644 index 0000000..4ba897b --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsRepositoryException.kt @@ -0,0 +1,4 @@ +package gg.grounds.persistence.permissions + +class PermissionsRepositoryException(message: String, cause: Throwable? = null) : + RuntimeException(message, cause) diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt index 8b85708..ad428be 100644 --- a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt @@ -1,22 +1,28 @@ package gg.grounds.persistence.permissions -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome +import gg.grounds.domain.permissions.PlayerGroupMembership import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import java.sql.SQLException +import java.sql.Timestamp import java.util.UUID import javax.sql.DataSource import org.jboss.logging.Logger @ApplicationScoped class PlayerGroupRepository @Inject constructor(private val dataSource: DataSource) { - fun addPlayerGroups(playerId: UUID, groupNames: Collection): ApplyOutcome { + fun addPlayerGroups( + playerId: UUID, + groupMemberships: Collection, + ): ApplyOutcome { return try { dataSource.connection.use { connection -> connection.prepareStatement(INSERT_PLAYER_GROUP).use { statement -> - groupNames.forEach { groupName -> + groupMemberships.forEach { membership -> statement.setObject(1, playerId) - statement.setString(2, groupName) + statement.setString(2, membership.groupName) + statement.setTimestamp(3, membership.expiresAt?.let { Timestamp.from(it) }) statement.addBatch() } val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 @@ -28,7 +34,7 @@ class PlayerGroupRepository @Inject constructor(private val dataSource: DataSour error, "Failed to add player groups (playerId=%s, groupCount=%d)", playerId, - groupNames.size, + groupMemberships.size, ) ApplyOutcome.ERROR } @@ -58,34 +64,15 @@ class PlayerGroupRepository @Inject constructor(private val dataSource: DataSour } } - fun getPlayerGroupNames(playerId: UUID): Set { - return try { - dataSource.connection.use { connection -> - connection.prepareStatement(SELECT_PLAYER_GROUPS).use { statement -> - statement.setObject(1, playerId) - statement.executeQuery().use { resultSet -> - val groupNames = linkedSetOf() - while (resultSet.next()) { - resultSet.getString("group_name")?.let { groupNames.add(it) } - } - groupNames - } - } - } - } catch (error: SQLException) { - LOG.errorf(error, "Failed to load player groups (playerId=%s)", playerId) - emptySet() - } - } - companion object { private val LOG = Logger.getLogger(PlayerGroupRepository::class.java) private const val INSERT_PLAYER_GROUP = """ - INSERT INTO player_groups (player_id, group_name) - VALUES (?, ?) - ON CONFLICT (player_id, group_name) DO NOTHING + INSERT INTO player_groups (player_id, group_name, expires_at) + VALUES (?, ?, ?) + ON CONFLICT (player_id, group_name) + DO UPDATE SET expires_at = EXCLUDED.expires_at """ private const val DELETE_PLAYER_GROUP = """ @@ -93,12 +80,5 @@ class PlayerGroupRepository @Inject constructor(private val dataSource: DataSour WHERE player_id = ? AND group_name = ? """ - private const val SELECT_PLAYER_GROUPS = - """ - SELECT group_name - FROM player_groups - WHERE player_id = ? - ORDER BY group_name - """ } } diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt index b27cd73..a19b387 100644 --- a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt @@ -1,22 +1,28 @@ package gg.grounds.persistence.permissions -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome +import gg.grounds.domain.permissions.PlayerPermissionGrant import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import java.sql.SQLException +import java.sql.Timestamp import java.util.UUID import javax.sql.DataSource import org.jboss.logging.Logger @ApplicationScoped class PlayerPermissionRepository @Inject constructor(private val dataSource: DataSource) { - fun addPlayerPermissions(playerId: UUID, permissions: Collection): ApplyOutcome { + fun addPlayerPermissions( + playerId: UUID, + permissionGrants: Collection, + ): ApplyOutcome { return try { dataSource.connection.use { connection -> connection.prepareStatement(INSERT_PLAYER_PERMISSION).use { statement -> - permissions.forEach { permission -> + permissionGrants.forEach { grant -> statement.setObject(1, playerId) - statement.setString(2, permission) + statement.setString(2, grant.permission) + statement.setTimestamp(3, grant.expiresAt?.let { Timestamp.from(it) }) statement.addBatch() } val updated = BatchUpdateHelper.countSuccessful(statement.executeBatch()) > 0 @@ -28,7 +34,7 @@ class PlayerPermissionRepository @Inject constructor(private val dataSource: Dat error, "Failed to add player permissions (playerId=%s, permissionsCount=%d)", playerId, - permissions.size, + permissionGrants.size, ) ApplyOutcome.ERROR } @@ -58,34 +64,15 @@ class PlayerPermissionRepository @Inject constructor(private val dataSource: Dat } } - fun getPlayerPermissions(playerId: UUID): Set { - return try { - dataSource.connection.use { connection -> - connection.prepareStatement(SELECT_PLAYER_PERMISSIONS).use { statement -> - statement.setObject(1, playerId) - statement.executeQuery().use { resultSet -> - val permissions = linkedSetOf() - while (resultSet.next()) { - resultSet.getString("permission")?.let { permissions.add(it) } - } - permissions - } - } - } - } catch (error: SQLException) { - LOG.errorf(error, "Failed to load player permissions (playerId=%s)", playerId) - emptySet() - } - } - companion object { private val LOG = Logger.getLogger(PlayerPermissionRepository::class.java) private const val INSERT_PLAYER_PERMISSION = """ - INSERT INTO player_permissions (player_id, permission) - VALUES (?, ?) - ON CONFLICT (player_id, permission) DO NOTHING + INSERT INTO player_permissions (player_id, permission, expires_at) + VALUES (?, ?, ?) + ON CONFLICT (player_id, permission) + DO UPDATE SET expires_at = EXCLUDED.expires_at """ private const val DELETE_PLAYER_PERMISSION = """ @@ -93,12 +80,5 @@ class PlayerPermissionRepository @Inject constructor(private val dataSource: Dat WHERE player_id = ? AND permission = ? """ - private const val SELECT_PLAYER_PERMISSIONS = - """ - SELECT permission - FROM player_permissions - WHERE player_id = ? - ORDER BY permission - """ } } diff --git a/src/main/resources/db/migration/V2__create_permissions.sql b/src/main/resources/db/migration/V2__create_permissions.sql index 13b68bf..3c6f301 100644 --- a/src/main/resources/db/migration/V2__create_permissions.sql +++ b/src/main/resources/db/migration/V2__create_permissions.sql @@ -5,21 +5,22 @@ CREATE TABLE IF NOT EXISTS permission_groups ( CREATE TABLE IF NOT EXISTS group_permissions ( group_name TEXT NOT NULL REFERENCES permission_groups(name) ON DELETE CASCADE, permission TEXT NOT NULL, + expires_at TIMESTAMPTZ, PRIMARY KEY (group_name, permission) ); CREATE TABLE IF NOT EXISTS player_groups ( - player_id UUID NOT NULL, - group_name TEXT NOT NULL REFERENCES permission_groups(name) ON DELETE CASCADE + player_id UUID NOT NULL, + group_name TEXT NOT NULL REFERENCES permission_groups(name) ON DELETE CASCADE, + expires_at TIMESTAMPTZ, + PRIMARY KEY (player_id, group_name) ); -ALTER TABLE player_groups DROP CONSTRAINT IF EXISTS player_groups_pkey; -ALTER TABLE player_groups ADD PRIMARY KEY (player_id, group_name); - CREATE INDEX IF NOT EXISTS idx_player_groups_group_name ON player_groups(group_name); CREATE TABLE IF NOT EXISTS player_permissions ( player_id UUID NOT NULL, permission TEXT NOT NULL, + expires_at TIMESTAMPTZ, PRIMARY KEY (player_id, permission) ); diff --git a/src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt b/src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt index 0c21481..32d6af5 100644 --- a/src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt +++ b/src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt @@ -1,11 +1,14 @@ package gg.grounds.api.permissions -import gg.grounds.domain.PlayerPermissionsData +import gg.grounds.domain.permissions.PlayerGroupMembership +import gg.grounds.domain.permissions.PlayerPermissionGrant +import gg.grounds.domain.permissions.PlayerPermissionsData import gg.grounds.grpc.permissions.CheckPlayerPermissionRequest import gg.grounds.grpc.permissions.GetPlayerPermissionsRequest import gg.grounds.persistence.permissions.PermissionsQueryRepository import io.grpc.Status import io.grpc.StatusRuntimeException +import java.time.Instant import java.util.UUID import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -38,11 +41,12 @@ class PermissionsPluginGrpcServiceTest { val repository = mock() val service = PermissionsPluginGrpcService(repository) val playerId = UUID.randomUUID() + val expiresAt = Instant.ofEpochSecond(1_700_000_000) val data = PlayerPermissionsData( playerId, - setOf("group-a"), - setOf("permission.read"), + setOf(PlayerGroupMembership("group-a", expiresAt)), + setOf(PlayerPermissionGrant("permission.read", expiresAt)), setOf("permission.read", "permission.write"), ) whenever(repository.getPlayerPermissions(playerId, true, false, true)).thenReturn(data) @@ -57,8 +61,22 @@ class PermissionsPluginGrpcServiceTest { val reply = service.getPlayerPermissions(request).await().indefinitely() assertEquals(playerId.toString(), reply.player.playerId) - assertEquals(setOf("group-a"), reply.player.groupNamesList.toSet()) - assertEquals(setOf("permission.read"), reply.player.directPermissionsList.toSet()) + assertEquals( + setOf("group-a"), + reply.player.groupMembershipsList.map { it.groupName }.toSet(), + ) + assertEquals( + expiresAt.epochSecond, + reply.player.groupMembershipsList.first().expiresAt.seconds, + ) + assertEquals( + setOf("permission.read"), + reply.player.directPermissionGrantsList.map { it.permission }.toSet(), + ) + assertEquals( + expiresAt.epochSecond, + reply.player.directPermissionGrantsList.first().expiresAt.seconds, + ) assertEquals( setOf("permission.read", "permission.write"), reply.player.effectivePermissionsList.toSet(), diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt index 4f08550..7837aef 100644 --- a/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt @@ -1,13 +1,19 @@ package gg.grounds.api.permissions.admin -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome +import gg.grounds.domain.permissions.GroupPermissionGrant import gg.grounds.grpc.permissions.AddGroupPermissionsRequest import gg.grounds.grpc.permissions.ApplyResult import gg.grounds.grpc.permissions.CreateGroupRequest +import gg.grounds.grpc.permissions.PermissionGrant as PermissionGrantProto import gg.grounds.grpc.permissions.RemoveGroupPermissionsRequest import gg.grounds.persistence.permissions.GroupRepository +import io.grpc.Status +import io.grpc.StatusRuntimeException +import java.time.Instant import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -20,9 +26,11 @@ class GroupAdminHandlerTest { val handler = GroupAdminHandler(repository) val request = CreateGroupRequest.newBuilder().setGroupName(" ").build() - val reply = handler.createGroup(request) + val exception = assertThrows { handler.createGroup(request) } - assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("group_name must be provided", status.description) verifyNoInteractions(repository) } @@ -45,9 +53,68 @@ class GroupAdminHandlerTest { val handler = GroupAdminHandler(repository) val request = AddGroupPermissionsRequest.newBuilder().setGroupName("admins").build() + val exception = + assertThrows { handler.addGroupPermissions(request) } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("group_name and permission_grants must be provided", status.description) + verifyNoInteractions(repository) + } + + @Test + fun addGroupPermissionsReturnsOutcome() { + val repository = mock() + val handler = GroupAdminHandler(repository) + whenever( + repository.addGroupPermissions( + "admins", + setOf(GroupPermissionGrant("permission.read", null)), + ) + ) + .thenReturn(ApplyOutcome.UPDATED) + val request = + AddGroupPermissionsRequest.newBuilder() + .setGroupName("admins") + .addPermissionGrants( + PermissionGrantProto.newBuilder().setPermission("permission.read").build() + ) + .build() + val reply = handler.addGroupPermissions(request) - assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(repository) + .addGroupPermissions("admins", setOf(GroupPermissionGrant("permission.read", null))) + } + + @Test + fun addGroupPermissionsRejectsPastExpiry() { + val repository = mock() + val handler = GroupAdminHandler(repository) + val expiresAt = Instant.now().minusSeconds(10) + val request = + AddGroupPermissionsRequest.newBuilder() + .setGroupName("admins") + .addPermissionGrants( + PermissionGrantProto.newBuilder() + .setPermission("permission.read") + .setExpiresAt( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(expiresAt.epochSecond) + .setNanos(expiresAt.nano) + .build() + ) + .build() + ) + .build() + + val exception = + assertThrows { handler.addGroupPermissions(request) } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("expires_at must be in the future", status.description) verifyNoInteractions(repository) } diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt index 87525be..a005853 100644 --- a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt @@ -1,13 +1,19 @@ package gg.grounds.api.permissions.admin -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome +import gg.grounds.domain.permissions.PlayerGroupMembership import gg.grounds.grpc.permissions.AddPlayerGroupsRequest import gg.grounds.grpc.permissions.ApplyResult +import gg.grounds.grpc.permissions.PlayerGroupMembership as PlayerGroupMembershipProto import gg.grounds.grpc.permissions.RemovePlayerGroupsRequest import gg.grounds.persistence.permissions.PlayerGroupRepository +import io.grpc.Status +import io.grpc.StatusRuntimeException +import java.time.Instant import java.util.UUID import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -21,12 +27,16 @@ class PlayerGroupsAdminHandlerTest { val request = AddPlayerGroupsRequest.newBuilder() .setPlayerId("not-a-uuid") - .addGroupNames("vip") + .addGroupMemberships( + PlayerGroupMembershipProto.newBuilder().setGroupName("vip").build() + ) .build() - val reply = handler.addPlayerGroups(request) + val exception = assertThrows { handler.addPlayerGroups(request) } - assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("player_id must be a UUID", status.description) verifyNoInteractions(repository) } @@ -35,18 +45,82 @@ class PlayerGroupsAdminHandlerTest { val repository = mock() val handler = PlayerGroupsAdminHandler(repository) val playerId = UUID.randomUUID() - whenever(repository.addPlayerGroups(playerId, setOf("vip"))) + whenever(repository.addPlayerGroups(playerId, setOf(PlayerGroupMembership("vip", null)))) .thenReturn(ApplyOutcome.UPDATED) val request = AddPlayerGroupsRequest.newBuilder() .setPlayerId(playerId.toString()) - .addGroupNames("vip") + .addGroupMemberships( + PlayerGroupMembershipProto.newBuilder().setGroupName("vip").build() + ) .build() val reply = handler.addPlayerGroups(request) assertEquals(ApplyResult.UPDATED, reply.applyResult) - verify(repository).addPlayerGroups(playerId, setOf("vip")) + verify(repository).addPlayerGroups(playerId, setOf(PlayerGroupMembership("vip", null))) + } + + @Test + fun addPlayerGroupsAcceptsExpiry() { + val repository = mock() + val handler = PlayerGroupsAdminHandler(repository) + val playerId = UUID.randomUUID() + val expiresAt = Instant.now().plusSeconds(60) + whenever( + repository.addPlayerGroups(playerId, setOf(PlayerGroupMembership("vip", expiresAt))) + ) + .thenReturn(ApplyOutcome.UPDATED) + val request = + AddPlayerGroupsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addGroupMemberships( + PlayerGroupMembershipProto.newBuilder() + .setGroupName("vip") + .setExpiresAt( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(expiresAt.epochSecond) + .setNanos(expiresAt.nano) + .build() + ) + .build() + ) + .build() + + val reply = handler.addPlayerGroups(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(repository).addPlayerGroups(playerId, setOf(PlayerGroupMembership("vip", expiresAt))) + } + + @Test + fun addPlayerGroupsRejectsPastExpiry() { + val repository = mock() + val handler = PlayerGroupsAdminHandler(repository) + val playerId = UUID.randomUUID() + val expiresAt = Instant.now().minusSeconds(10) + val request = + AddPlayerGroupsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addGroupMemberships( + PlayerGroupMembershipProto.newBuilder() + .setGroupName("vip") + .setExpiresAt( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(expiresAt.epochSecond) + .setNanos(expiresAt.nano) + .build() + ) + .build() + ) + .build() + + val exception = assertThrows { handler.addPlayerGroups(request) } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("expires_at must be in the future", status.description) + verifyNoInteractions(repository) } @Test @@ -56,9 +130,11 @@ class PlayerGroupsAdminHandlerTest { val request = RemovePlayerGroupsRequest.newBuilder().setPlayerId(UUID.randomUUID().toString()).build() - val reply = handler.removePlayerGroups(request) + val exception = assertThrows { handler.removePlayerGroups(request) } - assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("group_names must be provided", status.description) verifyNoInteractions(repository) } } diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt index fa5c9ea..c786c9d 100644 --- a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt @@ -1,13 +1,19 @@ package gg.grounds.api.permissions.admin -import gg.grounds.domain.ApplyOutcome +import gg.grounds.domain.permissions.ApplyOutcome +import gg.grounds.domain.permissions.PlayerPermissionGrant import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest import gg.grounds.grpc.permissions.ApplyResult +import gg.grounds.grpc.permissions.PermissionGrant as PermissionGrantProto import gg.grounds.grpc.permissions.RemovePlayerPermissionsRequest import gg.grounds.persistence.permissions.PlayerPermissionRepository +import io.grpc.Status +import io.grpc.StatusRuntimeException +import java.time.Instant import java.util.UUID import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -21,12 +27,17 @@ class PlayerPermissionsAdminHandlerTest { val request = AddPlayerPermissionsRequest.newBuilder() .setPlayerId("not-a-uuid") - .addPermissions("permission.read") + .addPermissionGrants( + PermissionGrantProto.newBuilder().setPermission("permission.read").build() + ) .build() - val reply = handler.addPlayerPermissions(request) + val exception = + assertThrows { handler.addPlayerPermissions(request) } - assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("player_id must be a UUID", status.description) verifyNoInteractions(repository) } @@ -35,18 +46,96 @@ class PlayerPermissionsAdminHandlerTest { val repository = mock() val handler = PlayerPermissionsAdminHandler(repository) val playerId = UUID.randomUUID() - whenever(repository.addPlayerPermissions(playerId, setOf("permission.read"))) + whenever( + repository.addPlayerPermissions( + playerId, + setOf(PlayerPermissionGrant("permission.read", null)), + ) + ) .thenReturn(ApplyOutcome.UPDATED) val request = AddPlayerPermissionsRequest.newBuilder() .setPlayerId(playerId.toString()) - .addPermissions("permission.read") + .addPermissionGrants( + PermissionGrantProto.newBuilder().setPermission("permission.read").build() + ) .build() val reply = handler.addPlayerPermissions(request) assertEquals(ApplyResult.UPDATED, reply.applyResult) - verify(repository).addPlayerPermissions(playerId, setOf("permission.read")) + verify(repository) + .addPlayerPermissions(playerId, setOf(PlayerPermissionGrant("permission.read", null))) + } + + @Test + fun addPlayerPermissionsAcceptsExpiry() { + val repository = mock() + val handler = PlayerPermissionsAdminHandler(repository) + val playerId = UUID.randomUUID() + val expiresAt = Instant.now().plusSeconds(60) + whenever( + repository.addPlayerPermissions( + playerId, + setOf(PlayerPermissionGrant("permission.read", expiresAt)), + ) + ) + .thenReturn(ApplyOutcome.UPDATED) + val request = + AddPlayerPermissionsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addPermissionGrants( + PermissionGrantProto.newBuilder() + .setPermission("permission.read") + .setExpiresAt( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(expiresAt.epochSecond) + .setNanos(expiresAt.nano) + .build() + ) + .build() + ) + .build() + + val reply = handler.addPlayerPermissions(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(repository) + .addPlayerPermissions( + playerId, + setOf(PlayerPermissionGrant("permission.read", expiresAt)), + ) + } + + @Test + fun addPlayerPermissionsRejectsPastExpiry() { + val repository = mock() + val handler = PlayerPermissionsAdminHandler(repository) + val playerId = UUID.randomUUID() + val expiresAt = Instant.now().minusSeconds(5) + val request = + AddPlayerPermissionsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addPermissionGrants( + PermissionGrantProto.newBuilder() + .setPermission("permission.read") + .setExpiresAt( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(expiresAt.epochSecond) + .setNanos(expiresAt.nano) + .build() + ) + .build() + ) + .build() + + val exception = + assertThrows { handler.addPlayerPermissions(request) } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("expires_at must be in the future", status.description) + verifyNoInteractions(repository) } @Test @@ -58,9 +147,12 @@ class PlayerPermissionsAdminHandlerTest { .setPlayerId(UUID.randomUUID().toString()) .build() - val reply = handler.removePlayerPermissions(request) + val exception = + assertThrows { handler.removePlayerPermissions(request) } - assertEquals(ApplyResult.APPLY_RESULT_UNSPECIFIED, reply.applyResult) + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("permissions must be provided", status.description) verifyNoInteractions(repository) } } From 665a36bbd26252eca9675476468a4ba2a6b16906 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 8 Mar 2026 21:04:03 +0100 Subject: [PATCH 3/5] chore: use pushed branch version of gprc-contracts # Conflicts: # build.gradle.kts --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2fc9837..221fba5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,7 +23,7 @@ dependencies { implementation("io.quarkus:quarkus-kotlin") implementation("io.quarkus:quarkus-scheduler") implementation("gg.grounds:library-grpc-contracts-player:0.2.0") - implementation("gg.grounds:library-grpc-contracts-permission:local-SNAPSHOT") + implementation("gg.grounds:library-grpc-contracts-permission:feat-perm-protos-SNAPSHOT") compileOnly("com.google.protobuf:protobuf-kotlin") From 7389ba34536722beeae0576109f82258bcd34b04 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 8 Mar 2026 21:05:21 +0100 Subject: [PATCH 4/5] feat: add realtime permission updates # Conflicts: # src/main/kotlin/gg/grounds/persistence/PlayerSessionRepository.kt # src/main/resources/application.properties --- build.gradle.kts | 1 + .../permissions/admin/GroupAdminHandler.kt | 24 +- .../admin/PlayerGroupsAdminHandler.kt | 16 +- .../admin/PlayerPermissionsAdminHandler.kt | 19 +- .../events/PermissionsChangeEmitter.kt | 254 ++++++++++++++++++ .../events/PermissionsChangeService.kt | 83 ++++++ .../events/PermissionsEventsGrpcService.kt | 74 +++++ .../events/PermissionsEventsPublisher.kt | 18 ++ .../events/PermissionsExpiryScheduler.kt | 81 ++++++ .../persistence/PlayerSessionRepository.kt | 25 ++ .../permissions/GroupRepository.kt | 35 +++ .../permissions/PlayerGroupRepository.kt | 89 ++++++ .../permissions/PlayerPermissionRepository.kt | 61 +++++ src/main/resources/application.properties | 1 + .../admin/GroupAdminHandlerTest.kt | 44 ++- .../admin/PlayerGroupsAdminHandlerTest.kt | 29 +- .../PlayerPermissionsAdminHandlerTest.kt | 44 +-- .../PermissionsEventsGrpcServiceTest.kt | 52 ++++ 18 files changed, 898 insertions(+), 52 deletions(-) create mode 100644 src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeEmitter.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsPublisher.kt create mode 100644 src/main/kotlin/gg/grounds/api/permissions/events/PermissionsExpiryScheduler.kt create mode 100644 src/test/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcServiceTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 221fba5..0227ea0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(enforcedPlatform("io.quarkus.platform:quarkus-bom:3.30.8")) implementation("io.quarkus:quarkus-arc") implementation("io.quarkus:quarkus-grpc") + implementation("io.quarkus:quarkus-scheduler") implementation("io.quarkus:quarkus-jdbc-postgresql") implementation("io.quarkus:quarkus-flyway") implementation("io.quarkus:quarkus-kotlin") diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt index 9378a8c..a681524 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt @@ -3,6 +3,7 @@ package gg.grounds.api.permissions.admin import gg.grounds.api.permissions.ApplyResultMapper import gg.grounds.api.permissions.PermissionsProtoMapper import gg.grounds.api.permissions.PermissionsRequestParser +import gg.grounds.api.permissions.events.PermissionsChangeService import gg.grounds.domain.permissions.ApplyOutcome import gg.grounds.grpc.permissions.AddGroupPermissionsReply import gg.grounds.grpc.permissions.AddGroupPermissionsRequest @@ -25,7 +26,12 @@ import java.time.Instant import org.jboss.logging.Logger @ApplicationScoped -class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRepository) { +class GroupAdminHandler +@Inject +constructor( + private val groupRepository: GroupRepository, + private val permissionsChangeService: PermissionsChangeService, +) { fun createGroup(request: CreateGroupRequest): CreateGroupReply { val groupName = request.groupName?.trim().orEmpty() if (groupName.isEmpty()) { @@ -148,7 +154,13 @@ class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRe .asRuntimeException() } - val outcome = groupRepository.addGroupPermissions(groupName, permissionGrants) + val outcome = + permissionsChangeService.emitGroupPermissionsDeltaIfChanged( + groupName, + "group_permission_add", + ) { + groupRepository.addGroupPermissions(groupName, permissionGrants) + } if (outcome == ApplyOutcome.ERROR) { throw Status.INTERNAL.withDescription("Failed to add group permissions") .asRuntimeException() @@ -181,7 +193,13 @@ class GroupAdminHandler @Inject constructor(private val groupRepository: GroupRe .asRuntimeException() } - val outcome = groupRepository.removeGroupPermissions(groupName, permissions) + val outcome = + permissionsChangeService.emitGroupPermissionsDeltaIfChanged( + groupName, + "group_permission_remove", + ) { + groupRepository.removeGroupPermissions(groupName, permissions) + } if (outcome == ApplyOutcome.ERROR) { throw Status.INTERNAL.withDescription("Failed to remove group permissions") .asRuntimeException() diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt index cb8bb30..cd0e2ca 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt @@ -2,6 +2,7 @@ package gg.grounds.api.permissions.admin import gg.grounds.api.permissions.ApplyResultMapper import gg.grounds.api.permissions.PermissionsRequestParser +import gg.grounds.api.permissions.events.PermissionsChangeService import gg.grounds.domain.permissions.ApplyOutcome import gg.grounds.grpc.permissions.AddPlayerGroupsReply import gg.grounds.grpc.permissions.AddPlayerGroupsRequest @@ -17,7 +18,10 @@ import org.jboss.logging.Logger @ApplicationScoped class PlayerGroupsAdminHandler @Inject -constructor(private val playerGroupRepository: PlayerGroupRepository) { +constructor( + private val playerGroupRepository: PlayerGroupRepository, + private val permissionsChangeService: PermissionsChangeService, +) { fun addPlayerGroups(request: AddPlayerGroupsRequest): AddPlayerGroupsReply { val rawPlayerId = request.playerId?.trim().orEmpty() val playerId = @@ -54,7 +58,10 @@ constructor(private val playerGroupRepository: PlayerGroupRepository) { .asRuntimeException() } - val outcome = playerGroupRepository.addPlayerGroups(playerId, groupMemberships) + val outcome = + permissionsChangeService.emitPlayerDeltaIfChanged(playerId, "player_group_add") { + playerGroupRepository.addPlayerGroups(playerId, groupMemberships) + } if (outcome == ApplyOutcome.ERROR) { throw Status.INTERNAL.withDescription("Failed to add player groups") .asRuntimeException() @@ -92,7 +99,10 @@ constructor(private val playerGroupRepository: PlayerGroupRepository) { .asRuntimeException() } - val outcome = playerGroupRepository.removePlayerGroups(playerId, groupNames) + val outcome = + permissionsChangeService.emitPlayerDeltaIfChanged(playerId, "player_group_remove") { + playerGroupRepository.removePlayerGroups(playerId, groupNames) + } if (outcome == ApplyOutcome.ERROR) { throw Status.INTERNAL.withDescription("Failed to remove player groups") .asRuntimeException() diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt index 8fd41f2..2509118 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt @@ -2,6 +2,7 @@ package gg.grounds.api.permissions.admin import gg.grounds.api.permissions.ApplyResultMapper import gg.grounds.api.permissions.PermissionsRequestParser +import gg.grounds.api.permissions.events.PermissionsChangeService import gg.grounds.domain.permissions.ApplyOutcome import gg.grounds.grpc.permissions.AddPlayerPermissionsReply import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest @@ -17,7 +18,10 @@ import org.jboss.logging.Logger @ApplicationScoped class PlayerPermissionsAdminHandler @Inject -constructor(private val playerPermissionRepository: PlayerPermissionRepository) { +constructor( + private val playerPermissionRepository: PlayerPermissionRepository, + private val permissionsChangeService: PermissionsChangeService, +) { fun addPlayerPermissions(request: AddPlayerPermissionsRequest): AddPlayerPermissionsReply { val rawPlayerId = request.playerId?.trim().orEmpty() val playerId = @@ -54,7 +58,10 @@ constructor(private val playerPermissionRepository: PlayerPermissionRepository) .asRuntimeException() } - val outcome = playerPermissionRepository.addPlayerPermissions(playerId, permissionGrants) + val outcome = + permissionsChangeService.emitPlayerDeltaIfChanged(playerId, "player_permission_add") { + playerPermissionRepository.addPlayerPermissions(playerId, permissionGrants) + } if (outcome == ApplyOutcome.ERROR) { throw Status.INTERNAL.withDescription("Failed to add player permissions") .asRuntimeException() @@ -94,7 +101,13 @@ constructor(private val playerPermissionRepository: PlayerPermissionRepository) .asRuntimeException() } - val outcome = playerPermissionRepository.removePlayerPermissions(playerId, permissions) + val outcome = + permissionsChangeService.emitPlayerDeltaIfChanged( + playerId, + "player_permission_remove", + ) { + playerPermissionRepository.removePlayerPermissions(playerId, permissions) + } if (outcome == ApplyOutcome.ERROR) { throw Status.INTERNAL.withDescription("Failed to remove player permissions") .asRuntimeException() diff --git a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeEmitter.kt b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeEmitter.kt new file mode 100644 index 0000000..2b3ff6f --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeEmitter.kt @@ -0,0 +1,254 @@ +package gg.grounds.api.permissions.events + +import com.google.protobuf.Timestamp +import gg.grounds.domain.permissions.PlayerGroupMembership +import gg.grounds.domain.permissions.PlayerPermissionGrant +import gg.grounds.domain.permissions.PlayerPermissionsData +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 jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import java.time.Instant +import java.util.UUID +import org.jboss.logging.Logger + +@ApplicationScoped +class PermissionsChangeEmitter +@Inject +constructor(private val publisher: PermissionsEventsPublisher) { + fun emitPlayerDelta( + playerId: UUID, + reason: String, + before: PlayerPermissionsData?, + after: PlayerPermissionsData?, + ) { + emitDeltaEvent( + playerId = playerId, + reason = reason, + before = before, + after = after, + includeDirect = true, + includeGroups = true, + ) + } + + fun emitEffectiveDelta( + playerId: UUID, + reason: String, + before: PlayerPermissionsData?, + after: PlayerPermissionsData?, + ) { + emitDeltaEvent( + playerId = playerId, + reason = reason, + before = before, + after = after, + includeDirect = false, + includeGroups = false, + ) + } + + fun emitRefresh(playerId: UUID, reason: String) { + val event = + PermissionsChangeEvent.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setPlayerId(playerId.toString()) + .setOccurredAt(nowTimestamp()) + .setReason(reason) + .setRequiresFullRefresh(true) + .build() + publisher.publish(event) + LOG.debugf( + "Permissions refresh event published (playerId=%s, eventId=%s, reason=%s)", + playerId, + event.eventId, + reason, + ) + } + + private fun emitDeltaEvent( + playerId: UUID, + reason: String, + before: PlayerPermissionsData?, + after: PlayerPermissionsData?, + includeDirect: Boolean, + includeGroups: Boolean, + ) { + if (before == null || after == null) { + emitRefresh(playerId, reason) + return + } + + val directDeltas = + if (includeDirect) { + diffDirectPermissions(before.directPermissionGrants, after.directPermissionGrants) + } else { + emptyList() + } + val groupDeltas = + if (includeGroups) { + diffGroupMemberships(before.groupMemberships, after.groupMemberships) + } else { + emptyList() + } + val effectiveDeltas = + diffEffectivePermissions(before.effectivePermissions, after.effectivePermissions) + + if (directDeltas.isEmpty() && groupDeltas.isEmpty() && effectiveDeltas.isEmpty()) { + LOG.debugf("Permissions change event skipped (playerId=%s, reason=no_deltas)", playerId) + return + } + + val event = + PermissionsChangeEvent.newBuilder() + .setEventId(UUID.randomUUID().toString()) + .setPlayerId(playerId.toString()) + .setOccurredAt(nowTimestamp()) + .setReason(reason) + .addAllDirectPermissionDeltas(directDeltas) + .addAllGroupMembershipDeltas(groupDeltas) + .addAllEffectivePermissionDeltas(effectiveDeltas) + .build() + + publisher.publish(event) + LOG.debugf( + "Permissions change event published (playerId=%s, eventId=%s, reason=%s, refresh=%s)", + playerId, + event.eventId, + reason, + event.requiresFullRefresh, + ) + } + + private fun diffDirectPermissions( + before: Set, + after: Set, + ): List { + val beforeMap = before.associateBy { it.permission } + val afterMap = after.associateBy { it.permission } + val deltas = mutableListOf() + + beforeMap.forEach { (permission, grant) -> + val afterGrant = afterMap[permission] + if (afterGrant == null) { + deltas.add( + PermissionDelta.newBuilder() + .setAction(PermissionDelta.Action.REMOVE) + .setPermission(permission) + .build() + ) + } else if (grant.expiresAt != afterGrant.expiresAt) { + deltas.add( + PermissionDelta.newBuilder() + .setAction(PermissionDelta.Action.REMOVE) + .setPermission(permission) + .build() + ) + deltas.add( + PermissionDelta.newBuilder() + .setAction(PermissionDelta.Action.ADD) + .setPermission(permission) + .apply { afterGrant.expiresAt?.let { setExpiresAt(toTimestamp(it)) } } + .build() + ) + } + } + + afterMap.forEach { (permission, grant) -> + if (beforeMap.containsKey(permission)) return@forEach + deltas.add( + PermissionDelta.newBuilder() + .setAction(PermissionDelta.Action.ADD) + .setPermission(permission) + .apply { grant.expiresAt?.let { setExpiresAt(toTimestamp(it)) } } + .build() + ) + } + + return deltas + } + + private fun diffGroupMemberships( + before: Set, + after: Set, + ): List { + val beforeMap = before.associateBy { it.groupName } + val afterMap = after.associateBy { it.groupName } + val deltas = mutableListOf() + + beforeMap.forEach { (groupName, membership) -> + val afterMembership = afterMap[groupName] + if (afterMembership == null) { + deltas.add( + GroupMembershipDelta.newBuilder() + .setAction(GroupMembershipDelta.Action.REMOVE) + .setGroupName(groupName) + .build() + ) + } else if (membership.expiresAt != afterMembership.expiresAt) { + deltas.add( + GroupMembershipDelta.newBuilder() + .setAction(GroupMembershipDelta.Action.REMOVE) + .setGroupName(groupName) + .build() + ) + deltas.add( + GroupMembershipDelta.newBuilder() + .setAction(GroupMembershipDelta.Action.ADD) + .setGroupName(groupName) + .apply { afterMembership.expiresAt?.let { setExpiresAt(toTimestamp(it)) } } + .build() + ) + } + } + + afterMap.forEach { (groupName, membership) -> + if (beforeMap.containsKey(groupName)) return@forEach + deltas.add( + GroupMembershipDelta.newBuilder() + .setAction(GroupMembershipDelta.Action.ADD) + .setGroupName(groupName) + .apply { membership.expiresAt?.let { setExpiresAt(toTimestamp(it)) } } + .build() + ) + } + + return deltas + } + + private fun diffEffectivePermissions( + before: Set, + after: Set, + ): List { + val deltas = mutableListOf() + before.filterNot(after::contains).forEach { permission -> + deltas.add( + EffectivePermissionDelta.newBuilder() + .setAction(EffectivePermissionDelta.Action.REMOVE) + .setPermission(permission) + .build() + ) + } + after.filterNot(before::contains).forEach { permission -> + deltas.add( + EffectivePermissionDelta.newBuilder() + .setAction(EffectivePermissionDelta.Action.ADD) + .setPermission(permission) + .build() + ) + } + return deltas + } + + private fun nowTimestamp(): Timestamp = toTimestamp(Instant.now()) + + private fun toTimestamp(instant: Instant): Timestamp { + return Timestamp.newBuilder().setSeconds(instant.epochSecond).setNanos(instant.nano).build() + } + + companion object { + private val LOG = Logger.getLogger(PermissionsChangeEmitter::class.java) + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt new file mode 100644 index 0000000..0878bca --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt @@ -0,0 +1,83 @@ +package gg.grounds.api.permissions.events + +import gg.grounds.domain.permissions.ApplyOutcome +import gg.grounds.persistence.permissions.PermissionsQueryRepository +import gg.grounds.persistence.permissions.PlayerGroupRepository +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import java.util.UUID + +@ApplicationScoped +class PermissionsChangeService +@Inject +constructor( + private val playerGroupRepository: PlayerGroupRepository, + private val permissionsQueryRepository: PermissionsQueryRepository, + private val permissionsChangeEmitter: PermissionsChangeEmitter, +) { + fun emitPlayerDeltaIfChanged( + playerId: UUID, + reason: String, + change: () -> ApplyOutcome, + ): ApplyOutcome { + val before = + permissionsQueryRepository.getPlayerPermissions( + playerId, + includeEffectivePermissions = true, + includeDirectPermissions = true, + includeGroups = true, + ) + val outcome = change() + if (outcome != ApplyOutcome.NO_CHANGE && outcome != ApplyOutcome.ERROR) { + val after = + permissionsQueryRepository.getPlayerPermissions( + playerId, + includeEffectivePermissions = true, + includeDirectPermissions = true, + includeGroups = true, + ) + permissionsChangeEmitter.emitPlayerDelta(playerId, reason, before, after) + } + return outcome + } + + fun emitGroupPermissionsDeltaIfChanged( + groupName: String, + reason: String, + change: () -> ApplyOutcome, + ): ApplyOutcome { + val playerIds = playerGroupRepository.listActivePlayersForGroup(groupName) + val before = + playerIds.associateWith { playerId -> + permissionsQueryRepository.getPlayerPermissions( + playerId, + includeEffectivePermissions = true, + includeDirectPermissions = true, + includeGroups = true, + ) + } + val outcome = change() + if ( + outcome != ApplyOutcome.NO_CHANGE && + outcome != ApplyOutcome.ERROR && + playerIds.isNotEmpty() + ) { + playerIds.forEach { playerId -> + val after = + permissionsQueryRepository.getPlayerPermissions( + playerId, + includeEffectivePermissions = true, + includeDirectPermissions = true, + includeGroups = true, + ) + permissionsChangeEmitter.emitEffectiveDelta( + playerId, + reason, + before[playerId], + after, + ) + } + } + return outcome + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt new file mode 100644 index 0000000..712098e --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt @@ -0,0 +1,74 @@ +package gg.grounds.api.permissions.events + +import gg.grounds.grpc.permissions.PermissionsChangeEvent +import gg.grounds.grpc.permissions.PermissionsEventsService +import gg.grounds.grpc.permissions.SubscribePermissionsChangesRequest +import gg.grounds.persistence.PlayerSessionRepository +import io.quarkus.grpc.GrpcService +import io.smallrye.mutiny.Multi +import jakarta.inject.Inject +import org.jboss.logging.Logger + +@GrpcService +class PermissionsEventsGrpcService +@Inject +constructor( + private val publisher: PermissionsEventsPublisher, + private val permissionsChangeEmitter: PermissionsChangeEmitter, + private val playerSessionRepository: PlayerSessionRepository, +) : PermissionsEventsService { + override fun subscribePermissionsChanges( + request: SubscribePermissionsChangesRequest + ): Multi { + LOG.infof( + "Permissions event subscription established (serverId=%s, lastEventId=%s)", + request.serverId, + request.lastEventId, + ) + if (request.lastEventId.isNotBlank()) { + val playerIds = playerSessionRepository.listActivePlayers() + playerIds.forEach { playerId -> + permissionsChangeEmitter.emitRefresh(playerId, "subscription_resume") + } + LOG.infof( + "Permissions subscription refresh completed (serverId=%s, lastEventId=%s, affectedPlayers=%d)", + request.serverId, + request.lastEventId, + playerIds.size, + ) + } + return publisher + .stream() + .onFailure() + .invoke { error -> + val reason = error.message ?: error::class.java.name + LOG.warnf( + error, + "Permissions event subscription failed (serverId=%s, lastEventId=%s, reason=%s)", + request.serverId, + request.lastEventId, + reason, + ) + } + .onCancellation() + .invoke { + LOG.infof( + "Permissions event subscription cancelled (serverId=%s, lastEventId=%s)", + request.serverId, + request.lastEventId, + ) + } + .onCompletion() + .invoke { + LOG.infof( + "Permissions event subscription completed (serverId=%s, lastEventId=%s)", + request.serverId, + request.lastEventId, + ) + } + } + + companion object { + private val LOG = Logger.getLogger(PermissionsEventsGrpcService::class.java) + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsPublisher.kt b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsPublisher.kt new file mode 100644 index 0000000..1644dc0 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsPublisher.kt @@ -0,0 +1,18 @@ +package gg.grounds.api.permissions.events + +import gg.grounds.grpc.permissions.PermissionsChangeEvent +import io.smallrye.mutiny.Multi +import io.smallrye.mutiny.helpers.MultiEmitterProcessor +import jakarta.enterprise.context.ApplicationScoped + +@ApplicationScoped +class PermissionsEventsPublisher { + private val processor = MultiEmitterProcessor.create() + private val broadcast = processor.toMulti().broadcast().toAllSubscribers() + + fun stream(): Multi = broadcast + + fun publish(event: PermissionsChangeEvent) { + processor.onNext(event) + } +} diff --git a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsExpiryScheduler.kt b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsExpiryScheduler.kt new file mode 100644 index 0000000..32bfc24 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsExpiryScheduler.kt @@ -0,0 +1,81 @@ +package gg.grounds.api.permissions.events + +import gg.grounds.persistence.permissions.GroupRepository +import gg.grounds.persistence.permissions.PlayerGroupRepository +import gg.grounds.persistence.permissions.PlayerPermissionRepository +import io.quarkus.scheduler.Scheduled +import jakarta.annotation.PostConstruct +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import java.time.Instant +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import org.jboss.logging.Logger + +@ApplicationScoped +class PermissionsExpiryScheduler +@Inject +constructor( + private val playerPermissionRepository: PlayerPermissionRepository, + private val playerGroupRepository: PlayerGroupRepository, + private val groupRepository: GroupRepository, + private val permissionsChangeEmitter: PermissionsChangeEmitter, +) { + private val lastScan = AtomicReference(Instant.now().minusSeconds(DEFAULT_BOOTSTRAP_SECONDS)) + + @PostConstruct + fun refreshOnStartup() { + val now = Instant.now() + val affectedPlayers = + linkedSetOf().apply { + addAll(playerPermissionRepository.listPlayersWithPermissions()) + addAll(playerGroupRepository.listPlayersWithGroups()) + } + affectedPlayers.forEach { playerId -> + permissionsChangeEmitter.emitRefresh(playerId, "startup") + } + lastScan.set(now) + LOG.infof( + "Permissions startup refresh completed (affectedPlayers=%d, scannedAt=%s)", + affectedPlayers.size, + now, + ) + } + + @Scheduled(every = "{permissions.expiry.scan.every}") + fun scanExpirations() { + val now = Instant.now() + val since = lastScan.getAndSet(now) + if (!since.isBefore(now)) { + return + } + + val expiredPlayers = + linkedSetOf().apply { + addAll(playerPermissionRepository.listPlayersWithExpiredPermissions(since, now)) + addAll(playerGroupRepository.listPlayersWithExpiredGroups(since, now)) + } + + val expiredGroups = groupRepository.listGroupsWithExpiredPermissions(since, now) + expiredGroups.forEach { groupName -> + expiredPlayers.addAll(playerGroupRepository.listActivePlayersForGroup(groupName)) + } + + expiredPlayers.forEach { playerId -> + permissionsChangeEmitter.emitRefresh(playerId, "expiry") + } + + LOG.debugf( + "Permissions expiry scan completed (since=%s, until=%s, affectedPlayers=%d, expiredGroups=%d)", + since, + now, + expiredPlayers.size, + expiredGroups.size, + ) + } + + companion object { + private const val DEFAULT_BOOTSTRAP_SECONDS = 60L + private val LOG = Logger.getLogger(PermissionsExpiryScheduler::class.java) + } +} diff --git a/src/main/kotlin/gg/grounds/persistence/PlayerSessionRepository.kt b/src/main/kotlin/gg/grounds/persistence/PlayerSessionRepository.kt index 7b0a908..0067679 100644 --- a/src/main/kotlin/gg/grounds/persistence/PlayerSessionRepository.kt +++ b/src/main/kotlin/gg/grounds/persistence/PlayerSessionRepository.kt @@ -126,6 +126,26 @@ class PlayerSessionRepository @Inject constructor(private val dataSource: DataSo } } + fun listActivePlayers(): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_ALL_PLAYERS).use { statement -> + statement.executeQuery().use { resultSet -> + val players = linkedSetOf() + while (resultSet.next()) { + val playerId = resultSet.getObject("player_id", UUID::class.java) + players.add(playerId) + } + players + } + } + } + } catch (error: SQLException) { + LOG.errorf(error, "Active player list fetch failed (reason=sql_error)") + emptySet() + } + } + private fun mapSession(resultSet: ResultSet): PlayerSession { val playerId = requireNotNull(resultSet.getObject("player_id", UUID::class.java)) { @@ -171,5 +191,10 @@ class PlayerSessionRepository @Inject constructor(private val dataSource: DataSo DELETE FROM player_sessions WHERE last_seen_at < ? """ + private const val SELECT_ALL_PLAYERS = + """ + SELECT player_id + FROM player_sessions + """ } } diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt index 748def4..e7a8c20 100644 --- a/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt +++ b/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt @@ -8,6 +8,7 @@ import jakarta.inject.Inject import java.sql.ResultSet import java.sql.SQLException import java.sql.Timestamp +import java.time.Instant import javax.sql.DataSource import org.jboss.logging.Logger @@ -155,6 +156,32 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { } } + fun listGroupsWithExpiredPermissions(since: Instant, until: Instant): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_EXPIRED_GROUP_PERMISSIONS).use { statement -> + statement.setTimestamp(1, Timestamp.from(since)) + statement.setTimestamp(2, Timestamp.from(until)) + statement.executeQuery().use { resultSet -> + val groups = linkedSetOf() + while (resultSet.next()) { + resultSet.getString("group_name")?.let { groups.add(it) } + } + groups + } + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to list expired group permissions (since=%s, until=%s)", + since, + until, + ) + emptySet() + } + } + private fun buildGroupFromResult(groupName: String, resultSet: ResultSet): PermissionGroup? { val permissions = linkedSetOf() var sawGroup = false @@ -253,5 +280,13 @@ class GroupRepository @Inject constructor(private val dataSource: DataSource) { WHERE group_name = ? AND permission = ? """ + private const val SELECT_EXPIRED_GROUP_PERMISSIONS = + """ + SELECT DISTINCT group_name + FROM group_permissions + WHERE expires_at IS NOT NULL + AND expires_at > ? + AND expires_at <= ? + """ } } diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt index ad428be..4697964 100644 --- a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt @@ -6,6 +6,7 @@ import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import java.sql.SQLException import java.sql.Timestamp +import java.time.Instant import java.util.UUID import javax.sql.DataSource import org.jboss.logging.Logger @@ -64,6 +65,74 @@ class PlayerGroupRepository @Inject constructor(private val dataSource: DataSour } } + fun listActivePlayersForGroup(groupName: String): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_ACTIVE_PLAYERS_FOR_GROUP).use { statement -> + statement.setString(1, groupName) + statement.executeQuery().use { resultSet -> + val players = linkedSetOf() + while (resultSet.next()) { + val playerId = resultSet.getObject("player_id", UUID::class.java) + players.add(playerId) + } + players + } + } + } + } catch (error: SQLException) { + LOG.errorf(error, "Failed to list active players for group (groupName=%s)", groupName) + emptySet() + } + } + + fun listPlayersWithExpiredGroups(since: Instant, until: Instant): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_EXPIRED_PLAYER_GROUPS).use { statement -> + statement.setTimestamp(1, Timestamp.from(since)) + statement.setTimestamp(2, Timestamp.from(until)) + statement.executeQuery().use { resultSet -> + val players = linkedSetOf() + while (resultSet.next()) { + val playerId = resultSet.getObject("player_id", UUID::class.java) + players.add(playerId) + } + players + } + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to list expired player groups (since=%s, until=%s)", + since, + until, + ) + emptySet() + } + } + + fun listPlayersWithGroups(): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_PLAYERS_WITH_GROUPS).use { statement -> + statement.executeQuery().use { resultSet -> + val players = linkedSetOf() + while (resultSet.next()) { + val playerId = resultSet.getObject("player_id", UUID::class.java) + players.add(playerId) + } + players + } + } + } + } catch (error: SQLException) { + LOG.errorf(error, "List players with groups failed (reason=sql_exception)") + emptySet() + } + } + companion object { private val LOG = Logger.getLogger(PlayerGroupRepository::class.java) @@ -80,5 +149,25 @@ class PlayerGroupRepository @Inject constructor(private val dataSource: DataSour WHERE player_id = ? AND group_name = ? """ + private const val SELECT_ACTIVE_PLAYERS_FOR_GROUP = + """ + SELECT player_id + FROM player_groups + WHERE group_name = ? + AND (expires_at IS NULL OR expires_at > now()) + """ + private const val SELECT_EXPIRED_PLAYER_GROUPS = + """ + SELECT DISTINCT player_id + FROM player_groups + WHERE expires_at IS NOT NULL + AND expires_at > ? + AND expires_at <= ? + """ + private const val SELECT_PLAYERS_WITH_GROUPS = + """ + SELECT DISTINCT player_id + FROM player_groups + """ } } diff --git a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt index a19b387..502828d 100644 --- a/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt @@ -6,6 +6,7 @@ import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import java.sql.SQLException import java.sql.Timestamp +import java.time.Instant import java.util.UUID import javax.sql.DataSource import org.jboss.logging.Logger @@ -64,6 +65,53 @@ class PlayerPermissionRepository @Inject constructor(private val dataSource: Dat } } + fun listPlayersWithExpiredPermissions(since: Instant, until: Instant): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_EXPIRED_PLAYER_PERMISSIONS).use { statement -> + statement.setTimestamp(1, Timestamp.from(since)) + statement.setTimestamp(2, Timestamp.from(until)) + statement.executeQuery().use { resultSet -> + val players = linkedSetOf() + while (resultSet.next()) { + val playerId = resultSet.getObject("player_id", UUID::class.java) + players.add(playerId) + } + players + } + } + } + } catch (error: SQLException) { + LOG.errorf( + error, + "Failed to list expired player permissions (since=%s, until=%s)", + since, + until, + ) + emptySet() + } + } + + fun listPlayersWithPermissions(): Set { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(SELECT_PLAYERS_WITH_PERMISSIONS).use { statement -> + statement.executeQuery().use { resultSet -> + val players = linkedSetOf() + while (resultSet.next()) { + val playerId = resultSet.getObject("player_id", UUID::class.java) + players.add(playerId) + } + players + } + } + } + } catch (error: SQLException) { + LOG.errorf(error, "List players with permissions failed (reason=sql_exception)") + emptySet() + } + } + companion object { private val LOG = Logger.getLogger(PlayerPermissionRepository::class.java) @@ -80,5 +128,18 @@ class PlayerPermissionRepository @Inject constructor(private val dataSource: Dat WHERE player_id = ? AND permission = ? """ + private const val SELECT_EXPIRED_PLAYER_PERMISSIONS = + """ + SELECT DISTINCT player_id + FROM player_permissions + WHERE expires_at IS NOT NULL + AND expires_at > ? + AND expires_at <= ? + """ + private const val SELECT_PLAYERS_WITH_PERMISSIONS = + """ + SELECT DISTINCT player_id + FROM player_permissions + """ } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a35234f..cf0108d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -14,3 +14,4 @@ quarkus.flyway.migrate-at-start=true grounds.player.sessions.ttl=${PLAYER_SESSIONS_TTL:90s} grounds.player.sessions.cleanup-interval=${PLAYER_SESSIONS_CLEANUP_INTERVAL:30s} +permissions.expiry.scan.every=30s diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt index 7837aef..0a2dc82 100644 --- a/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt @@ -1,7 +1,7 @@ package gg.grounds.api.permissions.admin +import gg.grounds.api.permissions.events.PermissionsChangeService import gg.grounds.domain.permissions.ApplyOutcome -import gg.grounds.domain.permissions.GroupPermissionGrant import gg.grounds.grpc.permissions.AddGroupPermissionsRequest import gg.grounds.grpc.permissions.ApplyResult import gg.grounds.grpc.permissions.CreateGroupRequest @@ -14,6 +14,8 @@ import java.time.Instant import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -23,7 +25,8 @@ class GroupAdminHandlerTest { @Test fun createGroupRejectsEmptyName() { val repository = mock() - val handler = GroupAdminHandler(repository) + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) val request = CreateGroupRequest.newBuilder().setGroupName(" ").build() val exception = assertThrows { handler.createGroup(request) } @@ -37,7 +40,8 @@ class GroupAdminHandlerTest { @Test fun createGroupReturnsOutcome() { val repository = mock() - val handler = GroupAdminHandler(repository) + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) whenever(repository.createGroup("admins")).thenReturn(ApplyOutcome.CREATED) val request = CreateGroupRequest.newBuilder().setGroupName("admins").build() @@ -50,7 +54,8 @@ class GroupAdminHandlerTest { @Test fun addGroupPermissionsRejectsEmptyPermissions() { val repository = mock() - val handler = GroupAdminHandler(repository) + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) val request = AddGroupPermissionsRequest.newBuilder().setGroupName("admins").build() val exception = @@ -65,11 +70,13 @@ class GroupAdminHandlerTest { @Test fun addGroupPermissionsReturnsOutcome() { val repository = mock() - val handler = GroupAdminHandler(repository) + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) whenever( - repository.addGroupPermissions( - "admins", - setOf(GroupPermissionGrant("permission.read", null)), + changeService.emitGroupPermissionsDeltaIfChanged( + eq("admins"), + eq("group_permission_add"), + any(), ) ) .thenReturn(ApplyOutcome.UPDATED) @@ -84,14 +91,15 @@ class GroupAdminHandlerTest { val reply = handler.addGroupPermissions(request) assertEquals(ApplyResult.UPDATED, reply.applyResult) - verify(repository) - .addGroupPermissions("admins", setOf(GroupPermissionGrant("permission.read", null))) + verify(changeService) + .emitGroupPermissionsDeltaIfChanged(eq("admins"), eq("group_permission_add"), any()) } @Test fun addGroupPermissionsRejectsPastExpiry() { val repository = mock() - val handler = GroupAdminHandler(repository) + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) val expiresAt = Instant.now().minusSeconds(10) val request = AddGroupPermissionsRequest.newBuilder() @@ -121,8 +129,15 @@ class GroupAdminHandlerTest { @Test fun removeGroupPermissionsReturnsOutcome() { val repository = mock() - val handler = GroupAdminHandler(repository) - whenever(repository.removeGroupPermissions("admins", setOf("permission.read"))) + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) + whenever( + changeService.emitGroupPermissionsDeltaIfChanged( + eq("admins"), + eq("group_permission_remove"), + any(), + ) + ) .thenReturn(ApplyOutcome.UPDATED) val request = RemoveGroupPermissionsRequest.newBuilder() @@ -133,6 +148,7 @@ class GroupAdminHandlerTest { val reply = handler.removeGroupPermissions(request) assertEquals(ApplyResult.UPDATED, reply.applyResult) - verify(repository).removeGroupPermissions("admins", setOf("permission.read")) + verify(changeService) + .emitGroupPermissionsDeltaIfChanged(eq("admins"), eq("group_permission_remove"), any()) } } diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt index a005853..d8b45d0 100644 --- a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt @@ -1,7 +1,7 @@ package gg.grounds.api.permissions.admin +import gg.grounds.api.permissions.events.PermissionsChangeService import gg.grounds.domain.permissions.ApplyOutcome -import gg.grounds.domain.permissions.PlayerGroupMembership import gg.grounds.grpc.permissions.AddPlayerGroupsRequest import gg.grounds.grpc.permissions.ApplyResult import gg.grounds.grpc.permissions.PlayerGroupMembership as PlayerGroupMembershipProto @@ -14,6 +14,8 @@ import java.util.UUID import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -23,7 +25,8 @@ class PlayerGroupsAdminHandlerTest { @Test fun addPlayerGroupsRejectsInvalidPlayerId() { val repository = mock() - val handler = PlayerGroupsAdminHandler(repository) + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) val request = AddPlayerGroupsRequest.newBuilder() .setPlayerId("not-a-uuid") @@ -43,9 +46,12 @@ class PlayerGroupsAdminHandlerTest { @Test fun addPlayerGroupsReturnsOutcome() { val repository = mock() - val handler = PlayerGroupsAdminHandler(repository) + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) val playerId = UUID.randomUUID() - whenever(repository.addPlayerGroups(playerId, setOf(PlayerGroupMembership("vip", null)))) + whenever( + changeService.emitPlayerDeltaIfChanged(eq(playerId), eq("player_group_add"), any()) + ) .thenReturn(ApplyOutcome.UPDATED) val request = AddPlayerGroupsRequest.newBuilder() @@ -58,17 +64,18 @@ class PlayerGroupsAdminHandlerTest { val reply = handler.addPlayerGroups(request) assertEquals(ApplyResult.UPDATED, reply.applyResult) - verify(repository).addPlayerGroups(playerId, setOf(PlayerGroupMembership("vip", null))) + verify(changeService).emitPlayerDeltaIfChanged(eq(playerId), eq("player_group_add"), any()) } @Test fun addPlayerGroupsAcceptsExpiry() { val repository = mock() - val handler = PlayerGroupsAdminHandler(repository) + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) val playerId = UUID.randomUUID() val expiresAt = Instant.now().plusSeconds(60) whenever( - repository.addPlayerGroups(playerId, setOf(PlayerGroupMembership("vip", expiresAt))) + changeService.emitPlayerDeltaIfChanged(eq(playerId), eq("player_group_add"), any()) ) .thenReturn(ApplyOutcome.UPDATED) val request = @@ -90,13 +97,14 @@ class PlayerGroupsAdminHandlerTest { val reply = handler.addPlayerGroups(request) assertEquals(ApplyResult.UPDATED, reply.applyResult) - verify(repository).addPlayerGroups(playerId, setOf(PlayerGroupMembership("vip", expiresAt))) + verify(changeService).emitPlayerDeltaIfChanged(eq(playerId), eq("player_group_add"), any()) } @Test fun addPlayerGroupsRejectsPastExpiry() { val repository = mock() - val handler = PlayerGroupsAdminHandler(repository) + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) val playerId = UUID.randomUUID() val expiresAt = Instant.now().minusSeconds(10) val request = @@ -126,7 +134,8 @@ class PlayerGroupsAdminHandlerTest { @Test fun removePlayerGroupsRejectsEmptyGroupNames() { val repository = mock() - val handler = PlayerGroupsAdminHandler(repository) + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) val request = RemovePlayerGroupsRequest.newBuilder().setPlayerId(UUID.randomUUID().toString()).build() diff --git a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt index c786c9d..dbc1f45 100644 --- a/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt @@ -1,7 +1,7 @@ package gg.grounds.api.permissions.admin +import gg.grounds.api.permissions.events.PermissionsChangeService import gg.grounds.domain.permissions.ApplyOutcome -import gg.grounds.domain.permissions.PlayerPermissionGrant import gg.grounds.grpc.permissions.AddPlayerPermissionsRequest import gg.grounds.grpc.permissions.ApplyResult import gg.grounds.grpc.permissions.PermissionGrant as PermissionGrantProto @@ -14,6 +14,8 @@ import java.util.UUID import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -23,7 +25,8 @@ class PlayerPermissionsAdminHandlerTest { @Test fun addPlayerPermissionsRejectsInvalidPlayerId() { val repository = mock() - val handler = PlayerPermissionsAdminHandler(repository) + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) val request = AddPlayerPermissionsRequest.newBuilder() .setPlayerId("not-a-uuid") @@ -44,12 +47,14 @@ class PlayerPermissionsAdminHandlerTest { @Test fun addPlayerPermissionsReturnsOutcome() { val repository = mock() - val handler = PlayerPermissionsAdminHandler(repository) + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) val playerId = UUID.randomUUID() whenever( - repository.addPlayerPermissions( - playerId, - setOf(PlayerPermissionGrant("permission.read", null)), + changeService.emitPlayerDeltaIfChanged( + eq(playerId), + eq("player_permission_add"), + any(), ) ) .thenReturn(ApplyOutcome.UPDATED) @@ -64,20 +69,22 @@ class PlayerPermissionsAdminHandlerTest { val reply = handler.addPlayerPermissions(request) assertEquals(ApplyResult.UPDATED, reply.applyResult) - verify(repository) - .addPlayerPermissions(playerId, setOf(PlayerPermissionGrant("permission.read", null))) + verify(changeService) + .emitPlayerDeltaIfChanged(eq(playerId), eq("player_permission_add"), any()) } @Test fun addPlayerPermissionsAcceptsExpiry() { val repository = mock() - val handler = PlayerPermissionsAdminHandler(repository) + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) val playerId = UUID.randomUUID() val expiresAt = Instant.now().plusSeconds(60) whenever( - repository.addPlayerPermissions( - playerId, - setOf(PlayerPermissionGrant("permission.read", expiresAt)), + changeService.emitPlayerDeltaIfChanged( + eq(playerId), + eq("player_permission_add"), + any(), ) ) .thenReturn(ApplyOutcome.UPDATED) @@ -100,17 +107,15 @@ class PlayerPermissionsAdminHandlerTest { val reply = handler.addPlayerPermissions(request) assertEquals(ApplyResult.UPDATED, reply.applyResult) - verify(repository) - .addPlayerPermissions( - playerId, - setOf(PlayerPermissionGrant("permission.read", expiresAt)), - ) + verify(changeService) + .emitPlayerDeltaIfChanged(eq(playerId), eq("player_permission_add"), any()) } @Test fun addPlayerPermissionsRejectsPastExpiry() { val repository = mock() - val handler = PlayerPermissionsAdminHandler(repository) + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) val playerId = UUID.randomUUID() val expiresAt = Instant.now().minusSeconds(5) val request = @@ -141,7 +146,8 @@ class PlayerPermissionsAdminHandlerTest { @Test fun removePlayerPermissionsRejectsEmptyPermissions() { val repository = mock() - val handler = PlayerPermissionsAdminHandler(repository) + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) val request = RemovePlayerPermissionsRequest.newBuilder() .setPlayerId(UUID.randomUUID().toString()) diff --git a/src/test/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcServiceTest.kt b/src/test/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcServiceTest.kt new file mode 100644 index 0000000..8a7bfa8 --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcServiceTest.kt @@ -0,0 +1,52 @@ +package gg.grounds.api.permissions.events + +import gg.grounds.grpc.permissions.SubscribePermissionsChangesRequest +import gg.grounds.persistence.PlayerSessionRepository +import java.util.UUID +import org.junit.jupiter.api.Test +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class PermissionsEventsGrpcServiceTest { + @Test + fun subscribeSkipsRefreshWhenNoLastEventId() { + val publisher = PermissionsEventsPublisher() + val emitter = mock() + val sessions = mock() + val service = PermissionsEventsGrpcService(publisher, emitter, sessions) + val request = + SubscribePermissionsChangesRequest.newBuilder() + .setServerId("server-1") + .setLastEventId("") + .build() + + service.subscribePermissionsChanges(request) + + verifyNoInteractions(emitter, sessions) + } + + @Test + fun subscribeEmitsRefreshWhenLastEventIdPresent() { + val publisher = PermissionsEventsPublisher() + val emitter = mock() + val sessions = mock() + val playerA = UUID.randomUUID() + val playerB = UUID.randomUUID() + whenever(sessions.listActivePlayers()).thenReturn(setOf(playerA, playerB)) + val service = PermissionsEventsGrpcService(publisher, emitter, sessions) + val request = + SubscribePermissionsChangesRequest.newBuilder() + .setServerId("server-2") + .setLastEventId("event-1") + .build() + + service.subscribePermissionsChanges(request) + + verify(sessions).listActivePlayers() + verify(emitter).emitRefresh(eq(playerA), eq("subscription_resume")) + verify(emitter).emitRefresh(eq(playerB), eq("subscription_resume")) + } +} From f1b8eff94b9c7faa1efde1df171c0aa40cb9cac6 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Sun, 8 Mar 2026 21:05:35 +0100 Subject: [PATCH 5/5] fix: implement copilots suggestion # Conflicts: # src/main/resources/application.properties --- .../admin/PlayerGroupsAdminHandler.kt | 2 +- .../events/PermissionsChangeService.kt | 31 +---------- .../events/PermissionsEventsGrpcService.kt | 2 + src/main/resources/application.properties | 2 +- .../events/PermissionsChangeServiceTest.kt | 54 +++++++++++++++++++ 5 files changed, 60 insertions(+), 31 deletions(-) create mode 100644 src/test/kotlin/gg/grounds/api/permissions/events/PermissionsChangeServiceTest.kt diff --git a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt index cd0e2ca..c9a156f 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt @@ -44,9 +44,9 @@ constructor( throw Status.INVALID_ARGUMENT.withDescription("group_memberships must be provided") .asRuntimeException() } + val now = Instant.now() if ( groupMemberships.any { membership -> - val now = Instant.now() PermissionsRequestParser.hasPastExpiry(membership.expiresAt, now) } ) { diff --git a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt index 0878bca..df4a90c 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt @@ -47,36 +47,9 @@ constructor( change: () -> ApplyOutcome, ): ApplyOutcome { val playerIds = playerGroupRepository.listActivePlayersForGroup(groupName) - val before = - playerIds.associateWith { playerId -> - permissionsQueryRepository.getPlayerPermissions( - playerId, - includeEffectivePermissions = true, - includeDirectPermissions = true, - includeGroups = true, - ) - } val outcome = change() - if ( - outcome != ApplyOutcome.NO_CHANGE && - outcome != ApplyOutcome.ERROR && - playerIds.isNotEmpty() - ) { - playerIds.forEach { playerId -> - val after = - permissionsQueryRepository.getPlayerPermissions( - playerId, - includeEffectivePermissions = true, - includeDirectPermissions = true, - includeGroups = true, - ) - permissionsChangeEmitter.emitEffectiveDelta( - playerId, - reason, - before[playerId], - after, - ) - } + if (outcome != ApplyOutcome.NO_CHANGE && outcome != ApplyOutcome.ERROR) { + playerIds.forEach { playerId -> permissionsChangeEmitter.emitRefresh(playerId, reason) } } return outcome } diff --git a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt index 712098e..d9ae9a5 100644 --- a/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt @@ -5,11 +5,13 @@ import gg.grounds.grpc.permissions.PermissionsEventsService import gg.grounds.grpc.permissions.SubscribePermissionsChangesRequest import gg.grounds.persistence.PlayerSessionRepository import io.quarkus.grpc.GrpcService +import io.smallrye.common.annotation.Blocking import io.smallrye.mutiny.Multi import jakarta.inject.Inject import org.jboss.logging.Logger @GrpcService +@Blocking class PermissionsEventsGrpcService @Inject constructor( diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cf0108d..2df8abb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,7 @@ quarkus.grpc.server.use-separate-server=false quarkus.http.host=0.0.0.0 quarkus.http.port=9000 -quarkus.log.level=${QUARKUS_LOG_LEVEL:DEBUG} +quarkus.log.level=${QUARKUS_LOG_LEVEL:INFO} quarkus.generate-code.grpc.scan-for-proto=gg.grounds:library-grpc-contracts-player,gg.grounds:library-grpc-contracts-permission diff --git a/src/test/kotlin/gg/grounds/api/permissions/events/PermissionsChangeServiceTest.kt b/src/test/kotlin/gg/grounds/api/permissions/events/PermissionsChangeServiceTest.kt new file mode 100644 index 0000000..1c0338d --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/events/PermissionsChangeServiceTest.kt @@ -0,0 +1,54 @@ +package gg.grounds.api.permissions.events + +import gg.grounds.domain.permissions.ApplyOutcome +import gg.grounds.persistence.permissions.PermissionsQueryRepository +import gg.grounds.persistence.permissions.PlayerGroupRepository +import java.util.UUID +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class PermissionsChangeServiceTest { + @Test + fun emitGroupPermissionsDeltaIfChangedEmitsRefreshForEachPlayer() { + val groupRepository = mock() + val queryRepository = mock() + val emitter = mock() + val changeService = PermissionsChangeService(groupRepository, queryRepository, emitter) + val playerId = UUID.randomUUID() + whenever(groupRepository.listActivePlayersForGroup("admins")).thenReturn(setOf(playerId)) + + val outcome = + changeService.emitGroupPermissionsDeltaIfChanged("admins", "group_permission_add") { + ApplyOutcome.UPDATED + } + + verify(emitter).emitRefresh(playerId, "group_permission_add") + verifyNoInteractions(queryRepository) + assertEquals(ApplyOutcome.UPDATED, outcome) + } + + @Test + fun emitGroupPermissionsDeltaIfChangedSkipsRefreshOnNoChange() { + val groupRepository = mock() + val queryRepository = mock() + val emitter = mock() + val changeService = PermissionsChangeService(groupRepository, queryRepository, emitter) + val playerId = UUID.randomUUID() + whenever(groupRepository.listActivePlayersForGroup("admins")).thenReturn(setOf(playerId)) + + val outcome = + changeService.emitGroupPermissionsDeltaIfChanged("admins", "group_permission_add") { + ApplyOutcome.NO_CHANGE + } + + verify(emitter, never()).emitRefresh(any(), any()) + verifyNoInteractions(queryRepository) + assertEquals(ApplyOutcome.NO_CHANGE, outcome) + } +}