Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions common/src/main/kotlin/gg/grounds/permissions/PermissionChecker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ interface Permissions {
fun snapshot(playerId: UUID): PermissionSnapshot?
}

data class PermissionCheckScope(val serverType: String? = null, val server: String? = null) {
/**
* Where a permission is being checked.
*
* `environment` is orthogonal to the two server dimensions: it names the deployment this server
* belongs to (`stage`, `prod`), not what it runs. A server therefore usually carries all three, and
* a grant may pin any one.
*/
data class PermissionCheckScope(
val serverType: String? = null,
val server: String? = null,
val environment: String? = null,
) {
companion object {
fun global(): PermissionCheckScope = PermissionCheckScope()

Expand All @@ -26,6 +37,9 @@ data class PermissionCheckScope(val serverType: String? = null, val server: Stri
PermissionCheckScope(serverType = serverType, server = server)

fun serverOnly(server: String): PermissionCheckScope = PermissionCheckScope(server = server)

fun environment(environment: String): PermissionCheckScope =
PermissionCheckScope(environment = environment)
}
}

Expand Down Expand Up @@ -135,11 +149,16 @@ class SnapshotPermissions(
)
}

// Least to most specific: an environment names a whole deployment, a
// server type names a workload inside it, a server names one instance.
// Mirrors PolicyEngine.specificityFor in service-permissions — the two
// must agree or an admin preview disagrees with the running server.
private fun PermissionScope.specificityFor(scope: PermissionCheckScope): Int? =
when (kind) {
PermissionScopeKind.GLOBAL -> 0
PermissionScopeKind.SERVER_TYPE -> if (value == scope.serverType) 1 else null
PermissionScopeKind.SERVER -> if (value == scope.server) 2 else null
PermissionScopeKind.ENVIRONMENT -> if (value == scope.environment) 1 else null
PermissionScopeKind.SERVER_TYPE -> if (value == scope.serverType) 2 else null
PermissionScopeKind.SERVER -> if (value == scope.server) 3 else null
}

private fun PermissionGrant.isExpired(now: Instant): Boolean =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum class PermissionEffect {

enum class PermissionScopeKind {
GLOBAL,
ENVIRONMENT,
SERVER_TYPE,
SERVER,
}
Expand All @@ -24,6 +25,9 @@ data class PermissionScope(val kind: PermissionScopeKind, val value: String? = n
companion object {
fun global(): PermissionScope = PermissionScope(PermissionScopeKind.GLOBAL)

fun environment(environment: String): PermissionScope =
PermissionScope(PermissionScopeKind.ENVIRONMENT, environment)

fun serverType(serverType: String): PermissionScope =
PermissionScope(PermissionScopeKind.SERVER_TYPE, serverType)

Expand Down
4 changes: 4 additions & 0 deletions common/src/main/proto/permissions.proto
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ enum PermissionScopeKind {
PERMISSION_SCOPE_KIND_GLOBAL = 1;
PERMISSION_SCOPE_KIND_SERVER_TYPE = 2;
PERMISSION_SCOPE_KIND_SERVER = 3;
// Appended rather than inserted in specificity order: the numbers are the
// wire format, and renumbering would silently reinterpret every grant a
// running server already holds.
PERMISSION_SCOPE_KIND_ENVIRONMENT = 4;
}

enum PermissionGrantSource {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,46 @@ class PermissionCheckerTest {
)
}

@Test
fun environmentScopedGrantsOverrideGlobalOnesInThatEnvironmentOnly() {
val allowPatterns = listOf(allow("region.edit", PermissionScope.global()))
val denyPatterns = listOf(deny("region.edit", PermissionScope.environment("stage")))

assertFalse(
permissions(
allowPatterns = allowPatterns,
denyPatterns = denyPatterns,
scope = PermissionCheckScope.environment("stage"),
)
.hasPermission(playerId, "region.edit")
)
assertTrue(
permissions(
allowPatterns = allowPatterns,
denyPatterns = denyPatterns,
scope = PermissionCheckScope.environment("prod"),
)
.hasPermission(playerId, "region.edit")
)
}

@Test
fun serverTypeScopedGrantsOutrankEnvironmentScopedOnes() {
val permissions =
permissions(
allowPatterns = listOf(allow("region.edit", PermissionScope.serverType("lobby"))),
denyPatterns = listOf(deny("region.edit", PermissionScope.environment("stage"))),
scope =
PermissionCheckScope(
serverType = "lobby",
server = "lobby-1",
environment = "stage",
),
)

assertTrue(permissions.hasPermission(playerId, "region.edit"))
}

@Test
fun ignoresExpiredIndividualGrants() {
val permissions =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ data class MinestomPermissionsConfig(
} ?: fallbackServerType,
serverId =
environment["GROUNDS_PERMISSION_SERVER_ID"]?.takeIf { it.isNotBlank() },
environment =
environment["GROUNDS_PERMISSION_ENVIRONMENT"]?.takeIf {
it.isNotBlank()
},
),
refreshIntervalSeconds =
environment["PERMISSIONS_REFRESH_INTERVAL_SECONDS"]
Expand All @@ -222,9 +226,4 @@ data class MinestomPermissionsConfig(
}

fun PermissionSnapshotContext.toCheckScope(): PermissionCheckScope =
when {
serverId != null && serverType != null -> PermissionCheckScope.server(serverId, serverType)
serverId != null -> PermissionCheckScope.serverOnly(serverId)
serverType != null -> PermissionCheckScope.serverType(serverType)
else -> PermissionCheckScope.global()
}
PermissionCheckScope(serverType = serverType, server = serverId, environment = environment)
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import java.time.Instant
import java.util.UUID
import java.util.concurrent.TimeUnit

data class PermissionSnapshotContext(val serverType: String? = null, val serverId: String? = null)
data class PermissionSnapshotContext(
val serverType: String? = null,
val serverId: String? = null,
/** Deployment this server belongs to (`stage`, `prod`), for environment-scoped grants. */
val environment: String? = null,
)

sealed interface PermissionSnapshotFetchResult {
data class Success(val snapshot: PermissionSnapshot) : PermissionSnapshotFetchResult
Expand Down Expand Up @@ -107,21 +112,21 @@ private fun PlayerPermissionSnapshot.toDomain(): PermissionSnapshot =
issuedAt = issuedAt.toInstant(),
refreshAfter = refreshAfter.toInstant(),
expiresAt = expiresAt.toInstant(),
allowPatterns = allowPatternsList.map { it.toDomain() },
denyPatterns = denyPatternsList.map { it.toDomain() },
allowPatterns = allowPatternsList.mapNotNull { it.toDomain() },
denyPatterns = denyPatternsList.mapNotNull { it.toDomain() },
roleKeys = roleKeysList.toSet(),
roleMetadata = roleMetadataList.map { it.toDomain() },
)

private fun GrpcPermissionGrant.toDomain(): PermissionGrant =
private fun GrpcPermissionGrant.toDomain(): PermissionGrant? =
PermissionGrant(
effect =
when (effect) {
GrpcPermissionEffect.PERMISSION_EFFECT_DENY -> PermissionEffect.DENY
else -> PermissionEffect.ALLOW
},
pattern = pattern,
scope = scope.toDomain(),
scope = scope.toDomain() ?: return null,
source =
when (source) {
GrpcPermissionGrantSource.PERMISSION_GRANT_SOURCE_PLAYER ->
Expand Down Expand Up @@ -156,12 +161,19 @@ private fun GrpcPermissionGrant.toDomainOrigin(): PermissionGrantOrigin? {
)
}

private fun GrpcPermissionScope.toDomain(): PermissionScope =
// Returns null for a scope kind this build does not know. Dropping the grant
// is the only safe reading: the previous `else -> global()` turned a scope
// added after this jar was built into an unconditional grant, which for an
// ALLOW means handing out a permission everywhere it was meant to be narrowed.
private fun GrpcPermissionScope.toDomain(): PermissionScope? =
when (kind) {
GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_GLOBAL -> PermissionScope.global()
GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_ENVIRONMENT ->
PermissionScope.environment(value)
GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER -> PermissionScope.server(value)
GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER_TYPE ->
PermissionScope.serverType(value)
else -> PermissionScope.global()
else -> null
}

private fun GrpcRoleMetadata.toDomain(): RoleMetadata =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ data class VelocityPermissionsConfig(
},
serverId =
environment["GROUNDS_PERMISSION_SERVER_ID"]?.takeIf { it.isNotBlank() },
environment =
environment["GROUNDS_PERMISSION_ENVIRONMENT"]?.takeIf {
it.isNotBlank()
},
),
refreshIntervalSeconds =
environment["PERMISSIONS_REFRESH_INTERVAL_SECONDS"]
Expand All @@ -273,9 +277,4 @@ data class VelocityPermissionsConfig(
}

fun PermissionSnapshotContext.toCheckScope(): PermissionCheckScope =
when {
serverId != null && serverType != null -> PermissionCheckScope.server(serverId, serverType)
serverId != null -> PermissionCheckScope.serverOnly(serverId)
serverType != null -> PermissionCheckScope.serverType(serverType)
else -> PermissionCheckScope.global()
}
PermissionCheckScope(serverType = serverType, server = serverId, environment = environment)
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import java.time.Instant
import java.util.UUID
import java.util.concurrent.TimeUnit

data class PermissionSnapshotContext(val serverType: String? = null, val serverId: String? = null)
data class PermissionSnapshotContext(
val serverType: String? = null,
val serverId: String? = null,
/** Deployment this proxy belongs to (`stage`, `prod`), for environment-scoped grants. */
val environment: String? = null,
)

sealed interface PermissionSnapshotFetchResult {
data class Success(val snapshot: PermissionSnapshot) : PermissionSnapshotFetchResult
Expand Down Expand Up @@ -108,21 +113,21 @@ private fun PlayerPermissionSnapshot.toDomain(): PermissionSnapshot =
issuedAt = issuedAt.toInstant(),
refreshAfter = refreshAfter.toInstant(),
expiresAt = expiresAt.toInstant(),
allowPatterns = allowPatternsList.map { it.toDomain() },
denyPatterns = denyPatternsList.map { it.toDomain() },
allowPatterns = allowPatternsList.mapNotNull { it.toDomain() },
denyPatterns = denyPatternsList.mapNotNull { it.toDomain() },
roleKeys = roleKeysList.toSet(),
roleMetadata = roleMetadataList.map { it.toDomain() },
)

private fun GrpcPermissionGrant.toDomain(): PermissionGrant =
private fun GrpcPermissionGrant.toDomain(): PermissionGrant? =
PermissionGrant(
effect =
when (effect) {
GrpcPermissionEffect.PERMISSION_EFFECT_DENY -> PermissionEffect.DENY
else -> PermissionEffect.ALLOW
},
pattern = pattern,
scope = scope.toDomain(),
scope = scope.toDomain() ?: return null,
source =
when (source) {
GrpcPermissionGrantSource.PERMISSION_GRANT_SOURCE_PLAYER ->
Expand Down Expand Up @@ -157,12 +162,19 @@ private fun GrpcPermissionGrant.toDomainOrigin(): PermissionGrantOrigin? {
)
}

private fun GrpcPermissionScope.toDomain(): PermissionScope =
// Returns null for a scope kind this build does not know. Dropping the grant
// is the only safe reading: the previous `else -> global()` turned a scope
// added after this jar was built into an unconditional grant, which for an
// ALLOW means handing out a permission everywhere it was meant to be narrowed.
private fun GrpcPermissionScope.toDomain(): PermissionScope? =
when (kind) {
GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_GLOBAL -> PermissionScope.global()
GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_ENVIRONMENT ->
PermissionScope.environment(value)
GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER -> PermissionScope.server(value)
GrpcPermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER_TYPE ->
PermissionScope.serverType(value)
else -> PermissionScope.global()
else -> null
}

private fun GrpcRoleMetadata.toDomain(): RoleMetadata =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package gg.grounds.permissions.velocity

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.PermissionScope as GrpcPermissionScope
import gg.grounds.grpc.permissions.PermissionScopeKind as GrpcPermissionScopeKind
import gg.grounds.grpc.permissions.PermissionSnapshotServiceGrpc
import gg.grounds.grpc.permissions.PlayerPermissionSnapshot
import gg.grounds.permissions.PermissionScope
import gg.grounds.permissions.ReleasedPermissionSnapshotFixture
import io.grpc.Server
import io.grpc.inprocess.InProcessChannelBuilder
import io.grpc.inprocess.InProcessServerBuilder
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
Expand Down Expand Up @@ -62,4 +69,79 @@ class PermissionSnapshotClientTest {
assertEquals(ReleasedPermissionSnapshotFixture.expected(), success.snapshot)
}
}

@Test
fun `maps environment scopes and drops scope kinds this build does not know`() {
val playerId = UUID.fromString("00000000-0000-0000-0000-000000000456")
val serverName = InProcessServerBuilder.generateName()
server =
InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(
object : PermissionSnapshotServiceGrpc.PermissionSnapshotServiceImplBase() {
override fun getPlayerSnapshot(
request: GetPlayerSnapshotRequest,
responseObserver: StreamObserver<PlayerPermissionSnapshot>,
) {
responseObserver.onNext(
PlayerPermissionSnapshot.newBuilder()
.setPlayerId(playerId.toString())
.setPolicyVersion(1)
.setIssuedAt(secondsFromEpoch(0))
.setRefreshAfter(secondsFromEpoch(60))
.setExpiresAt(secondsFromEpoch(3600))
.addAllowPatterns(
grant(
"stage.only",
GrpcPermissionScopeKind
.PERMISSION_SCOPE_KIND_ENVIRONMENT,
"stage",
)
)
.addAllowPatterns(
grant(
"from.the.future",
GrpcPermissionScopeKind
.PERMISSION_SCOPE_KIND_UNSPECIFIED,
"whatever",
)
)
.build()
)
responseObserver.onCompleted()
}
}
)
.build()
.start()
val channel = InProcessChannelBuilder.forName(serverName).directExecutor().build()

GrpcPermissionSnapshotClient.create(channel).use { client ->
val result =
client.fetchSnapshot(playerId, PermissionSnapshotContext(environment = "stage"))

val success =
assertInstanceOf(PermissionSnapshotFetchResult.Success::class.java, result)
assertEquals(1, success.snapshot.allowPatterns.size)
assertEquals("stage.only", success.snapshot.allowPatterns.single().pattern)
assertEquals(
PermissionScope.environment("stage"),
success.snapshot.allowPatterns.single().scope,
)
}
}

private fun grant(
pattern: String,
kind: GrpcPermissionScopeKind,
scopeValue: String,
): GrpcPermissionGrant =
GrpcPermissionGrant.newBuilder()
.setEffect(GrpcPermissionEffect.PERMISSION_EFFECT_ALLOW)
.setPattern(pattern)
.setScope(GrpcPermissionScope.newBuilder().setKind(kind).setValue(scopeValue))
.build()

private fun secondsFromEpoch(seconds: Long): Timestamp =
Timestamp.newBuilder().setSeconds(seconds).build()
}