From 5464b299ddae193e9eed0590642d1fabb23ae333 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Tue, 14 Jul 2026 18:48:32 +0200 Subject: [PATCH] fix(snapshot): remove caller identity groups --- .../grounds/permissions/PermissionSnapshot.kt | 15 +++ common/src/main/proto/permissions.proto | 19 +++- .../PermissionProtoContractTest.kt | 37 +++++++ .../minestom/PermissionSnapshotClient.kt | 28 +++++ .../minestom/PermissionSnapshotClientTest.kt | 102 ++++++++++++++++++ .../velocity/PermissionSnapshotClient.kt | 28 +++++ .../permissions/velocity/SnapshotDiskCache.kt | 4 + .../velocity/PermissionSnapshotClientTest.kt | 102 ++++++++++++++++++ .../velocity/SnapshotDiskCacheTest.kt | 9 ++ 9 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 common/src/test/kotlin/gg/grounds/permissions/PermissionProtoContractTest.kt create mode 100644 minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionSnapshotClientTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionSnapshotClientTest.kt diff --git a/common/src/main/kotlin/gg/grounds/permissions/PermissionSnapshot.kt b/common/src/main/kotlin/gg/grounds/permissions/PermissionSnapshot.kt index fe6425c..b2a04da 100644 --- a/common/src/main/kotlin/gg/grounds/permissions/PermissionSnapshot.kt +++ b/common/src/main/kotlin/gg/grounds/permissions/PermissionSnapshot.kt @@ -37,12 +37,27 @@ enum class PermissionGrantSource { PLAYER, } +enum class PermissionGrantOriginKind { + DEFAULT_ROLE, + DIRECT_ROLE, + GROUP_MAPPING, + DIRECT_PERMISSION, +} + +data class PermissionGrantOrigin( + val kind: PermissionGrantOriginKind, + val roleKey: String? = null, + val mappingId: String? = null, + val inheritedPath: List = emptyList(), +) + data class PermissionGrant( val effect: PermissionEffect, val pattern: String, val scope: PermissionScope, val source: PermissionGrantSource, val expiresAt: Instant? = null, + val origin: PermissionGrantOrigin? = null, ) data class RoleMetadata( diff --git a/common/src/main/proto/permissions.proto b/common/src/main/proto/permissions.proto index 7e5bafb..f3b6bac 100644 --- a/common/src/main/proto/permissions.proto +++ b/common/src/main/proto/permissions.proto @@ -18,7 +18,8 @@ service PermissionCatalogService { message GetPlayerSnapshotRequest { string player_id = 1; - repeated string keycloak_groups = 2; + reserved 2; + reserved "keycloak_groups"; string server_type = 3; string server_id = 4; } @@ -51,6 +52,14 @@ message PermissionGrant { PermissionScope scope = 3; PermissionGrantSource source = 4; google.protobuf.Timestamp expires_at = 5; + PermissionGrantOrigin origin = 6; +} + +message PermissionGrantOrigin { + PermissionGrantOriginKind kind = 1; + string role_key = 2; + string mapping_id = 3; + repeated string inherited_path = 4; } message PermissionScope { @@ -104,3 +113,11 @@ enum PermissionGrantSource { PERMISSION_GRANT_SOURCE_ROLE = 1; PERMISSION_GRANT_SOURCE_PLAYER = 2; } + +enum PermissionGrantOriginKind { + PERMISSION_GRANT_ORIGIN_KIND_UNSPECIFIED = 0; + PERMISSION_GRANT_ORIGIN_KIND_DEFAULT_ROLE = 1; + PERMISSION_GRANT_ORIGIN_KIND_DIRECT_ROLE = 2; + PERMISSION_GRANT_ORIGIN_KIND_GROUP_MAPPING = 3; + PERMISSION_GRANT_ORIGIN_KIND_DIRECT_PERMISSION = 4; +} diff --git a/common/src/test/kotlin/gg/grounds/permissions/PermissionProtoContractTest.kt b/common/src/test/kotlin/gg/grounds/permissions/PermissionProtoContractTest.kt new file mode 100644 index 0000000..c244bac --- /dev/null +++ b/common/src/test/kotlin/gg/grounds/permissions/PermissionProtoContractTest.kt @@ -0,0 +1,37 @@ +package gg.grounds.permissions + +import gg.grounds.grpc.permissions.GetPlayerSnapshotRequest +import gg.grounds.grpc.permissions.PermissionGrant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +class PermissionProtoContractTest { + @Test + fun `snapshot request reserves removed keycloak groups field`() { + val descriptor = GetPlayerSnapshotRequest.getDescriptor().toProto() + + assertFalse( + GetPlayerSnapshotRequest.getDescriptor().fields.any { it.name == "keycloak_groups" } + ) + assertEquals(listOf("keycloak_groups"), descriptor.reservedNameList) + assertEquals(listOf(2 to 3), descriptor.reservedRangeList.map { it.start to it.end }) + assertEquals( + 3, + GetPlayerSnapshotRequest.getDescriptor().findFieldByName("server_type").number, + ) + assertEquals( + 4, + GetPlayerSnapshotRequest.getDescriptor().findFieldByName("server_id").number, + ) + } + + @Test + fun `permission grants expose structured origin metadata`() { + val descriptor = PermissionGrant.getDescriptor() + + assertNotNull(descriptor.findFieldByName("origin")) + assertEquals(6, descriptor.findFieldByName("origin").number) + } +} diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionSnapshotClient.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionSnapshotClient.kt index b6eaf37..d9f667c 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionSnapshotClient.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionSnapshotClient.kt @@ -4,6 +4,7 @@ import com.google.protobuf.Timestamp import gg.grounds.grpc.permissions.GetPlayerSnapshotRequest import gg.grounds.grpc.permissions.PermissionEffect as GrpcPermissionEffect import gg.grounds.grpc.permissions.PermissionGrant as GrpcPermissionGrant +import gg.grounds.grpc.permissions.PermissionGrantOriginKind as GrpcPermissionGrantOriginKind import gg.grounds.grpc.permissions.PermissionGrantSource as GrpcPermissionGrantSource import gg.grounds.grpc.permissions.PermissionScope as GrpcPermissionScope import gg.grounds.grpc.permissions.PermissionScopeKind as GrpcPermissionScopeKind @@ -12,6 +13,8 @@ import gg.grounds.grpc.permissions.PlayerPermissionSnapshot import gg.grounds.grpc.permissions.RoleMetadata as GrpcRoleMetadata import gg.grounds.permissions.PermissionEffect import gg.grounds.permissions.PermissionGrant +import gg.grounds.permissions.PermissionGrantOrigin +import gg.grounds.permissions.PermissionGrantOriginKind import gg.grounds.permissions.PermissionGrantSource import gg.grounds.permissions.PermissionScope import gg.grounds.permissions.PermissionSnapshot @@ -123,8 +126,33 @@ private fun GrpcPermissionGrant.toDomain(): PermissionGrant = else -> PermissionGrantSource.ROLE }, expiresAt = if (hasExpiresAt()) expiresAt.toInstant() else null, + origin = toDomainOrigin(), ) +private fun GrpcPermissionGrant.toDomainOrigin(): PermissionGrantOrigin? { + if (!hasOrigin()) return null + + val kind = + when (origin.kind) { + GrpcPermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_DEFAULT_ROLE -> + PermissionGrantOriginKind.DEFAULT_ROLE + GrpcPermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_DIRECT_ROLE -> + PermissionGrantOriginKind.DIRECT_ROLE + GrpcPermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_GROUP_MAPPING -> + PermissionGrantOriginKind.GROUP_MAPPING + GrpcPermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_DIRECT_PERMISSION -> + PermissionGrantOriginKind.DIRECT_PERMISSION + else -> return null + } + + return PermissionGrantOrigin( + kind = kind, + roleKey = origin.roleKey.takeIf { it.isNotEmpty() }, + mappingId = origin.mappingId.takeIf { it.isNotEmpty() }, + inheritedPath = origin.inheritedPathList.toList(), + ) +} + private fun GrpcPermissionScope.toDomain(): PermissionScope = when (kind) { GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER -> PermissionScope.server(value) diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionSnapshotClientTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionSnapshotClientTest.kt new file mode 100644 index 0000000..edcb21a --- /dev/null +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionSnapshotClientTest.kt @@ -0,0 +1,102 @@ +package gg.grounds.permissions.minestom + +import com.google.protobuf.Timestamp +import gg.grounds.grpc.permissions.GetPlayerSnapshotRequest +import gg.grounds.grpc.permissions.PermissionEffect.PERMISSION_EFFECT_ALLOW +import gg.grounds.grpc.permissions.PermissionGrant +import gg.grounds.grpc.permissions.PermissionGrantOrigin +import gg.grounds.grpc.permissions.PermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_GROUP_MAPPING +import gg.grounds.grpc.permissions.PermissionGrantSource.PERMISSION_GRANT_SOURCE_ROLE +import gg.grounds.grpc.permissions.PermissionScope +import gg.grounds.grpc.permissions.PermissionScopeKind.PERMISSION_SCOPE_KIND_GLOBAL +import gg.grounds.grpc.permissions.PermissionSnapshotServiceGrpc +import gg.grounds.grpc.permissions.PlayerPermissionSnapshot +import io.grpc.Server +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder +import io.grpc.stub.StreamObserver +import java.util.UUID +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test + +class PermissionSnapshotClientTest { + private var server: Server? = null + + @AfterEach + fun stopServer() { + server?.shutdownNow() + } + + @Test + fun `sends only player and server context and maps grant origin`() { + val playerId = UUID.randomUUID() + var capturedRequest: GetPlayerSnapshotRequest? = null + server = + NettyServerBuilder.forPort(0) + .addService( + object : PermissionSnapshotServiceGrpc.PermissionSnapshotServiceImplBase() { + override fun getPlayerSnapshot( + request: GetPlayerSnapshotRequest, + responseObserver: StreamObserver, + ) { + capturedRequest = request + responseObserver.onNext(snapshotResponse(playerId)) + responseObserver.onCompleted() + } + } + ) + .build() + .start() + + GrpcPermissionSnapshotClient.create("localhost:${server!!.port}").use { client -> + val result = + client.fetchSnapshot( + playerId, + PermissionSnapshotContext(serverType = "paper", serverId = "lobby-1"), + ) + + val success = + assertInstanceOf(PermissionSnapshotFetchResult.Success::class.java, result) + val request = requireNotNull(capturedRequest) + assertEquals(setOf(1, 3, 4), request.allFields.keys.map { it.number }.toSet()) + assertEquals(playerId.toString(), request.playerId) + assertEquals("paper", request.serverType) + assertEquals("lobby-1", request.serverId) + assertEquals( + gg.grounds.permissions.PermissionGrantOrigin( + kind = gg.grounds.permissions.PermissionGrantOriginKind.GROUP_MAPPING, + roleKey = "moderator", + mappingId = "mapping-1", + inheritedPath = listOf("member", "moderator"), + ), + success.snapshot.allowPatterns.single().origin, + ) + } + } + + private fun snapshotResponse(playerId: UUID): PlayerPermissionSnapshot { + val timestamp = Timestamp.newBuilder().setSeconds(1_700_000_000).build() + return PlayerPermissionSnapshot.newBuilder() + .setPlayerId(playerId.toString()) + .setPolicyVersion(42) + .setIssuedAt(timestamp) + .setRefreshAfter(timestamp) + .setExpiresAt(timestamp) + .addAllowPatterns( + PermissionGrant.newBuilder() + .setEffect(PERMISSION_EFFECT_ALLOW) + .setPattern("grounds.chat") + .setScope(PermissionScope.newBuilder().setKind(PERMISSION_SCOPE_KIND_GLOBAL)) + .setSource(PERMISSION_GRANT_SOURCE_ROLE) + .setOrigin( + PermissionGrantOrigin.newBuilder() + .setKind(PERMISSION_GRANT_ORIGIN_KIND_GROUP_MAPPING) + .setRoleKey("moderator") + .setMappingId("mapping-1") + .addAllInheritedPath(listOf("member", "moderator")) + ) + ) + .build() + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionSnapshotClient.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionSnapshotClient.kt index 83da1c6..dc5e920 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionSnapshotClient.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionSnapshotClient.kt @@ -4,6 +4,7 @@ import com.google.protobuf.Timestamp import gg.grounds.grpc.permissions.GetPlayerSnapshotRequest import gg.grounds.grpc.permissions.PermissionEffect as GrpcPermissionEffect import gg.grounds.grpc.permissions.PermissionGrant as GrpcPermissionGrant +import gg.grounds.grpc.permissions.PermissionGrantOriginKind as GrpcPermissionGrantOriginKind import gg.grounds.grpc.permissions.PermissionGrantSource as GrpcPermissionGrantSource import gg.grounds.grpc.permissions.PermissionScope as GrpcPermissionScope import gg.grounds.grpc.permissions.PermissionScopeKind as GrpcPermissionScopeKind @@ -12,6 +13,8 @@ import gg.grounds.grpc.permissions.PlayerPermissionSnapshot import gg.grounds.grpc.permissions.RoleMetadata as GrpcRoleMetadata import gg.grounds.permissions.PermissionEffect import gg.grounds.permissions.PermissionGrant +import gg.grounds.permissions.PermissionGrantOrigin +import gg.grounds.permissions.PermissionGrantOriginKind import gg.grounds.permissions.PermissionGrantSource import gg.grounds.permissions.PermissionScope import gg.grounds.permissions.PermissionSnapshot @@ -124,8 +127,33 @@ private fun GrpcPermissionGrant.toDomain(): PermissionGrant = else -> PermissionGrantSource.ROLE }, expiresAt = if (hasExpiresAt()) expiresAt.toInstant() else null, + origin = toDomainOrigin(), ) +private fun GrpcPermissionGrant.toDomainOrigin(): PermissionGrantOrigin? { + if (!hasOrigin()) return null + + val kind = + when (origin.kind) { + GrpcPermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_DEFAULT_ROLE -> + PermissionGrantOriginKind.DEFAULT_ROLE + GrpcPermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_DIRECT_ROLE -> + PermissionGrantOriginKind.DIRECT_ROLE + GrpcPermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_GROUP_MAPPING -> + PermissionGrantOriginKind.GROUP_MAPPING + GrpcPermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_DIRECT_PERMISSION -> + PermissionGrantOriginKind.DIRECT_PERMISSION + else -> return null + } + + return PermissionGrantOrigin( + kind = kind, + roleKey = origin.roleKey.takeIf { it.isNotEmpty() }, + mappingId = origin.mappingId.takeIf { it.isNotEmpty() }, + inheritedPath = origin.inheritedPathList.toList(), + ) +} + private fun GrpcPermissionScope.toDomain(): PermissionScope = when (kind) { GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER -> PermissionScope.server(value) diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/SnapshotDiskCache.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/SnapshotDiskCache.kt index 6f7ae1b..810f31b 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/SnapshotDiskCache.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/SnapshotDiskCache.kt @@ -2,6 +2,7 @@ package gg.grounds.permissions.velocity import gg.grounds.permissions.PermissionEffect import gg.grounds.permissions.PermissionGrant +import gg.grounds.permissions.PermissionGrantOrigin import gg.grounds.permissions.PermissionGrantSource import gg.grounds.permissions.PermissionScope import gg.grounds.permissions.PermissionScopeKind @@ -112,6 +113,7 @@ private data class CachedPermissionGrant( val scope: CachedPermissionScope, val source: PermissionGrantSource, val expiresAt: String? = null, + val origin: PermissionGrantOrigin? = null, ) { fun toDomain(): PermissionGrant = PermissionGrant( @@ -120,6 +122,7 @@ private data class CachedPermissionGrant( scope = scope.toDomain(), source = source, expiresAt = expiresAt?.let(Instant::parse), + origin = origin, ) } @@ -158,6 +161,7 @@ private fun PermissionGrant.toCache(): CachedPermissionGrant = scope = scope.toCache(), source = source, expiresAt = expiresAt?.toString(), + origin = origin, ) private fun PermissionScope.toCache(): CachedPermissionScope = CachedPermissionScope(kind, value) diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionSnapshotClientTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionSnapshotClientTest.kt new file mode 100644 index 0000000..2f67c5c --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionSnapshotClientTest.kt @@ -0,0 +1,102 @@ +package gg.grounds.permissions.velocity + +import com.google.protobuf.Timestamp +import gg.grounds.grpc.permissions.GetPlayerSnapshotRequest +import gg.grounds.grpc.permissions.PermissionEffect.PERMISSION_EFFECT_ALLOW +import gg.grounds.grpc.permissions.PermissionGrant +import gg.grounds.grpc.permissions.PermissionGrantOrigin +import gg.grounds.grpc.permissions.PermissionGrantOriginKind.PERMISSION_GRANT_ORIGIN_KIND_GROUP_MAPPING +import gg.grounds.grpc.permissions.PermissionGrantSource.PERMISSION_GRANT_SOURCE_ROLE +import gg.grounds.grpc.permissions.PermissionScope +import gg.grounds.grpc.permissions.PermissionScopeKind.PERMISSION_SCOPE_KIND_GLOBAL +import gg.grounds.grpc.permissions.PermissionSnapshotServiceGrpc +import gg.grounds.grpc.permissions.PlayerPermissionSnapshot +import io.grpc.Server +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder +import io.grpc.stub.StreamObserver +import java.util.UUID +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test + +class PermissionSnapshotClientTest { + private var server: Server? = null + + @AfterEach + fun stopServer() { + server?.shutdownNow() + } + + @Test + fun `sends only player and server context and maps grant origin`() { + val playerId = UUID.randomUUID() + var capturedRequest: GetPlayerSnapshotRequest? = null + server = + NettyServerBuilder.forPort(0) + .addService( + object : PermissionSnapshotServiceGrpc.PermissionSnapshotServiceImplBase() { + override fun getPlayerSnapshot( + request: GetPlayerSnapshotRequest, + responseObserver: StreamObserver, + ) { + capturedRequest = request + responseObserver.onNext(snapshotResponse(playerId)) + responseObserver.onCompleted() + } + } + ) + .build() + .start() + + GrpcPermissionSnapshotClient.create("localhost:${server!!.port}").use { client -> + val result = + client.fetchSnapshot( + playerId, + PermissionSnapshotContext(serverType = "velocity", serverId = "proxy-1"), + ) + + val success = + assertInstanceOf(PermissionSnapshotFetchResult.Success::class.java, result) + val request = requireNotNull(capturedRequest) + assertEquals(setOf(1, 3, 4), request.allFields.keys.map { it.number }.toSet()) + assertEquals(playerId.toString(), request.playerId) + assertEquals("velocity", request.serverType) + assertEquals("proxy-1", request.serverId) + assertEquals( + gg.grounds.permissions.PermissionGrantOrigin( + kind = gg.grounds.permissions.PermissionGrantOriginKind.GROUP_MAPPING, + roleKey = "moderator", + mappingId = "mapping-1", + inheritedPath = listOf("member", "moderator"), + ), + success.snapshot.allowPatterns.single().origin, + ) + } + } + + private fun snapshotResponse(playerId: UUID): PlayerPermissionSnapshot { + val timestamp = Timestamp.newBuilder().setSeconds(1_700_000_000).build() + return PlayerPermissionSnapshot.newBuilder() + .setPlayerId(playerId.toString()) + .setPolicyVersion(42) + .setIssuedAt(timestamp) + .setRefreshAfter(timestamp) + .setExpiresAt(timestamp) + .addAllowPatterns( + PermissionGrant.newBuilder() + .setEffect(PERMISSION_EFFECT_ALLOW) + .setPattern("grounds.chat") + .setScope(PermissionScope.newBuilder().setKind(PERMISSION_SCOPE_KIND_GLOBAL)) + .setSource(PERMISSION_GRANT_SOURCE_ROLE) + .setOrigin( + PermissionGrantOrigin.newBuilder() + .setKind(PERMISSION_GRANT_ORIGIN_KIND_GROUP_MAPPING) + .setRoleKey("moderator") + .setMappingId("mapping-1") + .addAllInheritedPath(listOf("member", "moderator")) + ) + ) + .build() + } +} diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/SnapshotDiskCacheTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/SnapshotDiskCacheTest.kt index 1005cb1..c28c9d3 100644 --- a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/SnapshotDiskCacheTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/SnapshotDiskCacheTest.kt @@ -2,6 +2,8 @@ package gg.grounds.permissions.velocity import gg.grounds.permissions.PermissionEffect import gg.grounds.permissions.PermissionGrant +import gg.grounds.permissions.PermissionGrantOrigin +import gg.grounds.permissions.PermissionGrantOriginKind import gg.grounds.permissions.PermissionGrantSource import gg.grounds.permissions.PermissionScope import gg.grounds.permissions.PermissionSnapshot @@ -84,6 +86,13 @@ class SnapshotDiskCacheTest { pattern = "grounds.chat", scope = PermissionScope.global(), source = PermissionGrantSource.ROLE, + origin = + PermissionGrantOrigin( + kind = PermissionGrantOriginKind.GROUP_MAPPING, + roleKey = "default", + mappingId = "mapping-1", + inheritedPath = listOf("member", "default"), + ), ) ), denyPatterns = emptyList(),