From 626dc256365a6c8bddf5d6b435c8008cf888ed5f Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 9 Jul 2026 21:04:37 +0200 Subject: [PATCH 1/5] docs: add permission manifest collector plan --- ...026-07-09-permission-manifest-collector.md | 88 +++++++++++++++++++ ...09-permission-manifest-collector-design.md | 33 +++++++ 2 files changed, 121 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-permission-manifest-collector.md create mode 100644 docs/superpowers/specs/2026-07-09-permission-manifest-collector-design.md diff --git a/docs/superpowers/plans/2026-07-09-permission-manifest-collector.md b/docs/superpowers/plans/2026-07-09-permission-manifest-collector.md new file mode 100644 index 0000000..67f407d --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-permission-manifest-collector.md @@ -0,0 +1,88 @@ +# Permission Manifest Collector Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Register catalog entries from every active Velocity plugin and Minestom module through the permissions runtime component. + +**Architecture:** A shared parser represents `META-INF/grounds/permissions.json`. Velocity and Minestom turn active components into classloader origins, then the permissions runtime registers every valid manifest through the existing gRPC service. service-permissions persists the validated entries for Portal. + +**Tech Stack:** Kotlin, Jackson, gRPC/protobuf, Velocity, Grounds Minestom runtime, Quarkus, PostgreSQL, JUnit 5. + +## Global Constraints + +- Use exactly `META-INF/grounds/permissions.json`. +- Discover only active Velocity plugins and installed Minestom providers. +- Use the existing `PermissionCatalogService.RegisterPermissionManifest` RPC. +- Never fail server startup because a manifest is missing, malformed, or temporarily unregistrable. +- Runtime records use `custom = false`; Portal API changes are out of scope. +- Log messages must be factual English with non-sensitive origin context. +- In every changed Gradle repository run `./gradlew test`, `./gradlew spotlessApply`, and `./gradlew build` with escalated permissions. + +--- + +### Task 1: Persist Runtime Catalog Registrations + +**Repository:** `/home/lukas/grounds/service-permissions` + +**Files:** +- Modify: `src/main/kotlin/gg/grounds/permissions/api/PermissionCatalogGrpcService.kt` +- Modify: `src/test/kotlin/gg/grounds/permissions/api/PermissionSnapshotGrpcServiceTest.kt` + +**Interfaces:** +- Consumes `RegisterPermissionManifestRequest` and `PermissionRepository.upsertCatalogEntry(CatalogEntryRecord)`. +- Produces runtime-owned `CatalogEntryRecord` values returned by `PermissionCatalogResource.listCatalog()`. + +- [ ] Write a failing test that registers one manifest and asserts `repository.listCatalogEntries().single()` has the request key and `custom == false`. +- [ ] Run `./gradlew test --tests gg.grounds.permissions.api.PermissionSnapshotGrpcServiceTest`; verify the catalog is empty before implementation. +- [ ] Inject `PermissionRepository`; after validation, map every protobuf entry to `CatalogEntryRecord(key, label, description, source, sourceVersion, supportedScopes, custom = false, lastSeenAt = Instant.now())` and call `upsertCatalogEntry`. +- [ ] Run `./gradlew test`, `./gradlew spotlessApply`, and `./gradlew build`. +- [ ] Commit `feat: persist permission manifests`. + +### Task 2: Expose Active Minestom Provider Origins + +**Repository:** `/home/lukas/grounds/grounds-minestom-runtime` + +**Files:** +- Modify: `runtime-api/src/main/kotlin/gg/grounds/runtime/GroundsServerContext.kt` +- Modify: `runtime-core/src/main/kotlin/gg/grounds/runtime/core/GroundsModuleComposition.kt` +- Modify: `runtime-core/src/main/kotlin/gg/grounds/runtime/core/GroundsServer.kt` +- Test: `runtime-core/src/test/kotlin/gg/grounds/runtime/core/GroundsServerTest.kt` + +**Interfaces:** +- Produces `ActiveGroundsModuleProvider(id: String, version: String, classLoader: ClassLoader)`. +- Extends `GroundsServerContext` with `activeModuleProviders: List`. + +- [ ] Write a failing test proving the context exposes selected provider IDs but omits merely discovered providers. +- [ ] Run `./gradlew :runtime-core:test --tests gg.grounds.runtime.core.GroundsServerTest`; confirm the new context property is absent. +- [ ] Retain provider ID, version, and classloader in `GroundsModuleComposition`; pass only server-type-matched providers into `DefaultGroundsServerContext`. +- [ ] Run `./gradlew test`, `./gradlew spotlessApply`, and `./gradlew build`. +- [ ] Commit `feat: expose active module providers`. + +### Task 3: Collect and Register Active Runtime Manifests + +**Repository:** `/home/lukas/grounds/plugin-permissions` + +**Files:** +- Modify: `common/build.gradle.kts` +- Create: `common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifest.kt` +- Create: `common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt` +- Modify: `velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt` +- Modify: `minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt` +- Create: `minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt` +- Modify: `velocity/src/main/resources/META-INF/grounds/permissions.json` +- Test: `common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt` +- Test: `velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt` +- Test: `minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt` + +**Interfaces:** +- Consumes `ManifestOrigin(id: String, version: String, classLoader: ClassLoader)`. +- Produces one registration attempt per valid active manifest and the own manifest used for command authorization. + +- [ ] Write failing parser tests for the `plugin-agones` JSON shape, missing resources, duplicate keys, and invalid scopes. +- [ ] Write failing discovery tests asserting that two active origins register two manifests and an origin with no resource is skipped. +- [ ] Add Jackson JSON dependencies to `common/build.gradle.kts`; implement the shared parser and `PermissionManifestCollector.collect(origins)` using `META-INF/grounds/permissions.json`. +- [ ] In Velocity map `PluginManager.getPlugins()` containers with instances to origins; in Minestom map `ctx.activeModuleProviders` to origins. +- [ ] Add a Minestom `PermissionCatalogClient` backed by the existing gRPC channel pattern; register each collected manifest off the server thread with five bounded retry attempts and log final failures without aborting startup. +- [ ] Replace PR #4's self-only `META-INF/grounds-permissions.json` implementation with the standardized collector and the own `META-INF/grounds/permissions.json` manifest. +- [ ] Run `./gradlew test`, `./gradlew spotlessApply`, and `./gradlew build`. +- [ ] Commit `feat: collect permission manifests` and push the existing PR branch. diff --git a/docs/superpowers/specs/2026-07-09-permission-manifest-collector-design.md b/docs/superpowers/specs/2026-07-09-permission-manifest-collector-design.md new file mode 100644 index 0000000..dfcebd6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-permission-manifest-collector-design.md @@ -0,0 +1,33 @@ +# Permission Manifest Collector Design + +## Goal + +The permissions plugin or module collects manifests from every active runtime component and registers the entries with service-permissions so Portal displays runtime-owned permission nodes. + +## Manifest Contract + +Every participating artifact places one JSON resource at `META-INF/grounds/permissions.json`. + +```json +{ + "source": "plugin-agones", + "permissions": [ + { + "key": "grounds.command.agones", + "label": "Use Agones command", + "description": "Allows using the Agones command.", + "supportedScopes": ["GLOBAL", "SERVER_TYPE", "SERVER"] + } + ] +} +``` + +The collector skips missing resources. It reports malformed JSON, duplicate keys, blank required fields, and entries without scopes without stopping the server. + +## Discovery + +Velocity enumerates active `PluginContainer` instances through `PluginManager.getPlugins()` and reads the resource from each plugin instance classloader. Minestom exposes the provider classloaders for modules selected and installed by `GroundsServer`; discovered but unselected providers are excluded. + +## Registration + +The collector sends one `RegisterPermissionManifest` request per valid manifest using the manifest source, component version, and runtime scope. It retries transient failures five times with increasing delays. `service-permissions` persists runtime entries with `custom = false`; Portal continues to use its existing catalog endpoint. From 309ed40b9cd60707fa302bef8dee3805985f1f5e Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 9 Jul 2026 21:15:09 +0200 Subject: [PATCH 2/5] feat: collect permission manifests --- common/build.gradle.kts | 2 + .../catalog}/PermissionManifest.kt | 52 +++--- .../catalog/PermissionManifestCollector.kt | 34 ++++ .../PermissionManifestCollectorTest.kt | 111 ++++++++++++ velocity/build.gradle.kts | 1 + .../velocity/GroundsPermissionsPlugin.kt | 162 ++++++++++-------- .../velocity/PermissionCatalogClient.kt | 7 +- .../velocity/PermissionCommandPermissions.kt | 3 + .../velocity/PermissionManifestDiscovery.kt | 17 ++ .../velocity/PermissionManifestRegistrar.kt | 75 ++++++++ .../permissions.json} | 1 + .../velocity/PermissionCommandServiceTest.kt | 8 +- .../PermissionManifestDiscoveryTest.kt | 108 ++++++++++++ 13 files changed, 487 insertions(+), 94 deletions(-) rename {velocity/src/main/kotlin/gg/grounds/permissions/velocity => common/src/main/kotlin/gg/grounds/permissions/catalog}/PermissionManifest.kt (58%) create mode 100644 common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt create mode 100644 common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscovery.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrar.kt rename velocity/src/main/resources/META-INF/{grounds-permissions.json => grounds/permissions.json} (97%) create mode 100644 velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 1fbf799..6d85acb 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -4,6 +4,8 @@ dependencies { implementation(platform("gg.grounds:grounds-dependencies:0.1.0")) api("com.google.protobuf:protobuf-java") + implementation("tools.jackson.core:jackson-databind:3.1.0") + implementation("tools.jackson.module:jackson-module-kotlin:3.1.0") testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt b/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifest.kt similarity index 58% rename from velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt rename to common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifest.kt index a22ab21..2fb0b68 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifest.kt +++ b/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifest.kt @@ -1,4 +1,4 @@ -package gg.grounds.permissions.velocity +package gg.grounds.permissions.catalog import java.io.InputStream import tools.jackson.databind.DeserializationFeature @@ -6,53 +6,59 @@ import tools.jackson.databind.ObjectMapper import tools.jackson.databind.json.JsonMapper import tools.jackson.module.kotlin.KotlinModule -data class PermissionManifest(val permissions: List) { +data class PermissionManifest(val source: String, val permissions: List) { init { + require(source.isNotBlank()) { "Permission manifest source must not be blank." } require(permissions.isNotEmpty()) { "Permission manifest must declare at least one permission." } require(permissions.map(PermissionManifestEntry::key).toSet().size == permissions.size) { "Permission manifest must not declare duplicate keys." } + permissions.forEach { permission -> + require(permission.key.isNotBlank()) { "Permission manifest keys must not be blank." } + require(permission.label.isNotBlank()) { + "Permission manifest labels must not be blank." + } + require(permission.description.isNotBlank()) { + "Permission manifest descriptions must not be blank (key=${permission.key})." + } + require(permission.supportedScopes.isNotEmpty()) { + "Permission manifest scopes must not be empty (key=${permission.key})." + } + } } companion object { - private const val RESOURCE_NAME = "META-INF/grounds-permissions.json" + const val RESOURCE_PATH = "META-INF/grounds/permissions.json" + private val mapper: ObjectMapper = JsonMapper.builder() .addModule(KotlinModule.Builder().build()) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build() - fun loadResource(): PermissionManifest { - val input = - PermissionManifest::class.java.classLoader.getResourceAsStream(RESOURCE_NAME) - ?: error("Missing required permission manifest resource $RESOURCE_NAME") - return input.use(::load) - } - fun load(input: InputStream): PermissionManifest = - mapper.readValue(input, PermissionManifest::class.java).validated() - } - - private fun validated(): PermissionManifest { - permissions.forEach { permission -> - require(permission.key.isNotBlank()) { "Permission manifest keys must not be blank." } - require(permission.label.isNotBlank()) { - "Permission manifest labels must not be blank." + try { + mapper.readValue(input, PermissionManifest::class.java) + } catch (exception: Exception) { + throw IllegalArgumentException("Invalid permission manifest.", exception) } - require(permission.supportedScopes.isNotEmpty()) { - "Permission manifest scopes must not be empty (key=${permission.key})." + + fun loadResource(classLoader: ClassLoader): PermissionManifest? = + classLoader.getResourceAsStream(RESOURCE_PATH)?.use(::load) + + fun loadRequiredResource(classLoader: ClassLoader): PermissionManifest = + requireNotNull(loadResource(classLoader)) { + "Missing required permission manifest resource $RESOURCE_PATH" } - } - return this } } data class PermissionManifestEntry( val key: String, val label: String, - val description: String = "", + val description: String, val supportedScopes: List, ) diff --git a/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt b/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt new file mode 100644 index 0000000..c6aae69 --- /dev/null +++ b/common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt @@ -0,0 +1,34 @@ +package gg.grounds.permissions.catalog + +data class ManifestOrigin(val id: String, val version: String, val classLoader: ClassLoader) + +data class CollectedPermissionManifest(val origin: ManifestOrigin, val manifest: PermissionManifest) + +data class PermissionManifestCollectionFailure(val origin: ManifestOrigin, val reason: String) + +data class PermissionManifestCollection( + val manifests: List, + val failures: List, +) + +class PermissionManifestCollector { + fun collect(origins: Iterable): PermissionManifestCollection { + val manifests = mutableListOf() + val failures = mutableListOf() + + origins.forEach { origin -> + try { + val manifest = PermissionManifest.loadResource(origin.classLoader) ?: return@forEach + manifests += CollectedPermissionManifest(origin, manifest) + } catch (exception: IllegalArgumentException) { + failures += + PermissionManifestCollectionFailure( + origin = origin, + reason = exception.message ?: exception::class.java.simpleName, + ) + } + } + + return PermissionManifestCollection(manifests = manifests, failures = failures) + } +} diff --git a/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt b/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt new file mode 100644 index 0000000..00b8319 --- /dev/null +++ b/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt @@ -0,0 +1,111 @@ +package gg.grounds.permissions.catalog + +import java.io.ByteArrayInputStream +import java.io.InputStream +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PermissionManifestCollectorTest { + @Test + fun `loads the established manifest shape`() { + val manifest = + PermissionManifest.load( + """ + { + "source": "plugin-agones", + "permissions": [ + { + "key": "grounds.command.agones", + "label": "Use Agones command", + "description": "Allows using the Agones command.", + "supportedScopes": ["GLOBAL", "SERVER_TYPE", "SERVER"] + } + ] + } + """ + .trimIndent() + .byteInputStream() + ) + + assertEquals("plugin-agones", manifest.source) + assertEquals("grounds.command.agones", manifest.permissions.single().key) + assertEquals( + listOf( + PermissionManifestScope.GLOBAL, + PermissionManifestScope.SERVER_TYPE, + PermissionManifestScope.SERVER, + ), + manifest.permissions.single().supportedScopes, + ) + } + + @Test + fun `skips origins without a manifest resource`() { + val collection = + PermissionManifestCollector() + .collect(listOf(ManifestOrigin("missing-plugin", "1.0.0", ResourceClassLoader()))) + + assertTrue(collection.manifests.isEmpty()) + assertTrue(collection.failures.isEmpty()) + } + + @Test + fun `reports duplicate permission keys without collecting the manifest`() { + val collection = + PermissionManifestCollector() + .collect( + listOf( + ManifestOrigin( + "duplicate-plugin", + "1.0.0", + ResourceClassLoader( + """ + { + "source": "duplicate-plugin", + "permissions": [ + { "key": "grounds.command.duplicate", "label": "Duplicate", "description": "Allows duplicate command.", "supportedScopes": ["GLOBAL"] }, + { "key": "grounds.command.duplicate", "label": "Duplicate again", "description": "Allows duplicate command again.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + ), + ) + ) + ) + + assertTrue(collection.manifests.isEmpty()) + assertEquals("duplicate-plugin", collection.failures.single().origin.id) + } + + @Test + fun `rejects unsupported scopes`() { + assertThrows(IllegalArgumentException::class.java) { + PermissionManifest.load( + """ + { + "source": "invalid-plugin", + "permissions": [ + { "key": "grounds.command.invalid", "label": "Invalid", "description": "Allows invalid command.", "supportedScopes": ["PROJECT"] } + ] + } + """ + .trimIndent() + .byteInputStream() + ) + } + } + + private class ResourceClassLoader(manifest: String? = null) : ClassLoader() { + private val bytes = manifest?.encodeToByteArray() + + override fun getResourceAsStream(name: String): InputStream? = + if (name == PermissionManifest.RESOURCE_PATH && bytes != null) { + ByteArrayInputStream(bytes) + } else { + null + } + } +} diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index acd51f6..3ffd14f 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -11,5 +11,6 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.slf4j:slf4j-api") + testImplementation("com.velocitypowered:velocity-api") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt index ef0ce55..cf58c11 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt @@ -12,6 +12,8 @@ import gg.grounds.BuildInfo import gg.grounds.permissions.InMemoryPermissionSnapshots import gg.grounds.permissions.PermissionCheckScope import gg.grounds.permissions.SnapshotPermissions +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestCollector import io.grpc.LoadBalancerRegistry import io.grpc.NameResolverRegistry import io.grpc.internal.DnsNameResolverProvider @@ -65,54 +67,57 @@ constructor( 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, - 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 + loadCommandPermissions()?.let { commandPermissions -> + val router = + PermissionCommandRouter( + service = + PermissionCommandService( + snapshots = snapshots, + permissions = permissions, + refreshSnapshot = listener::loadSnapshot, + 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)") + logger.info( + "Registered permissions commands successfully (root=permissions, alias=perm)" + ) + } proxy.scheduler - .buildTask( - this, - Runnable { registerPermissionManifest(manifestClient, manifest, config) }, - ) + .buildTask(this, Runnable { registerActivePermissionManifests(manifestClient, config) }) .schedule() logger.info( @@ -138,31 +143,54 @@ constructor( LoadBalancerRegistry.getDefaultRegistry().register(PickFirstLoadBalancerProvider()) } - private fun registerPermissionManifest( + private fun loadCommandPermissions(): PermissionCommandPermissions? = + try { + PermissionCommandPermissions.fromManifest( + PermissionManifest.loadRequiredResource(javaClass.classLoader) + ) + } catch (exception: IllegalArgumentException) { + logger.warn( + "Skipped permissions command registration (originId=plugin-permissions, reason={})", + exception.message ?: exception::class.java.simpleName, + ) + null + } + + private fun registerActivePermissionManifests( 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, - ) + val collection = + PermissionManifestCollector() + .collect(discoverPermissionManifestOrigins(proxy.pluginManager.plugins)) + collection.failures.forEach { failure -> + logger.warn( + "Skipped malformed permission manifest (originId={}, originVersion={}, reason={})", + failure.origin.id, + failure.origin.version, + failure.reason, + ) + } + val registration = + PermissionManifestRegistrar(manifestClient, config.context) + .register(collection.manifests) + registration.registered.forEach { collected -> + logger.info( + "Registered permission catalog manifest successfully (originId={}, source={}, version={}, permissionCount={})", + collected.origin.id, + collected.manifest.source, + collected.origin.version, + collected.manifest.permissions.size, + ) + } + registration.failures.forEach { failure -> + logger.warn( + "Failed to register permission catalog manifest (originId={}, source={}, attempts={}, reason={})", + failure.collected.origin.id, + failure.collected.manifest.source, + failure.attempts, + failure.reason, + ) } } } diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt index 198af29..6a888ec 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCatalogClient.kt @@ -4,6 +4,9 @@ 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 gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestEntry +import gg.grounds.permissions.catalog.PermissionManifestScope import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder import io.grpc.Status @@ -19,7 +22,6 @@ sealed interface PermissionManifestRegistrationResult { interface PermissionCatalogClient : AutoCloseable { fun register( manifest: PermissionManifest, - source: String, sourceVersion: String, context: PermissionSnapshotContext, ): PermissionManifestRegistrationResult @@ -33,7 +35,6 @@ class GrpcPermissionCatalogClient private constructor(private val channel: Manag override fun register( manifest: PermissionManifest, - source: String, sourceVersion: String, context: PermissionSnapshotContext, ): PermissionManifestRegistrationResult = @@ -43,7 +44,7 @@ class GrpcPermissionCatalogClient private constructor(private val channel: Manag .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) .registerPermissionManifest( RegisterPermissionManifestRequest.newBuilder() - .setSource(source) + .setSource(manifest.source) .setSourceVersion(sourceVersion) .setServerType(context.serverType.orEmpty()) .setServerId(context.serverId.orEmpty()) diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt index 14def9b..ec597e3 100644 --- a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionCommandPermissions.kt @@ -1,5 +1,8 @@ package gg.grounds.permissions.velocity +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestEntry + class PermissionCommandPermissions private constructor(private val nodes: Map) { fun forArguments(arguments: Array): String = when { diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscovery.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscovery.kt new file mode 100644 index 0000000..d970933 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscovery.kt @@ -0,0 +1,17 @@ +package gg.grounds.permissions.velocity + +import com.velocitypowered.api.plugin.PluginContainer +import gg.grounds.permissions.catalog.ManifestOrigin + +fun discoverPermissionManifestOrigins( + containers: Collection +): List = + containers.mapNotNull { container -> + val instance = container.instance.orElse(null) ?: return@mapNotNull null + val classLoader = instance.javaClass.classLoader ?: return@mapNotNull null + ManifestOrigin( + id = container.description.id, + version = container.description.version.orElse("unknown"), + classLoader = classLoader, + ) + } diff --git a/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrar.kt b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrar.kt new file mode 100644 index 0000000..e427b92 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrar.kt @@ -0,0 +1,75 @@ +package gg.grounds.permissions.velocity + +import gg.grounds.permissions.catalog.CollectedPermissionManifest + +data class PermissionManifestRegistrationFailure( + val collected: CollectedPermissionManifest, + val attempts: Int, + val reason: String, +) + +data class PermissionManifestRegistrationReport( + val registered: List, + val failures: List, +) + +class PermissionManifestRegistrar( + private val client: PermissionCatalogClient, + private val context: PermissionSnapshotContext, + private val sleep: (Long) -> Unit = { duration -> Thread.sleep(duration) }, +) { + fun register( + manifests: Iterable + ): PermissionManifestRegistrationReport { + val registered = mutableListOf() + val failures = mutableListOf() + + manifests.forEach manifestLoop@{ collected -> + repeat(MAX_ATTEMPTS) { attempt -> + when ( + val result = + client.register( + manifest = collected.manifest, + sourceVersion = collected.origin.version, + context = context, + ) + ) { + PermissionManifestRegistrationResult.Accepted -> { + registered += collected + return@manifestLoop + } + is PermissionManifestRegistrationResult.Unavailable -> { + if (attempt == MAX_ATTEMPTS - 1) { + failures += + PermissionManifestRegistrationFailure( + collected = collected, + attempts = MAX_ATTEMPTS, + reason = result.reason, + ) + return@manifestLoop + } + try { + sleep((attempt + 1) * BACKOFF_MS) + } catch (exception: InterruptedException) { + Thread.currentThread().interrupt() + failures += + PermissionManifestRegistrationFailure( + collected = collected, + attempts = attempt + 1, + reason = "interrupted", + ) + return@manifestLoop + } + } + } + } + } + + return PermissionManifestRegistrationReport(registered = registered, failures = failures) + } + + private companion object { + const val MAX_ATTEMPTS = 5 + const val BACKOFF_MS = 250L + } +} diff --git a/velocity/src/main/resources/META-INF/grounds-permissions.json b/velocity/src/main/resources/META-INF/grounds/permissions.json similarity index 97% rename from velocity/src/main/resources/META-INF/grounds-permissions.json rename to velocity/src/main/resources/META-INF/grounds/permissions.json index 16dea99..c888359 100644 --- a/velocity/src/main/resources/META-INF/grounds-permissions.json +++ b/velocity/src/main/resources/META-INF/grounds/permissions.json @@ -1,4 +1,5 @@ { + "source": "plugin-permissions", "permissions": [ { "key": "grounds.permissions.command", diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt index 76366cd..2c1e489 100644 --- a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionCommandServiceTest.kt @@ -8,6 +8,7 @@ import gg.grounds.permissions.PermissionScope import gg.grounds.permissions.PermissionSnapshot import gg.grounds.permissions.RoleMetadata import gg.grounds.permissions.SnapshotPermissions +import gg.grounds.permissions.catalog.PermissionManifest import java.time.Clock import java.time.Instant import java.time.ZoneOffset @@ -229,6 +230,7 @@ class PermissionCommandServiceTest { PermissionManifest.load( """ { + "source": "plugin-permissions", "permissions": [ { "key": "grounds.permissions.command", "label": "Permissions commands", "description": "Allows command help.", "supportedScopes": ["GLOBAL"] }, { "key": "grounds.permissions.command.status", "label": "Permissions status", "description": "Allows status.", "supportedScopes": ["GLOBAL"] }, @@ -262,7 +264,11 @@ class PermissionCommandServiceTest { @Test fun `packaged command permissions are complete`() { val permissions = - PermissionCommandPermissions.fromManifest(PermissionManifest.loadResource()) + PermissionCommandPermissions.fromManifest( + PermissionManifest.loadRequiredResource( + GroundsPermissionsPlugin::class.java.classLoader + ) + ) assertEquals("grounds.permissions.command", permissions.forArguments(emptyArray())) assertEquals( diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt new file mode 100644 index 0000000..36772e5 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt @@ -0,0 +1,108 @@ +package gg.grounds.permissions.velocity + +import com.velocitypowered.api.plugin.PluginContainer +import com.velocitypowered.api.plugin.PluginDescription +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestCollector +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.lang.reflect.Proxy +import java.util.Optional +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PermissionManifestDiscoveryTest { + @Test + fun `collects manifests from active plugin instances and skips plugins without one`() { + val first = manifestInstance("first-plugin") + val second = manifestInstance("second-plugin") + val containers = + listOf( + pluginContainer("first-plugin", "1.0.0", first), + pluginContainer("without-manifest", "1.0.0", Any()), + pluginContainer("second-plugin", "2.0.0", second), + ) + val origins = discoverPermissionManifestOrigins(containers) + val manifests = PermissionManifestCollector().collect(origins).manifests + val client = RecordingCatalogClient() + + PermissionManifestRegistrar( + client = client, + context = PermissionSnapshotContext(serverType = "proxy", serverId = "proxy-1"), + sleep = {}, + ) + .register(manifests) + + assertEquals(listOf("first-plugin", "second-plugin"), manifests.map { it.manifest.source }) + assertEquals(listOf("1.0.0", "2.0.0"), manifests.map { it.origin.version }) + assertEquals(listOf("first-plugin", "second-plugin"), client.registeredSources) + } + + private fun pluginContainer(id: String, version: String, instance: Any): PluginContainer { + val description = + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PluginDescription::class.java)) { + _, + method, + _ -> + when (method.name) { + "getId" -> id + "getVersion" -> Optional.of(version) + else -> error("Unexpected plugin description method: ${method.name}") + } + } as PluginDescription + return Proxy.newProxyInstance( + javaClass.classLoader, + arrayOf(PluginContainer::class.java), + ) { _, method, _ -> + when (method.name) { + "getDescription" -> description + "getInstance" -> Optional.of(instance) + else -> error("Unexpected plugin container method: ${method.name}") + } + } as PluginContainer + } + + private fun manifestInstance(source: String): Any = + Proxy.newProxyInstance(ManifestClassLoader(source), arrayOf(Runnable::class.java)) { + _, + method, + _ -> + when (method.name) { + "run" -> Unit + else -> error("Unexpected plugin instance method: ${method.name}") + } + } + + private class ManifestClassLoader(private val source: String) : ClassLoader() { + override fun getResourceAsStream(name: String): InputStream? = + if (name == PermissionManifest.RESOURCE_PATH) { + ByteArrayInputStream( + """ + { + "source": "${source}", + "permissions": [ + { "key": "grounds.command.${source}", "label": "Use command", "description": "Allows using the command.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + .encodeToByteArray() + ) + } else { + null + } + } + + private class RecordingCatalogClient : PermissionCatalogClient { + val registeredSources = mutableListOf() + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult { + registeredSources += manifest.source + return PermissionManifestRegistrationResult.Accepted + } + } +} From cd7d3d8e3135aa2ca6768ca534d2fc4369b4aa5b Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 9 Jul 2026 21:21:40 +0200 Subject: [PATCH 3/5] test: cover permission manifest edge cases --- .../PermissionManifestCollectorTest.kt | 81 +++++++++++++++++++ .../PermissionManifestDiscoveryTest.kt | 6 +- .../PermissionManifestRegistrarTest.kt | 64 +++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrarTest.kt diff --git a/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt b/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt index 00b8319..a9598d2 100644 --- a/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt +++ b/common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt @@ -80,6 +80,87 @@ class PermissionManifestCollectorTest { assertEquals("duplicate-plugin", collection.failures.single().origin.id) } + @Test + fun `reports manifests with missing or blank sources`() { + val collection = + PermissionManifestCollector() + .collect( + listOf( + ManifestOrigin( + "missing-source", + "1.0.0", + ResourceClassLoader( + """ + { + "permissions": [ + { "key": "grounds.command.missing-source", "label": "Missing source", "description": "Requires a source.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + ), + ), + ManifestOrigin( + "blank-source", + "1.0.0", + ResourceClassLoader( + """ + { + "source": " ", + "permissions": [ + { "key": "grounds.command.blank-source", "label": "Blank source", "description": "Requires a source.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + ), + ), + ) + ) + + assertTrue(collection.manifests.isEmpty()) + assertEquals( + listOf("missing-source", "blank-source"), + collection.failures.map { it.origin.id }, + ) + } + + @Test + fun `reports manifests with blank required permission entry fields`() { + val collection = + PermissionManifestCollector() + .collect( + listOf("key", "label", "description").map { field -> + ManifestOrigin( + "blank-$field", + "1.0.0", + ResourceClassLoader( + """ + { + "source": "blank-$field", + "permissions": [ + { + "key": "${if (field == "key") " " else "grounds.command.blank-$field"}", + "label": "${if (field == "label") " " else "Use command"}", + "description": "${if (field == "description") " " else "Allows using the command."}", + "supportedScopes": ["GLOBAL"] + } + ] + } + """ + .trimIndent() + ), + ) + } + ) + + assertTrue(collection.manifests.isEmpty()) + assertEquals( + listOf("blank-key", "blank-label", "blank-description"), + collection.failures.map { it.origin.id }, + ) + } + @Test fun `rejects unsupported scopes`() { assertThrows(IllegalArgumentException::class.java) { diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt index 36772e5..8a97c41 100644 --- a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt @@ -19,7 +19,7 @@ class PermissionManifestDiscoveryTest { val containers = listOf( pluginContainer("first-plugin", "1.0.0", first), - pluginContainer("without-manifest", "1.0.0", Any()), + pluginContainer("without-instance", "1.0.0", null), pluginContainer("second-plugin", "2.0.0", second), ) val origins = discoverPermissionManifestOrigins(containers) @@ -38,7 +38,7 @@ class PermissionManifestDiscoveryTest { assertEquals(listOf("first-plugin", "second-plugin"), client.registeredSources) } - private fun pluginContainer(id: String, version: String, instance: Any): PluginContainer { + private fun pluginContainer(id: String, version: String, instance: Any?): PluginContainer { val description = Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PluginDescription::class.java)) { _, @@ -56,7 +56,7 @@ class PermissionManifestDiscoveryTest { ) { _, method, _ -> when (method.name) { "getDescription" -> description - "getInstance" -> Optional.of(instance) + "getInstance" -> Optional.ofNullable(instance) else -> error("Unexpected plugin container method: ${method.name}") } } as PluginContainer diff --git a/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrarTest.kt b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrarTest.kt new file mode 100644 index 0000000..3c85c7c --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestRegistrarTest.kt @@ -0,0 +1,64 @@ +package gg.grounds.permissions.velocity + +import gg.grounds.permissions.catalog.CollectedPermissionManifest +import gg.grounds.permissions.catalog.ManifestOrigin +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestEntry +import gg.grounds.permissions.catalog.PermissionManifestScope +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PermissionManifestRegistrarTest { + @Test + fun `retries unavailable registration five times before reporting the final failure`() { + val client = UnavailableCatalogClient("catalog unavailable") + val sleeps = mutableListOf() + val collected = + CollectedPermissionManifest( + origin = ManifestOrigin("test-plugin", "1.0.0", javaClass.classLoader), + manifest = + PermissionManifest( + source = "test-plugin", + permissions = + listOf( + PermissionManifestEntry( + key = "grounds.command.test", + label = "Use test command", + description = "Allows using the test command.", + supportedScopes = listOf(PermissionManifestScope.GLOBAL), + ) + ), + ), + ) + + val report = + PermissionManifestRegistrar( + client = client, + context = PermissionSnapshotContext(serverType = "proxy", serverId = "proxy-1"), + sleep = sleeps::add, + ) + .register(listOf(collected)) + + assertTrue(report.registered.isEmpty()) + assertEquals(5, client.attempts) + assertEquals(listOf(250L, 500L, 750L, 1_000L), sleeps) + assertEquals(1, report.failures.size) + assertEquals(collected, report.failures.single().collected) + assertEquals(5, report.failures.single().attempts) + assertEquals("catalog unavailable", report.failures.single().reason) + } + + private class UnavailableCatalogClient(private val reason: String) : PermissionCatalogClient { + var attempts = 0 + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult { + attempts += 1 + return PermissionManifestRegistrationResult.Unavailable(reason) + } + } +} From f86e1858e4fb299cf5abde0108e4d40d5da704d9 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 9 Jul 2026 21:43:53 +0200 Subject: [PATCH 4/5] feat: collect Minestom permission manifests --- minestom/build.gradle.kts | 2 +- .../minestom/GroundsPermissionsModule.kt | 73 ++++++++ .../minestom/PermissionCatalogClient.kt | 109 ++++++++++++ .../PermissionManifestRegistration.kt | 96 +++++++++++ .../PermissionManifestDiscoveryTest.kt | 157 ++++++++++++++++++ 5 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt create mode 100644 minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionManifestRegistration.kt create mode 100644 minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt diff --git a/minestom/build.gradle.kts b/minestom/build.gradle.kts index 97c0bad..a501a1e 100644 --- a/minestom/build.gradle.kts +++ b/minestom/build.gradle.kts @@ -13,7 +13,7 @@ buildConfig { dependencies { implementation(platform("gg.grounds:grounds-dependencies:0.1.0")) - api("gg.grounds:grounds-minestom-runtime-runtime-api:0.1.0") + api("gg.grounds:grounds-minestom-runtime-runtime-api:0.3.0") implementation(project(":common")) implementation("io.grpc:grpc-netty-shaded:1.79.0") implementation("io.grpc:grpc-stub") diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt index ae5b259..8babdeb 100644 --- a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt @@ -5,9 +5,13 @@ import gg.grounds.permissions.InMemoryPermissionSnapshots import gg.grounds.permissions.PermissionCheckScope import gg.grounds.permissions.Permissions import gg.grounds.permissions.SnapshotPermissions +import gg.grounds.permissions.catalog.CollectedPermissionManifest import gg.grounds.runtime.GroundsModule import gg.grounds.runtime.GroundsServerContext import java.time.Clock +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException import net.minestom.server.MinecraftServer import net.minestom.server.event.Event import net.minestom.server.event.EventNode @@ -18,6 +22,8 @@ class GroundsPermissionsModule(private val clock: Clock = Clock.systemUTC()) : G private val logger: Logger = LoggerFactory.getLogger(GroundsPermissionsModule::class.java) private val snapshots = InMemoryPermissionSnapshots() private var client: PermissionSnapshotClient? = null + private var catalogClient: PermissionCatalogClient? = null + private var catalogExecutor: ExecutorService? = null private var eventNode: EventNode? = null override val id: String = MODULE_ID @@ -31,6 +37,11 @@ class GroundsPermissionsModule(private val clock: Clock = Clock.systemUTC()) : G fallbackServerType = ctx.serverType.name.lowercase(), ) val snapshotClient = GrpcPermissionSnapshotClient.create(config.grpcTarget) + val manifestClient = GrpcPermissionCatalogClient.create(config.grpcTarget) + val manifestExecutor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "grounds-permissions-manifest-catalog").apply { isDaemon = true } + } val permissions = SnapshotPermissions( snapshots = snapshots, @@ -53,9 +64,18 @@ class GroundsPermissionsModule(private val clock: Clock = Clock.systemUTC()) : G MinecraftServer.getGlobalEventHandler().addChild(node) eventNode = node client = snapshotClient + catalogClient = manifestClient + catalogExecutor = manifestExecutor ctx.onShutdown { stop() } + registerActivePermissionManifests( + activeProviders = ctx.activeModuleProviders, + manifestClient = manifestClient, + manifestExecutor = manifestExecutor, + context = config.context, + ) + logger.info( "Installed permissions module (serverType={}, serverId={}, target={})", config.context.serverType, @@ -67,8 +87,61 @@ class GroundsPermissionsModule(private val clock: Clock = Clock.systemUTC()) : G override fun stop() { eventNode?.let(MinecraftServer.getGlobalEventHandler()::removeChild) eventNode = null + catalogExecutor?.shutdownNow() + catalogExecutor = null client?.close() client = null + catalogClient?.close() + catalogClient = null + } + + private fun registerActivePermissionManifests( + activeProviders: Iterable, + manifestClient: PermissionCatalogClient, + manifestExecutor: ExecutorService, + context: PermissionSnapshotContext, + ) { + try { + manifestExecutor.execute { + val collection = collectActivePermissionManifests(activeProviders) + collection.failures.forEach { failure -> + logger.warn( + "Skipped malformed permission manifest (originId={}, originVersion={}, reason={})", + failure.origin.id, + failure.origin.version, + failure.reason, + ) + } + val registration = + MinestomPermissionManifestRegistrar(manifestClient, context) + .register(collection.manifests) + registration.registered.forEach(::logManifestRegistration) + registration.failures.forEach { failure -> + logger.warn( + "Failed to register permission catalog manifest (originId={}, source={}, attempts={}, reason={})", + failure.collected.origin.id, + failure.collected.manifest.source, + failure.attempts, + failure.reason, + ) + } + } + } catch (exception: RejectedExecutionException) { + logger.warn( + "Skipped permission catalog registration (reason={})", + exception::class.java.simpleName, + ) + } + } + + private fun logManifestRegistration(collected: CollectedPermissionManifest) { + logger.info( + "Registered permission catalog manifest successfully (originId={}, source={}, version={}, permissionCount={})", + collected.origin.id, + collected.manifest.source, + collected.origin.version, + collected.manifest.permissions.size, + ) } companion object { diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt new file mode 100644 index 0000000..f7edd4d --- /dev/null +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt @@ -0,0 +1,109 @@ +package gg.grounds.permissions.minestom + +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 gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.permissions.catalog.PermissionManifestEntry +import gg.grounds.permissions.catalog.PermissionManifestScope +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, + 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, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult = + try { + val reply = + stub + .withDeadlineAfter(DEFAULT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .registerPermissionManifest( + RegisterPermissionManifestRequest.newBuilder() + .setSource(manifest.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 (exception: StatusRuntimeException) { + PermissionManifestRegistrationResult.Unavailable(exception.status.toSafeReason()) + } catch (exception: RuntimeException) { + PermissionManifestRegistrationResult.Unavailable( + exception.message ?: exception::class.java.name + ) + } + + override fun close() { + channel.shutdown() + try { + if (!channel.awaitTermination(3, TimeUnit.SECONDS)) { + channel.shutdownNow() + channel.awaitTermination(3, TimeUnit.SECONDS) + } + } catch (exception: 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() diff --git a/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionManifestRegistration.kt b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionManifestRegistration.kt new file mode 100644 index 0000000..2c7fee7 --- /dev/null +++ b/minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionManifestRegistration.kt @@ -0,0 +1,96 @@ +package gg.grounds.permissions.minestom + +import gg.grounds.permissions.catalog.CollectedPermissionManifest +import gg.grounds.permissions.catalog.ManifestOrigin +import gg.grounds.permissions.catalog.PermissionManifestCollection +import gg.grounds.permissions.catalog.PermissionManifestCollector +import gg.grounds.runtime.ActiveGroundsModuleProvider + +data class MinestomPermissionManifestRegistrationFailure( + val collected: CollectedPermissionManifest, + val attempts: Int, + val reason: String, +) + +data class MinestomPermissionManifestRegistrationReport( + val registered: List, + val failures: List, +) + +fun collectActivePermissionManifests( + activeProviders: Iterable +): PermissionManifestCollection = + PermissionManifestCollector() + .collect( + activeProviders.map { provider -> + ManifestOrigin( + id = provider.id, + version = provider.version, + classLoader = provider.classLoader, + ) + } + ) + +class MinestomPermissionManifestRegistrar( + private val client: PermissionCatalogClient, + private val context: PermissionSnapshotContext, + private val sleep: (Long) -> Unit = { duration -> Thread.sleep(duration) }, +) { + fun register( + manifests: Iterable + ): MinestomPermissionManifestRegistrationReport { + val registered = mutableListOf() + val failures = mutableListOf() + + manifests.forEach manifestLoop@{ collected -> + repeat(MAX_ATTEMPTS) { attempt -> + when ( + val result = + client.register( + manifest = collected.manifest, + sourceVersion = collected.origin.version, + context = context, + ) + ) { + PermissionManifestRegistrationResult.Accepted -> { + registered += collected + return@manifestLoop + } + is PermissionManifestRegistrationResult.Unavailable -> { + if (attempt == MAX_ATTEMPTS - 1) { + failures += + MinestomPermissionManifestRegistrationFailure( + collected = collected, + attempts = MAX_ATTEMPTS, + reason = result.reason, + ) + return@manifestLoop + } + try { + sleep((attempt + 1) * BACKOFF_MS) + } catch (exception: InterruptedException) { + Thread.currentThread().interrupt() + failures += + MinestomPermissionManifestRegistrationFailure( + collected = collected, + attempts = attempt + 1, + reason = "interrupted", + ) + return@manifestLoop + } + } + } + } + } + + return MinestomPermissionManifestRegistrationReport( + registered = registered, + failures = failures, + ) + } + + private companion object { + const val MAX_ATTEMPTS = 5 + const val BACKOFF_MS = 250L + } +} diff --git a/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt new file mode 100644 index 0000000..8b702e8 --- /dev/null +++ b/minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt @@ -0,0 +1,157 @@ +package gg.grounds.permissions.minestom + +import gg.grounds.permissions.catalog.ManifestOrigin +import gg.grounds.permissions.catalog.PermissionManifest +import gg.grounds.runtime.ActiveGroundsModuleProvider +import java.io.ByteArrayInputStream +import java.io.InputStream +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PermissionManifestDiscoveryTest { + @Test + fun `collects and registers manifests from active providers while skipping missing and malformed resources`() { + val activeProviders = + listOf( + provider("active-one", "1.0.0", validManifestClassLoader("active-one")), + provider("without-manifest", "1.0.0", ClassLoader.getSystemClassLoader()), + provider("malformed", "1.0.0", malformedManifestClassLoader()), + provider("active-two", "2.0.0", validManifestClassLoader("active-two")), + ) + val client = RecordingCatalogClient() + val collection = collectActivePermissionManifests(activeProviders) + + assertEquals( + listOf("active-one", "active-two"), + collection.manifests.map { it.manifest.source }, + ) + assertEquals(listOf("malformed"), collection.failures.map { it.origin.id }) + + assertDoesNotThrow { + MinestomPermissionManifestRegistrar( + client = client, + context = PermissionSnapshotContext(serverType = "arena", serverId = "arena-1"), + sleep = {}, + ) + .register(collection.manifests) + } + + assertEquals(listOf("active-one", "active-two"), client.registeredSources) + } + + @Test + fun `retries catalog failures five times and reports the final failure without throwing`() { + val client = UnavailableCatalogClient("unavailable") + val sleeps = mutableListOf() + val manifest = + PermissionManifest( + source = "active-module", + permissions = + listOf( + gg.grounds.permissions.catalog.PermissionManifestEntry( + key = "grounds.permissions.read", + label = "Read permissions", + description = "Allows reading permission state.", + supportedScopes = + listOf( + gg.grounds.permissions.catalog.PermissionManifestScope.GLOBAL + ), + ) + ), + ) + + val report = + assertDoesNotThrow { + MinestomPermissionManifestRegistrar( + client = client, + context = PermissionSnapshotContext(serverType = "arena"), + sleep = sleeps::add, + ) + .register( + listOf( + gg.grounds.permissions.catalog.CollectedPermissionManifest( + origin = + ManifestOrigin( + id = "active-module", + version = "1.0.0", + classLoader = javaClass.classLoader, + ), + manifest = manifest, + ) + ) + ) + } + + assertEquals(5, client.attempts) + assertEquals(listOf(250L, 500L, 750L, 1_000L), sleeps) + assertTrue(report.registered.isEmpty()) + assertEquals(1, report.failures.size) + assertEquals("unavailable", report.failures.single().reason) + } + + private fun provider( + id: String, + version: String, + classLoader: ClassLoader, + ): ActiveGroundsModuleProvider = + ActiveGroundsModuleProvider(id = id, version = version, classLoader = classLoader) + + private fun validManifestClassLoader(source: String): ClassLoader = + object : ClassLoader() { + override fun getResourceAsStream(name: String): InputStream? = + if (name == PermissionManifest.RESOURCE_PATH) { + ByteArrayInputStream( + """ + { + "source": "$source", + "permissions": [ + { "key": "grounds.$source.read", "label": "Read", "description": "Allows reading.", "supportedScopes": ["GLOBAL"] } + ] + } + """ + .trimIndent() + .encodeToByteArray() + ) + } else { + null + } + } + + private fun malformedManifestClassLoader(): ClassLoader = + object : ClassLoader() { + override fun getResourceAsStream(name: String): InputStream? = + if (name == PermissionManifest.RESOURCE_PATH) { + ByteArrayInputStream("not-json".encodeToByteArray()) + } else { + null + } + } + + private class RecordingCatalogClient : PermissionCatalogClient { + val registeredSources = mutableListOf() + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult { + registeredSources += manifest.source + return PermissionManifestRegistrationResult.Accepted + } + } + + private class UnavailableCatalogClient(private val reason: String) : PermissionCatalogClient { + var attempts = 0 + + override fun register( + manifest: PermissionManifest, + sourceVersion: String, + context: PermissionSnapshotContext, + ): PermissionManifestRegistrationResult { + attempts += 1 + return PermissionManifestRegistrationResult.Unavailable(reason) + } + } +} From fc9b90db6177be8b84080898b33778e3f09002ce Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 9 Jul 2026 21:46:52 +0200 Subject: [PATCH 5/5] chore: remove implementation planning documents --- ...026-07-09-permission-manifest-collector.md | 88 ------------------- ...09-permission-manifest-collector-design.md | 33 ------- 2 files changed, 121 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-09-permission-manifest-collector.md delete mode 100644 docs/superpowers/specs/2026-07-09-permission-manifest-collector-design.md diff --git a/docs/superpowers/plans/2026-07-09-permission-manifest-collector.md b/docs/superpowers/plans/2026-07-09-permission-manifest-collector.md deleted file mode 100644 index 67f407d..0000000 --- a/docs/superpowers/plans/2026-07-09-permission-manifest-collector.md +++ /dev/null @@ -1,88 +0,0 @@ -# Permission Manifest Collector Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Register catalog entries from every active Velocity plugin and Minestom module through the permissions runtime component. - -**Architecture:** A shared parser represents `META-INF/grounds/permissions.json`. Velocity and Minestom turn active components into classloader origins, then the permissions runtime registers every valid manifest through the existing gRPC service. service-permissions persists the validated entries for Portal. - -**Tech Stack:** Kotlin, Jackson, gRPC/protobuf, Velocity, Grounds Minestom runtime, Quarkus, PostgreSQL, JUnit 5. - -## Global Constraints - -- Use exactly `META-INF/grounds/permissions.json`. -- Discover only active Velocity plugins and installed Minestom providers. -- Use the existing `PermissionCatalogService.RegisterPermissionManifest` RPC. -- Never fail server startup because a manifest is missing, malformed, or temporarily unregistrable. -- Runtime records use `custom = false`; Portal API changes are out of scope. -- Log messages must be factual English with non-sensitive origin context. -- In every changed Gradle repository run `./gradlew test`, `./gradlew spotlessApply`, and `./gradlew build` with escalated permissions. - ---- - -### Task 1: Persist Runtime Catalog Registrations - -**Repository:** `/home/lukas/grounds/service-permissions` - -**Files:** -- Modify: `src/main/kotlin/gg/grounds/permissions/api/PermissionCatalogGrpcService.kt` -- Modify: `src/test/kotlin/gg/grounds/permissions/api/PermissionSnapshotGrpcServiceTest.kt` - -**Interfaces:** -- Consumes `RegisterPermissionManifestRequest` and `PermissionRepository.upsertCatalogEntry(CatalogEntryRecord)`. -- Produces runtime-owned `CatalogEntryRecord` values returned by `PermissionCatalogResource.listCatalog()`. - -- [ ] Write a failing test that registers one manifest and asserts `repository.listCatalogEntries().single()` has the request key and `custom == false`. -- [ ] Run `./gradlew test --tests gg.grounds.permissions.api.PermissionSnapshotGrpcServiceTest`; verify the catalog is empty before implementation. -- [ ] Inject `PermissionRepository`; after validation, map every protobuf entry to `CatalogEntryRecord(key, label, description, source, sourceVersion, supportedScopes, custom = false, lastSeenAt = Instant.now())` and call `upsertCatalogEntry`. -- [ ] Run `./gradlew test`, `./gradlew spotlessApply`, and `./gradlew build`. -- [ ] Commit `feat: persist permission manifests`. - -### Task 2: Expose Active Minestom Provider Origins - -**Repository:** `/home/lukas/grounds/grounds-minestom-runtime` - -**Files:** -- Modify: `runtime-api/src/main/kotlin/gg/grounds/runtime/GroundsServerContext.kt` -- Modify: `runtime-core/src/main/kotlin/gg/grounds/runtime/core/GroundsModuleComposition.kt` -- Modify: `runtime-core/src/main/kotlin/gg/grounds/runtime/core/GroundsServer.kt` -- Test: `runtime-core/src/test/kotlin/gg/grounds/runtime/core/GroundsServerTest.kt` - -**Interfaces:** -- Produces `ActiveGroundsModuleProvider(id: String, version: String, classLoader: ClassLoader)`. -- Extends `GroundsServerContext` with `activeModuleProviders: List`. - -- [ ] Write a failing test proving the context exposes selected provider IDs but omits merely discovered providers. -- [ ] Run `./gradlew :runtime-core:test --tests gg.grounds.runtime.core.GroundsServerTest`; confirm the new context property is absent. -- [ ] Retain provider ID, version, and classloader in `GroundsModuleComposition`; pass only server-type-matched providers into `DefaultGroundsServerContext`. -- [ ] Run `./gradlew test`, `./gradlew spotlessApply`, and `./gradlew build`. -- [ ] Commit `feat: expose active module providers`. - -### Task 3: Collect and Register Active Runtime Manifests - -**Repository:** `/home/lukas/grounds/plugin-permissions` - -**Files:** -- Modify: `common/build.gradle.kts` -- Create: `common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifest.kt` -- Create: `common/src/main/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollector.kt` -- Modify: `velocity/src/main/kotlin/gg/grounds/permissions/velocity/GroundsPermissionsPlugin.kt` -- Modify: `minestom/src/main/kotlin/gg/grounds/permissions/minestom/GroundsPermissionsModule.kt` -- Create: `minestom/src/main/kotlin/gg/grounds/permissions/minestom/PermissionCatalogClient.kt` -- Modify: `velocity/src/main/resources/META-INF/grounds/permissions.json` -- Test: `common/src/test/kotlin/gg/grounds/permissions/catalog/PermissionManifestCollectorTest.kt` -- Test: `velocity/src/test/kotlin/gg/grounds/permissions/velocity/PermissionManifestDiscoveryTest.kt` -- Test: `minestom/src/test/kotlin/gg/grounds/permissions/minestom/PermissionManifestDiscoveryTest.kt` - -**Interfaces:** -- Consumes `ManifestOrigin(id: String, version: String, classLoader: ClassLoader)`. -- Produces one registration attempt per valid active manifest and the own manifest used for command authorization. - -- [ ] Write failing parser tests for the `plugin-agones` JSON shape, missing resources, duplicate keys, and invalid scopes. -- [ ] Write failing discovery tests asserting that two active origins register two manifests and an origin with no resource is skipped. -- [ ] Add Jackson JSON dependencies to `common/build.gradle.kts`; implement the shared parser and `PermissionManifestCollector.collect(origins)` using `META-INF/grounds/permissions.json`. -- [ ] In Velocity map `PluginManager.getPlugins()` containers with instances to origins; in Minestom map `ctx.activeModuleProviders` to origins. -- [ ] Add a Minestom `PermissionCatalogClient` backed by the existing gRPC channel pattern; register each collected manifest off the server thread with five bounded retry attempts and log final failures without aborting startup. -- [ ] Replace PR #4's self-only `META-INF/grounds-permissions.json` implementation with the standardized collector and the own `META-INF/grounds/permissions.json` manifest. -- [ ] Run `./gradlew test`, `./gradlew spotlessApply`, and `./gradlew build`. -- [ ] Commit `feat: collect permission manifests` and push the existing PR branch. diff --git a/docs/superpowers/specs/2026-07-09-permission-manifest-collector-design.md b/docs/superpowers/specs/2026-07-09-permission-manifest-collector-design.md deleted file mode 100644 index dfcebd6..0000000 --- a/docs/superpowers/specs/2026-07-09-permission-manifest-collector-design.md +++ /dev/null @@ -1,33 +0,0 @@ -# Permission Manifest Collector Design - -## Goal - -The permissions plugin or module collects manifests from every active runtime component and registers the entries with service-permissions so Portal displays runtime-owned permission nodes. - -## Manifest Contract - -Every participating artifact places one JSON resource at `META-INF/grounds/permissions.json`. - -```json -{ - "source": "plugin-agones", - "permissions": [ - { - "key": "grounds.command.agones", - "label": "Use Agones command", - "description": "Allows using the Agones command.", - "supportedScopes": ["GLOBAL", "SERVER_TYPE", "SERVER"] - } - ] -} -``` - -The collector skips missing resources. It reports malformed JSON, duplicate keys, blank required fields, and entries without scopes without stopping the server. - -## Discovery - -Velocity enumerates active `PluginContainer` instances through `PluginManager.getPlugins()` and reads the resource from each plugin instance classloader. Minestom exposes the provider classloaders for modules selected and installed by `GroundsServer`; discovered but unselected providers are excluded. - -## Registration - -The collector sends one `RegisterPermissionManifest` request per valid manifest using the manifest source, component version, and runtime scope. It retries transient failures five times with increasing delays. `service-permissions` persists runtime entries with `custom = false`; Portal continues to use its existing catalog endpoint.