diff --git a/build.gradle.kts b/build.gradle.kts index c1b75cb..0227ea0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,11 +18,13 @@ 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") implementation("io.quarkus:quarkus-scheduler") implementation("gg.grounds:library-grpc-contracts-player:0.2.0") + implementation("gg.grounds:library-grpc-contracts-permission:feat-perm-protos-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..f6504b1 --- /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.permissions.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..bde5509 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/PermissionsProtoMapper.kt @@ -0,0 +1,52 @@ +package gg.grounds.api.permissions + +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()) + .addAllGroupMemberships(data.groupMemberships.map { toProto(it) }) + .addAllDirectPermissionGrants(data.directPermissionGrants.map { toProto(it) }) + .addAllEffectivePermissions(data.effectivePermissions) + .build() + } + + fun toProto(group: PermissionGroup): Group { + return Group.newBuilder() + .setGroupName(group.name) + .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 new file mode 100644 index 0000000..b0208e5 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/PermissionsRequestParser.kt @@ -0,0 +1,73 @@ +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 { + 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() + } + + 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 new file mode 100644 index 0000000..a681524 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandler.kt @@ -0,0 +1,221 @@ +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 +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 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 +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()) { + LOG.warnf("Create group request rejected (reason=empty_group_name)") + throw Status.INVALID_ARGUMENT.withDescription("group_name must be provided") + .asRuntimeException() + } + + val outcome = groupRepository.createGroup(groupName) + 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() + } + + fun deleteGroup(request: DeleteGroupRequest): DeleteGroupReply { + val groupName = request.groupName?.trim().orEmpty() + if (groupName.isEmpty()) { + LOG.warnf("Delete group request rejected (reason=empty_group_name)") + throw Status.INVALID_ARGUMENT.withDescription("group_name must be provided") + .asRuntimeException() + } + + val outcome = groupRepository.deleteGroup(groupName) + 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() + } + + fun getGroup(request: GetGroupRequest): GetGroupReply { + val groupName = request.groupName?.trim().orEmpty() + if (groupName.isEmpty()) { + LOG.warnf("Get group request rejected (reason=empty_group_name)") + throw Status.INVALID_ARGUMENT.withDescription("group_name must be provided") + .asRuntimeException() + } + + val group = + 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, + ) + 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 = + 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, + 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 permissionGrants = + PermissionsRequestParser.sanitizeGroupPermissionGrants(request.permissionGrantsList) + if (groupName.isEmpty() || permissionGrants.isEmpty()) { + LOG.warnf( + "Add group permissions request rejected (groupName=%s, permissionGrantsCount=%d, reason=invalid_input)", + groupName, + permissionGrants.size, + ) + throw Status.INVALID_ARGUMENT.withDescription( + "group_name and permission_grants must be provided" + ) + .asRuntimeException() + } + 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, + ) + throw Status.INVALID_ARGUMENT.withDescription("expires_at must be in the future") + .asRuntimeException() + } + + 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() + } + LOG.infof( + "Add group permissions completed (groupName=%s, permissionGrantsCount=%d, outcome=%s)", + groupName, + permissionGrants.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, + ) + throw Status.INVALID_ARGUMENT.withDescription( + "group_name and permissions must be provided" + ) + .asRuntimeException() + } + + 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() + } + 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..c9a156f --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandler.kt @@ -0,0 +1,124 @@ +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 +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 +class PlayerGroupsAdminHandler +@Inject +constructor( + private val playerGroupRepository: PlayerGroupRepository, + private val permissionsChangeService: PermissionsChangeService, +) { + 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, + ) + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() + } + val groupMemberships = + PermissionsRequestParser.sanitizeGroupMemberships(request.groupMembershipsList) + if (groupMemberships.isEmpty()) { + LOG.warnf( + "Add player groups request rejected (playerId=%s, reason=empty_group_memberships)", + playerId, + ) + throw Status.INVALID_ARGUMENT.withDescription("group_memberships must be provided") + .asRuntimeException() + } + val now = Instant.now() + if ( + groupMemberships.any { membership -> + PermissionsRequestParser.hasPastExpiry(membership.expiresAt, now) + } + ) { + LOG.warnf( + "Add player groups request rejected (playerId=%s, reason=expired_membership)", + playerId, + ) + throw Status.INVALID_ARGUMENT.withDescription("expires_at must be in the future") + .asRuntimeException() + } + + 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() + } + LOG.infof( + "Add player groups completed (playerId=%s, groupCount=%d, outcome=%s)", + playerId, + groupMemberships.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, + ) + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() + } + val groupNames = PermissionsRequestParser.sanitize(request.groupNamesList) + if (groupNames.isEmpty()) { + LOG.warnf( + "Remove player groups request rejected (playerId=%s, reason=empty_group_names)", + playerId, + ) + throw Status.INVALID_ARGUMENT.withDescription("group_names must be provided") + .asRuntimeException() + } + + 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() + } + 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..2509118 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandler.kt @@ -0,0 +1,129 @@ +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 +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 +class PlayerPermissionsAdminHandler +@Inject +constructor( + private val playerPermissionRepository: PlayerPermissionRepository, + private val permissionsChangeService: PermissionsChangeService, +) { + 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, + ) + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() + } + val permissionGrants = + PermissionsRequestParser.sanitizePermissionGrants(request.permissionGrantsList) + if (permissionGrants.isEmpty()) { + LOG.warnf( + "Add player permissions request rejected (playerId=%s, reason=empty_permission_grants)", + playerId, + ) + throw Status.INVALID_ARGUMENT.withDescription("permission_grants must be provided") + .asRuntimeException() + } + 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, + ) + throw Status.INVALID_ARGUMENT.withDescription("expires_at must be in the future") + .asRuntimeException() + } + + 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() + } + LOG.infof( + "Add player permissions completed (playerId=%s, permissionsCount=%d, outcome=%s)", + playerId, + permissionGrants.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, + ) + throw Status.INVALID_ARGUMENT.withDescription("player_id must be a UUID") + .asRuntimeException() + } + val permissions = PermissionsRequestParser.sanitize(request.permissionsList) + if (permissions.isEmpty()) { + LOG.warnf( + "Remove player permissions request rejected (playerId=%s, reason=empty_permissions)", + playerId, + ) + throw Status.INVALID_ARGUMENT.withDescription("permissions must be provided") + .asRuntimeException() + } + + 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() + } + 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/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..df4a90c --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsChangeService.kt @@ -0,0 +1,56 @@ +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 outcome = change() + 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 new file mode 100644 index 0000000..d9ae9a5 --- /dev/null +++ b/src/main/kotlin/gg/grounds/api/permissions/events/PermissionsEventsGrpcService.kt @@ -0,0 +1,76 @@ +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.common.annotation.Blocking +import io.smallrye.mutiny.Multi +import jakarta.inject.Inject +import org.jboss.logging.Logger + +@GrpcService +@Blocking +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/domain/permissions/ApplyOutcome.kt b/src/main/kotlin/gg/grounds/domain/permissions/ApplyOutcome.kt new file mode 100644 index 0000000..c4dbc49 --- /dev/null +++ b/src/main/kotlin/gg/grounds/domain/permissions/ApplyOutcome.kt @@ -0,0 +1,9 @@ +package gg.grounds.domain.permissions + +enum class ApplyOutcome { + NO_CHANGE, + CREATED, + UPDATED, + DELETED, + ERROR, +} 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/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/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..e7a8c20 --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/GroupRepository.kt @@ -0,0 +1,292 @@ +package gg.grounds.persistence.permissions + +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 java.time.Instant +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, + ) + throw PermissionsRepositoryException("Failed to load permission group", error) + } + } + + 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, + ) + throw PermissionsRepositoryException("Failed to list permission groups", error) + } + } + + fun addGroupPermissions( + groupName: String, + permissionGrants: Collection, + ): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(INSERT_GROUP_PERMISSION).use { statement -> + permissionGrants.forEach { grant -> + statement.setString(1, groupName) + statement.setString(2, grant.permission) + statement.setTimestamp(3, grant.expiresAt?.let { Timestamp.from(it) }) + 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, permissionGrantsCount=%d)", + groupName, + permissionGrants.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 + } + } + + 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 + while (resultSet.next()) { + sawGroup = true + resultSet.getString("permission")?.let { permission -> + permissions.add( + GroupPermissionGrant( + permission, + resultSet.getTimestamp("expires_at")?.toInstant(), + ) + ) + } + } + 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 { permission -> + currentPermissions.add( + GroupPermissionGrant( + permission, + resultSet.getTimestamp("expires_at")?.toInstant(), + ) + ) + } + } + 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, gp.expires_at + 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, 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, expires_at) + VALUES (?, ?, ?) + ON CONFLICT (group_name, permission) + DO UPDATE SET expires_at = EXCLUDED.expires_at + """ + private const val DELETE_GROUP_PERMISSION = + """ + DELETE FROM group_permissions + 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/PermissionsQueryRepository.kt b/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt new file mode 100644 index 0000000..8f04f9e --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PermissionsQueryRepository.kt @@ -0,0 +1,185 @@ +package gg.grounds.persistence.permissions + +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 +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 { groupName -> + names.add( + PlayerGroupMembership( + groupName, + resultSet.getTimestamp("expires_at")?.toInstant(), + ) + ) + } + } + 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 { permission -> + permissions.add( + PlayerPermissionGrant( + permission, + resultSet.getTimestamp("expires_at")?.toInstant(), + ) + ) + } + } + permissions + } + } + } else { + emptySet() + } + + val groupMemberships = if (includeGroups) loadedGroupNames else emptySet() + val directPermissionGrants = + if (includeDirectPermissions) loadedDirectPermissions else emptySet() + + val effectivePermissions = + if (includeEffectivePermissions) { + val permissions = linkedSetOf() + permissions.addAll( + loadedDirectPermissions.map { grant -> grant.permission } + ) + 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, + groupMemberships, + directPermissionGrants, + 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, 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, 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 = + """ + SELECT gp.permission + 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 = + """ + SELECT 1 + 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 new file mode 100644 index 0000000..4697964 --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerGroupRepository.kt @@ -0,0 +1,173 @@ +package gg.grounds.persistence.permissions + +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.time.Instant +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, + groupMemberships: Collection, + ): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(INSERT_PLAYER_GROUP).use { statement -> + groupMemberships.forEach { membership -> + statement.setObject(1, playerId) + statement.setString(2, membership.groupName) + statement.setTimestamp(3, membership.expiresAt?.let { Timestamp.from(it) }) + 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, + groupMemberships.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 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) + + private const val INSERT_PLAYER_GROUP = + """ + 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 = + """ + DELETE FROM player_groups + 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 new file mode 100644 index 0000000..502828d --- /dev/null +++ b/src/main/kotlin/gg/grounds/persistence/permissions/PlayerPermissionRepository.kt @@ -0,0 +1,145 @@ +package gg.grounds.persistence.permissions + +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.time.Instant +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, + permissionGrants: Collection, + ): ApplyOutcome { + return try { + dataSource.connection.use { connection -> + connection.prepareStatement(INSERT_PLAYER_PERMISSION).use { statement -> + permissionGrants.forEach { grant -> + statement.setObject(1, playerId) + statement.setString(2, grant.permission) + statement.setTimestamp(3, grant.expiresAt?.let { Timestamp.from(it) }) + 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, + permissionGrants.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 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) + + private const val INSERT_PLAYER_PERMISSION = + """ + 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 = + """ + DELETE FROM player_permissions + 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 1306eeb..2df8abb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,9 +1,9 @@ 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 +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} @@ -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/main/resources/db/migration/V2__create_permissions.sql b/src/main/resources/db/migration/V2__create_permissions.sql new file mode 100644 index 0000000..3c6f301 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_permissions.sql @@ -0,0 +1,26 @@ +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, + 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, + expires_at TIMESTAMPTZ, + 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 new file mode 100644 index 0000000..32d6af5 --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/PermissionsPluginGrpcServiceTest.kt @@ -0,0 +1,168 @@ +package gg.grounds.api.permissions + +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 +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 expiresAt = Instant.ofEpochSecond(1_700_000_000) + val data = + PlayerPermissionsData( + playerId, + setOf(PlayerGroupMembership("group-a", expiresAt)), + setOf(PlayerPermissionGrant("permission.read", expiresAt)), + 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.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(), + ) + 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..0a2dc82 --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/GroupAdminHandlerTest.kt @@ -0,0 +1,154 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.api.permissions.events.PermissionsChangeService +import gg.grounds.domain.permissions.ApplyOutcome +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.any +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 GroupAdminHandlerTest { + @Test + fun createGroupRejectsEmptyName() { + val repository = mock() + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) + val request = CreateGroupRequest.newBuilder().setGroupName(" ").build() + + val exception = assertThrows { handler.createGroup(request) } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("group_name must be provided", status.description) + verifyNoInteractions(repository) + } + + @Test + fun createGroupReturnsOutcome() { + val repository = mock() + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) + 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 changeService = mock() + val handler = GroupAdminHandler(repository, changeService) + 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 changeService = mock() + val handler = GroupAdminHandler(repository, changeService) + whenever( + changeService.emitGroupPermissionsDeltaIfChanged( + eq("admins"), + eq("group_permission_add"), + any(), + ) + ) + .thenReturn(ApplyOutcome.UPDATED) + val request = + AddGroupPermissionsRequest.newBuilder() + .setGroupName("admins") + .addPermissionGrants( + PermissionGrantProto.newBuilder().setPermission("permission.read").build() + ) + .build() + + val reply = handler.addGroupPermissions(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(changeService) + .emitGroupPermissionsDeltaIfChanged(eq("admins"), eq("group_permission_add"), any()) + } + + @Test + fun addGroupPermissionsRejectsPastExpiry() { + val repository = mock() + val changeService = mock() + val handler = GroupAdminHandler(repository, changeService) + 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) + } + + @Test + fun removeGroupPermissionsReturnsOutcome() { + val repository = mock() + 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() + .setGroupName("admins") + .addPermissions("permission.read") + .build() + + val reply = handler.removeGroupPermissions(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + 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 new file mode 100644 index 0000000..d8b45d0 --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerGroupsAdminHandlerTest.kt @@ -0,0 +1,149 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.api.permissions.events.PermissionsChangeService +import gg.grounds.domain.permissions.ApplyOutcome +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.any +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 PlayerGroupsAdminHandlerTest { + @Test + fun addPlayerGroupsRejectsInvalidPlayerId() { + val repository = mock() + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) + val request = + AddPlayerGroupsRequest.newBuilder() + .setPlayerId("not-a-uuid") + .addGroupMemberships( + PlayerGroupMembershipProto.newBuilder().setGroupName("vip").build() + ) + .build() + + val exception = assertThrows { handler.addPlayerGroups(request) } + + 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 addPlayerGroupsReturnsOutcome() { + val repository = mock() + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) + val playerId = UUID.randomUUID() + whenever( + changeService.emitPlayerDeltaIfChanged(eq(playerId), eq("player_group_add"), any()) + ) + .thenReturn(ApplyOutcome.UPDATED) + val request = + AddPlayerGroupsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addGroupMemberships( + PlayerGroupMembershipProto.newBuilder().setGroupName("vip").build() + ) + .build() + + val reply = handler.addPlayerGroups(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(changeService).emitPlayerDeltaIfChanged(eq(playerId), eq("player_group_add"), any()) + } + + @Test + fun addPlayerGroupsAcceptsExpiry() { + val repository = mock() + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) + val playerId = UUID.randomUUID() + val expiresAt = Instant.now().plusSeconds(60) + whenever( + changeService.emitPlayerDeltaIfChanged(eq(playerId), eq("player_group_add"), any()) + ) + .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(changeService).emitPlayerDeltaIfChanged(eq(playerId), eq("player_group_add"), any()) + } + + @Test + fun addPlayerGroupsRejectsPastExpiry() { + val repository = mock() + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) + 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 + fun removePlayerGroupsRejectsEmptyGroupNames() { + val repository = mock() + val changeService = mock() + val handler = PlayerGroupsAdminHandler(repository, changeService) + val request = + RemovePlayerGroupsRequest.newBuilder().setPlayerId(UUID.randomUUID().toString()).build() + + val exception = assertThrows { handler.removePlayerGroups(request) } + + 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 new file mode 100644 index 0000000..dbc1f45 --- /dev/null +++ b/src/test/kotlin/gg/grounds/api/permissions/admin/PlayerPermissionsAdminHandlerTest.kt @@ -0,0 +1,164 @@ +package gg.grounds.api.permissions.admin + +import gg.grounds.api.permissions.events.PermissionsChangeService +import gg.grounds.domain.permissions.ApplyOutcome +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.any +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 PlayerPermissionsAdminHandlerTest { + @Test + fun addPlayerPermissionsRejectsInvalidPlayerId() { + val repository = mock() + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) + val request = + AddPlayerPermissionsRequest.newBuilder() + .setPlayerId("not-a-uuid") + .addPermissionGrants( + PermissionGrantProto.newBuilder().setPermission("permission.read").build() + ) + .build() + + val exception = + assertThrows { handler.addPlayerPermissions(request) } + + 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 addPlayerPermissionsReturnsOutcome() { + val repository = mock() + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) + val playerId = UUID.randomUUID() + whenever( + changeService.emitPlayerDeltaIfChanged( + eq(playerId), + eq("player_permission_add"), + any(), + ) + ) + .thenReturn(ApplyOutcome.UPDATED) + val request = + AddPlayerPermissionsRequest.newBuilder() + .setPlayerId(playerId.toString()) + .addPermissionGrants( + PermissionGrantProto.newBuilder().setPermission("permission.read").build() + ) + .build() + + val reply = handler.addPlayerPermissions(request) + + assertEquals(ApplyResult.UPDATED, reply.applyResult) + verify(changeService) + .emitPlayerDeltaIfChanged(eq(playerId), eq("player_permission_add"), any()) + } + + @Test + fun addPlayerPermissionsAcceptsExpiry() { + val repository = mock() + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) + val playerId = UUID.randomUUID() + val expiresAt = Instant.now().plusSeconds(60) + whenever( + changeService.emitPlayerDeltaIfChanged( + eq(playerId), + eq("player_permission_add"), + any(), + ) + ) + .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(changeService) + .emitPlayerDeltaIfChanged(eq(playerId), eq("player_permission_add"), any()) + } + + @Test + fun addPlayerPermissionsRejectsPastExpiry() { + val repository = mock() + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) + 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 + fun removePlayerPermissionsRejectsEmptyPermissions() { + val repository = mock() + val changeService = mock() + val handler = PlayerPermissionsAdminHandler(repository, changeService) + val request = + RemovePlayerPermissionsRequest.newBuilder() + .setPlayerId(UUID.randomUUID().toString()) + .build() + + val exception = + assertThrows { handler.removePlayerPermissions(request) } + + val status = Status.fromThrowable(exception) + assertEquals(Status.Code.INVALID_ARGUMENT, status.code) + assertEquals("permissions must be provided", status.description) + verifyNoInteractions(repository) + } +} 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) + } +} 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")) + } +}