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
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class MinestomPermissionSnapshotLoaderTest {
client = FakeClient(PermissionSnapshotFetchResult.Success(snapshot)),
)

val result = loader.loadSnapshot(playerId, "lukas")
val result = loader.loadSnapshot(playerId, "Alex")

assertTrue(result.allowed)
assertEquals(snapshot, result.snapshot)
Expand All @@ -42,7 +42,7 @@ class MinestomPermissionSnapshotLoaderTest {
client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable")),
)

val result = loader.loadSnapshot(playerId, "lukas")
val result = loader.loadSnapshot(playerId, "Alex")

assertTrue(result.allowed)
assertEquals(snapshot, result.snapshot)
Expand All @@ -54,7 +54,7 @@ class MinestomPermissionSnapshotLoaderTest {
val loader =
loader(client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable")))

val result = loader.loadSnapshot(playerId, "lukas")
val result = loader.loadSnapshot(playerId, "Alex")

assertFalse(result.allowed)
}
Expand All @@ -70,7 +70,7 @@ class MinestomPermissionSnapshotLoaderTest {
client = FakeClient(PermissionSnapshotFetchResult.Unavailable("unavailable")),
)

val result = loader.loadSnapshot(playerId, "lukas")
val result = loader.loadSnapshot(playerId, "Alex")

assertFalse(result.allowed)
}
Expand All @@ -82,7 +82,7 @@ class MinestomPermissionSnapshotLoaderTest {
val context = PermissionSnapshotContext(serverType = "arena", serverId = "arena-1")
val loader = loader(client = client, context = context)

loader.loadSnapshot(playerId, "lukas")
loader.loadSnapshot(playerId, "Alex")

assertSame(context, client.lastContext)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gg.grounds.permissions.velocity

import com.google.inject.Inject
import com.velocitypowered.api.command.CommandMeta
import com.velocitypowered.api.event.Subscribe
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent
Expand All @@ -10,6 +11,7 @@ import com.velocitypowered.api.proxy.ProxyServer
import gg.grounds.BuildInfo
import gg.grounds.permissions.InMemoryPermissionSnapshots
import gg.grounds.permissions.PermissionCheckScope
import gg.grounds.permissions.SnapshotPermissions
import io.grpc.LoadBalancerRegistry
import io.grpc.NameResolverRegistry
import io.grpc.internal.DnsNameResolverProvider
Expand All @@ -33,6 +35,8 @@ constructor(
@param:DataDirectory private val dataDirectory: Path,
) {
private var client: PermissionSnapshotClient? = null
private var catalogClient: PermissionCatalogClient? = null
private var commandMeta: CommandMeta? = null

init {
logger.info("Initialized plugin (plugin=plugin-permissions, version={})", BuildInfo.VERSION)
Expand All @@ -44,18 +48,72 @@ constructor(

val config = VelocityPermissionsConfig.fromEnvironment(System.getenv())
val snapshotClient = GrpcPermissionSnapshotClient.create(config.grpcTarget)
val manifestClient = GrpcPermissionCatalogClient.create(config.grpcTarget)
client = snapshotClient
catalogClient = manifestClient

proxy.eventManager.register(
this,
val snapshots = InMemoryPermissionSnapshots()
val listener =
PermissionLoginListener(
logger = logger,
snapshots = InMemoryPermissionSnapshots(),
snapshots = snapshots,
cache = SnapshotDiskCache(logger, dataDirectory.resolve("snapshots")),
client = snapshotClient,
context = config.context,
),
)
)
proxy.eventManager.register(this, listener)

val permissions =
SnapshotPermissions(snapshots, defaultScope = config.context.toCheckScope())
val manifest = PermissionManifest.loadResource()
val commandPermissions = PermissionCommandPermissions.fromManifest(manifest)
val router =
PermissionCommandRouter(
service =
PermissionCommandService(
snapshots = snapshots,
permissions = permissions,
refreshSnapshot = listener::loadSnapshot,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't reuse login fallback for manual refresh

When the backend fetch fails for an online player with a valid disk cache, PermissionLoginListener.loadSnapshot returns allowed(cached) so logins can continue. Wiring the refresh command to that same method means /perm refresh <player> and refresh all report success/policyVersion while only reloading cached data, so operators can think a permission change was refreshed even though the backend was unavailable.

Useful? React with 👍 / 👎.

status =
PermissionCommandStatus(
version = BuildInfo.VERSION,
grpcTarget = config.grpcTarget,
context = config.context,
),
),
findOnlinePlayer = { identifier ->
VelocityPermissionsCommand.findOnlinePlayer(proxy, identifier)
},
onlinePlayers = { VelocityPermissionsCommand.onlinePlayers(proxy) },
defaultScope =
PermissionCheckScopeArgument(
serverType = config.context.serverType,
server = config.context.serverId,
),
)
val command =
VelocityPermissionsCommand(
plugin = this,
proxy = proxy,
router = router,
commandPermissions = commandPermissions,
isAuthorized = { source, permission ->
VelocityPermissionsCommand.isPlayerAuthorized(source, permissions, permission)
},
)
val commandMeta =
proxy.commandManager.metaBuilder("permissions").aliases("perm").plugin(this).build()
proxy.commandManager.register(commandMeta, command)
this.commandMeta = commandMeta

logger.info("Registered permissions commands successfully (root=permissions, alias=perm)")

proxy.scheduler
.buildTask(
this,
Runnable { registerPermissionManifest(manifestClient, manifest, config) },
)
.schedule()

logger.info(
"Configured permissions snapshot client (target={}, serverType={}, serverId={})",
Expand All @@ -67,14 +125,46 @@ constructor(

@Subscribe
fun onShutdown(event: ProxyShutdownEvent) {
commandMeta?.let(proxy.commandManager::unregister)
commandMeta = null
client?.close()
client = null
catalogClient?.close()
catalogClient = null
}

private fun registerProviders() {
NameResolverRegistry.getDefaultRegistry().register(DnsNameResolverProvider())
LoadBalancerRegistry.getDefaultRegistry().register(PickFirstLoadBalancerProvider())
}

private fun registerPermissionManifest(
manifestClient: PermissionCatalogClient,
manifest: PermissionManifest,
config: VelocityPermissionsConfig,
) {
when (
val result =
manifestClient.register(
manifest = manifest,
source = "plugin-permissions",
sourceVersion = BuildInfo.VERSION,
context = config.context,
)
) {
PermissionManifestRegistrationResult.Accepted ->
logger.info(
"Registered permission catalog manifest successfully (source=plugin-permissions, version={}, permissionCount={})",
BuildInfo.VERSION,
manifest.permissions.size,
)
is PermissionManifestRegistrationResult.Unavailable ->
logger.warn(
"Failed to register permission catalog manifest (source=plugin-permissions, reason={})",
result.reason,
)
}
}
}

data class VelocityPermissionsConfig(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package gg.grounds.permissions.velocity

import gg.grounds.grpc.permissions.PermissionCatalogServiceGrpc
import gg.grounds.grpc.permissions.PermissionManifestEntry as GrpcPermissionManifestEntry
import gg.grounds.grpc.permissions.PermissionScopeKind
import gg.grounds.grpc.permissions.RegisterPermissionManifestRequest
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import io.grpc.Status
import io.grpc.StatusRuntimeException
import java.util.concurrent.TimeUnit

sealed interface PermissionManifestRegistrationResult {
data object Accepted : PermissionManifestRegistrationResult

data class Unavailable(val reason: String) : PermissionManifestRegistrationResult
}

interface PermissionCatalogClient : AutoCloseable {
fun register(
manifest: PermissionManifest,
source: String,
sourceVersion: String,
context: PermissionSnapshotContext,
): PermissionManifestRegistrationResult

override fun close() {}
}

class GrpcPermissionCatalogClient private constructor(private val channel: ManagedChannel) :
PermissionCatalogClient {
private val stub = PermissionCatalogServiceGrpc.newBlockingStub(channel)

override fun register(
manifest: PermissionManifest,
source: String,
sourceVersion: String,
context: PermissionSnapshotContext,
): PermissionManifestRegistrationResult =
try {
val reply =
stub
.withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.registerPermissionManifest(
RegisterPermissionManifestRequest.newBuilder()
.setSource(source)
.setSourceVersion(sourceVersion)
.setServerType(context.serverType.orEmpty())
.setServerId(context.serverId.orEmpty())
.addAllPermissions(
manifest.permissions.map(PermissionManifestEntry::toGrpc)
)
.build()
)
if (reply.accepted) {
PermissionManifestRegistrationResult.Accepted
} else {
PermissionManifestRegistrationResult.Unavailable(
reply.message.ifBlank { "rejected" }
)
}
} catch (e: StatusRuntimeException) {
PermissionManifestRegistrationResult.Unavailable(e.status.toSafeReason())
} catch (e: RuntimeException) {
PermissionManifestRegistrationResult.Unavailable(e.message ?: e::class.java.name)
}

override fun close() {
channel.shutdown()
try {
if (!channel.awaitTermination(3, TimeUnit.SECONDS)) {
channel.shutdownNow()
channel.awaitTermination(3, TimeUnit.SECONDS)
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
channel.shutdownNow()
}
}

companion object {
fun create(target: String): GrpcPermissionCatalogClient =
GrpcPermissionCatalogClient(
ManagedChannelBuilder.forTarget(target).usePlaintext().build()
)

private const val DEFAULT_TIMEOUT_MS = 2000L
}
}

private fun PermissionManifestEntry.toGrpc(): GrpcPermissionManifestEntry =
GrpcPermissionManifestEntry.newBuilder()
.setKey(key)
.setLabel(label)
.setDescription(description)
.addAllSupportedScopes(supportedScopes.map(PermissionManifestScope::toGrpc))
.build()

private fun PermissionManifestScope.toGrpc(): PermissionScopeKind =
when (this) {
PermissionManifestScope.GLOBAL -> PermissionScopeKind.PERMISSION_SCOPE_KIND_GLOBAL
PermissionManifestScope.SERVER_TYPE -> PermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER_TYPE
PermissionManifestScope.SERVER -> PermissionScopeKind.PERMISSION_SCOPE_KIND_SERVER
}

private fun Status.toSafeReason(): String = code.toString()
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package gg.grounds.permissions.velocity

class PermissionCommandPermissions private constructor(private val nodes: Map<String, String>) {
fun forArguments(arguments: Array<String>): String =
when {
arguments.firstOrNull().equals("status", ignoreCase = true) -> node("status")
arguments.firstOrNull().equals("refresh", ignoreCase = true) -> node("refresh")
arguments.firstOrNull().equals("user", ignoreCase = true) &&
arguments.getOrNull(2).equals("info", ignoreCase = true) -> node("user.info")
arguments.firstOrNull().equals("user", ignoreCase = true) &&
arguments.getOrNull(2).equals("refresh", ignoreCase = true) -> node("user.refresh")
arguments.firstOrNull().equals("user", ignoreCase = true) &&
arguments.getOrNull(2).equals("permission", ignoreCase = true) &&
arguments.getOrNull(3).equals("check", ignoreCase = true) -> node("user.check")
else -> node("root")
}

private fun node(key: String): String =
requireNotNull(nodes[key]) { "Missing command permission: $key" }

companion object {
private val REQUIRED_KEYS =
setOf("root", "status", "user.info", "user.check", "user.refresh", "refresh")

fun fromManifest(manifest: PermissionManifest): PermissionCommandPermissions {
val nodes =
manifest.permissions.map(PermissionManifestEntry::key).associateBy { key ->
when (key) {
"grounds.permissions.command" -> "root"
"grounds.permissions.command.status" -> "status"
"grounds.permissions.command.user.info" -> "user.info"
"grounds.permissions.command.user.check" -> "user.check"
"grounds.permissions.command.user.refresh" -> "user.refresh"
"grounds.permissions.command.refresh" -> "refresh"
else -> key
}
}
val missing = REQUIRED_KEYS - nodes.keys
require(missing.isEmpty()) {
"Missing required command permissions: ${missing.sorted().joinToString(",")}"
}
return PermissionCommandPermissions(nodes)
}
}
}
Loading