1.0
This commit is contained in:
parent
fca823328b
commit
9bab7d9c2c
103
README.md
Normal file
103
README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# permits-lib
|
||||||
|
|
||||||
|
Permits Lib provides a declarative way to describe Bukkit/Paper permission hierarchies using a Kotlin
|
||||||
|
DSL. Once a tree is built you can hand it to `PermissionRegistry` (or the higher-level
|
||||||
|
`MutationSession`) and the library will register/unregister the relevant `org.bukkit.permissions.Permission`
|
||||||
|
instances and keep `PermissionAttachment`s in sync.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class ExamplePlugin : JavaPlugin() {
|
||||||
|
private val permits = PermitsLib.session(this)
|
||||||
|
|
||||||
|
override fun onEnable() {
|
||||||
|
val tree = permissionTree("example") {
|
||||||
|
node("command") {
|
||||||
|
description = "Access to all example commands"
|
||||||
|
defaultValue = PermissionDefault.OP
|
||||||
|
|
||||||
|
node("reload") {
|
||||||
|
description = "Allows /example reload (permission example.command.reload)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to a helper node defined elsewhere under the command branch:
|
||||||
|
child("helper")
|
||||||
|
|
||||||
|
// Link to a permission outside the current branch by using the absolute helper:
|
||||||
|
childAbsolute("tools.repair")
|
||||||
|
|
||||||
|
node("cooldown") {
|
||||||
|
description = "Allows /example cooldown tweaks"
|
||||||
|
wildcard = false // opt-out if you do not want example.command.* to include it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node("command.helper") {
|
||||||
|
description = "Allows /example helper (referenced via child(\"helper\"))"
|
||||||
|
}
|
||||||
|
|
||||||
|
node("tools.repair") {
|
||||||
|
description = "Allows /example tools repair (linked with childAbsolute)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
permits.applyTree(tree)
|
||||||
|
|
||||||
|
// The tree above materializes as permissions such as:
|
||||||
|
// example.command, example.command.reload, example.command.helper, example.command.cooldown,
|
||||||
|
// example.tools.repair,
|
||||||
|
// plus the auto-generated example.command.* wildcard that references every child (visible if you
|
||||||
|
// export to plugin.yml or inspect Bukkit's /permissions output).
|
||||||
|
|
||||||
|
configureRuntimePermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun grantHelper(player: Player) {
|
||||||
|
permits.attachments.grant(player, PermissionId.of("example.command.reload"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureRuntimePermissions() {
|
||||||
|
// Later in runtime you can mutate the previously applied structure without rebuilding it:
|
||||||
|
permits.edit("example") {
|
||||||
|
// Update an existing node and link it to new children
|
||||||
|
node("command") {
|
||||||
|
description = "Admins for every command path"
|
||||||
|
node("debug") {
|
||||||
|
description = "Allows /example debug"
|
||||||
|
defaultValue = PermissionDefault.OP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove deprecated permissions entirely
|
||||||
|
remove("command.cooldown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concepts
|
||||||
|
|
||||||
|
- **Permission tree** – immutable graph of `PermissionNode`s. Nodes specify description, default value,
|
||||||
|
boolean children map, optional tags, and the `wildcard` flag (enabled by default) that makes the library
|
||||||
|
create/update `namespace.path.*` aggregate permissions automatically.
|
||||||
|
- **DSL** – `permissionTree("namespace") { ... }` ensures consistent prefixes and validation (no cycles).
|
||||||
|
- **Nested nodes** – `node("command") { node("reload") { ... } }` automatically produces
|
||||||
|
`namespace.command` and `namespace.command.reload` plus wires the parent/child relationship so you don't
|
||||||
|
have to repeat the full id.
|
||||||
|
- **Flexible references** – `child("reload")`, `node("command") { node("reload") { ... } }`, or
|
||||||
|
even `node("command.reload")` inside `edit` all resolve to the same node; children are auto-created on
|
||||||
|
first reference but you can demand explicit nodes by adding a `node` block later, and you can unlink
|
||||||
|
specific children via `node("command") { removeChild("cooldown") }` without deleting the underlying node.
|
||||||
|
Nested `child(...)` calls are relative to the current node by default, while `childAbsolute(...)` lets you
|
||||||
|
point at any fully-qualified permission ID within the namespace.
|
||||||
|
- **PermissionRegistry** – calculates a diff between snapshots and performs the minimum additions,
|
||||||
|
removals, or updates via Bukkit's `PluginManager`.
|
||||||
|
- **Wildcards** – with `wildcard = true`, the generated `namespace.command.*` child always exists and stays
|
||||||
|
in sync so granting `example.command.*` automatically grants every nested node; set it to `false` to opt
|
||||||
|
out for specific permissions.
|
||||||
|
- **Mutable edits** – `permits.edit { ... }` clones the currently registered tree, lets you mutate nodes
|
||||||
|
imperatively, re-validates, and only pushes the structural diff to Bukkit.
|
||||||
|
- **AttachmentSynchronizer** – manages identity-based `PermissionAttachment`s and exposes high-level
|
||||||
|
helpers (`grant`, `revoke`, `applyPatch`).
|
||||||
|
- **MutationSession** – ties everything together for plugins that just want to push new trees and manage
|
||||||
|
attachments without worrying about the lower-level services.
|
||||||
|
|
@ -1,14 +1,44 @@
|
||||||
package net.hareworks.permits_lib;
|
package net.hareworks.permits_lib
|
||||||
|
|
||||||
|
import net.hareworks.permits_lib.bukkit.MutationSession
|
||||||
|
import net.hareworks.permits_lib.dsl.permissionTree
|
||||||
|
import org.bukkit.permissions.PermissionDefault
|
||||||
import org.bukkit.plugin.java.JavaPlugin
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
|
||||||
public class App : JavaPlugin() {
|
class App : JavaPlugin() {
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var instance: App
|
lateinit var instance: App
|
||||||
private set
|
private set
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEnable() {
|
private lateinit var session: MutationSession
|
||||||
instance = this
|
|
||||||
}
|
override fun onEnable() {
|
||||||
|
instance = this
|
||||||
|
session = MutationSession.create(this)
|
||||||
|
|
||||||
|
val baseTree = permissionTree("permits-lib") {
|
||||||
|
node("command") {
|
||||||
|
description = "Allows execution of all permits-lib commands."
|
||||||
|
defaultValue = PermissionDefault.OP
|
||||||
|
|
||||||
|
node("reload") {
|
||||||
|
description = "Allows /permitslib reload."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.applyTree(baseTree)
|
||||||
|
|
||||||
|
// Example of mutating the existing tree without rebuilding everything.
|
||||||
|
session.edit {
|
||||||
|
node("command") {
|
||||||
|
node("debug") {
|
||||||
|
description = "Allows /permitslib debug utilities."
|
||||||
|
defaultValue = PermissionDefault.OP
|
||||||
|
wildcard = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,8 @@
|
||||||
package net.hareworks.permits_lib;
|
package net.hareworks.permits_lib
|
||||||
|
|
||||||
|
import net.hareworks.permits_lib.bukkit.MutationSession
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
|
||||||
|
object PermitsLib {
|
||||||
|
fun session(plugin: JavaPlugin): MutationSession = MutationSession.create(plugin)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package net.hareworks.permits_lib.bukkit
|
||||||
|
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a set of attachment changes to be applied to a [Permissible].
|
||||||
|
* `true`/`false` represent forced grant/deny, while `null` removes the override.
|
||||||
|
*/
|
||||||
|
data class AttachmentPatch(
|
||||||
|
val changes: Map<PermissionId, Boolean?>
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = AttachmentPatch(emptyMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package net.hareworks.permits_lib.bukkit
|
||||||
|
|
||||||
|
import java.util.IdentityHashMap
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionId
|
||||||
|
import net.hareworks.permits_lib.util.ThreadChecks
|
||||||
|
import org.bukkit.permissions.PermissionAttachment
|
||||||
|
import org.bukkit.permissions.Permissible
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages [PermissionAttachment] instances per [Permissible], applying patches and cleaning up once no
|
||||||
|
* overrides remain.
|
||||||
|
*/
|
||||||
|
class AttachmentSynchronizer(
|
||||||
|
private val plugin: JavaPlugin
|
||||||
|
) {
|
||||||
|
private data class AttachmentHandle(
|
||||||
|
val attachment: PermissionAttachment,
|
||||||
|
val overrides: MutableMap<PermissionId, Boolean> = linkedMapOf()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val handles = IdentityHashMap<Permissible, AttachmentHandle>()
|
||||||
|
|
||||||
|
fun applyPatch(permissible: Permissible, patch: AttachmentPatch) {
|
||||||
|
ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.applyPatch")
|
||||||
|
if (patch.changes.isEmpty()) return
|
||||||
|
val handle = ensureHandle(permissible)
|
||||||
|
patch.changes.forEach { (id, value) ->
|
||||||
|
if (value == null) {
|
||||||
|
handle.overrides.remove(id)
|
||||||
|
handle.attachment.unsetPermission(id.value)
|
||||||
|
} else {
|
||||||
|
handle.overrides[id] = value
|
||||||
|
handle.attachment.setPermission(id.value, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (handle.overrides.isEmpty()) {
|
||||||
|
release(permissible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun grant(permissible: Permissible, permission: PermissionId, value: Boolean = true) {
|
||||||
|
applyPatch(permissible, AttachmentPatch(mapOf(permission to value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revoke(permissible: Permissible, permission: PermissionId) {
|
||||||
|
applyPatch(permissible, AttachmentPatch(mapOf(permission to null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(permissible: Permissible) {
|
||||||
|
ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.clear")
|
||||||
|
handles.remove(permissible)?.attachment?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.clearAll")
|
||||||
|
handles.values.forEach { it.attachment.remove() }
|
||||||
|
handles.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureHandle(permissible: Permissible): AttachmentHandle =
|
||||||
|
handles[permissible] ?: AttachmentHandle(permissible.addAttachment(plugin)).also {
|
||||||
|
handles[permissible] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun release(permissible: Permissible) {
|
||||||
|
handles.remove(permissible)?.attachment?.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package net.hareworks.permits_lib.bukkit
|
||||||
|
|
||||||
|
import net.hareworks.permits_lib.domain.MutablePermissionTree
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionTree
|
||||||
|
import net.hareworks.permits_lib.domain.TreeDiff
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level façade that ties the registry and attachment synchronizer together.
|
||||||
|
*/
|
||||||
|
class MutationSession(
|
||||||
|
private val registry: PermissionRegistry,
|
||||||
|
val attachments: AttachmentSynchronizer
|
||||||
|
) {
|
||||||
|
private var tree: PermissionTree? = null
|
||||||
|
private var diff: TreeDiff? = null
|
||||||
|
|
||||||
|
fun applyTree(next: PermissionTree): TreeDiff {
|
||||||
|
val computed = registry.applyTree(next)
|
||||||
|
tree = next
|
||||||
|
diff = computed
|
||||||
|
return computed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutates the currently applied tree (must exist) and immediately applies the resulting diff to the
|
||||||
|
* Bukkit registry.
|
||||||
|
*/
|
||||||
|
fun edit(block: MutablePermissionTree.() -> Unit): TreeDiff {
|
||||||
|
val base = tree ?: error("No permission tree applied yet. Call applyTree or edit(namespace) first.")
|
||||||
|
return editInternal(MutablePermissionTree.from(base), block)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutates the existing tree or creates a fresh one for the provided [namespace] when none was applied
|
||||||
|
* before.
|
||||||
|
*/
|
||||||
|
fun edit(namespace: String, block: MutablePermissionTree.() -> Unit): TreeDiff {
|
||||||
|
val mutable = tree?.let {
|
||||||
|
require(it.namespace == namespace) {
|
||||||
|
"Existing tree namespace '${it.namespace}' differs from requested '$namespace'."
|
||||||
|
}
|
||||||
|
MutablePermissionTree.from(it)
|
||||||
|
} ?: MutablePermissionTree.create(namespace)
|
||||||
|
return editInternal(mutable, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun editInternal(
|
||||||
|
mutable: MutablePermissionTree,
|
||||||
|
block: MutablePermissionTree.() -> Unit
|
||||||
|
): TreeDiff {
|
||||||
|
mutable.block()
|
||||||
|
val next = mutable.build()
|
||||||
|
return applyTree(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
registry.clear()
|
||||||
|
attachments.clearAll()
|
||||||
|
tree = null
|
||||||
|
diff = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentTree(): PermissionTree? = tree
|
||||||
|
fun lastDiff(): TreeDiff? = diff
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(plugin: org.bukkit.plugin.java.JavaPlugin): MutationSession =
|
||||||
|
MutationSession(
|
||||||
|
registry = PermissionRegistry(plugin),
|
||||||
|
attachments = AttachmentSynchronizer(plugin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package net.hareworks.permits_lib.bukkit
|
||||||
|
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionNode
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionTree
|
||||||
|
import net.hareworks.permits_lib.domain.TreeDiff
|
||||||
|
import net.hareworks.permits_lib.domain.TreeDiffer
|
||||||
|
import net.hareworks.permits_lib.domain.TreeSnapshot
|
||||||
|
import net.hareworks.permits_lib.util.ThreadChecks
|
||||||
|
import org.bukkit.permissions.Permission
|
||||||
|
import org.bukkit.plugin.PluginManager
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers permission nodes against Bukkit's [PluginManager], keeping track of previous state so that
|
||||||
|
* only diffs are applied back to the server.
|
||||||
|
*/
|
||||||
|
class PermissionRegistry(
|
||||||
|
private val plugin: JavaPlugin,
|
||||||
|
private val pluginManager: PluginManager = plugin.server.pluginManager
|
||||||
|
) {
|
||||||
|
private var snapshot: TreeSnapshot? = null
|
||||||
|
|
||||||
|
fun applyTree(tree: PermissionTree): TreeDiff {
|
||||||
|
ThreadChecks.ensurePrimaryThread("PermissionRegistry.applyTree")
|
||||||
|
|
||||||
|
val nextSnapshot = tree.toSnapshot()
|
||||||
|
val diff = TreeDiffer.diff(snapshot, nextSnapshot)
|
||||||
|
if (!diff.hasChanges) {
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
diff.removed.forEach { removeNode(it) }
|
||||||
|
diff.added.forEach { registerNode(it) }
|
||||||
|
diff.updated.forEach { updateNode(it) }
|
||||||
|
|
||||||
|
snapshot = nextSnapshot
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
ThreadChecks.ensurePrimaryThread("PermissionRegistry.clear")
|
||||||
|
snapshot?.nodes?.values?.forEach { removeNode(it) }
|
||||||
|
snapshot = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeNode(node: PermissionNode) {
|
||||||
|
pluginManager.getPermission(node.id.value)?.let { permission ->
|
||||||
|
pluginManager.removePermission(permission)
|
||||||
|
permission.recalculatePermissibles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerNode(node: PermissionNode) {
|
||||||
|
val permission = Permission(node.id.value, node.description, node.defaultValue)
|
||||||
|
permission.children.clear()
|
||||||
|
permission.children.putAll(node.children.mapKeys { it.key.value })
|
||||||
|
pluginManager.addPermission(permission)
|
||||||
|
permission.recalculatePermissibles()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNode(updated: TreeDiff.UpdatedNode) {
|
||||||
|
removeNode(updated.before)
|
||||||
|
registerNode(updated.after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
import org.bukkit.permissions.PermissionDefault
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative view over a permission tree that lets callers mutate nodes directly. Once the desired
|
||||||
|
* modifications are complete, call [build] to obtain an immutable [PermissionTree].
|
||||||
|
*/
|
||||||
|
class MutablePermissionTree internal constructor(
|
||||||
|
private val namespace: String,
|
||||||
|
private val drafts: MutableMap<PermissionId, PermissionNodeDraft>
|
||||||
|
) {
|
||||||
|
fun node(id: String, block: MutableNode.() -> Unit = {}): MutableNode {
|
||||||
|
val permissionId = PermissionId.of(qualify(id))
|
||||||
|
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
|
||||||
|
return MutableNode(permissionId, draft).apply(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(id: String) {
|
||||||
|
val permissionId = PermissionId.of(qualify(id))
|
||||||
|
drafts.remove(permissionId)
|
||||||
|
drafts.values.forEach { it.children.remove(permissionId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contains(id: String): Boolean = drafts.containsKey(PermissionId.of(qualify(id)))
|
||||||
|
|
||||||
|
fun build(): PermissionTree {
|
||||||
|
val nodes = drafts.mapValues { it.value.toNode() }
|
||||||
|
return PermissionTree.from(namespace, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun qualify(id: String): String =
|
||||||
|
if (id.startsWith(namespace)) id else "$namespace.$id"
|
||||||
|
|
||||||
|
private fun qualifyRelative(parent: PermissionId, childSegment: String): String {
|
||||||
|
val normalized = childSegment.trim().lowercase().trimStart('.')
|
||||||
|
require(normalized.isNotEmpty()) { "Child id must not be blank." }
|
||||||
|
return if (normalized.startsWith(namespace)) {
|
||||||
|
normalized
|
||||||
|
} else {
|
||||||
|
"${parent.value}.$normalized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class MutableNode internal constructor(
|
||||||
|
val id: PermissionId,
|
||||||
|
private val draft: PermissionNodeDraft
|
||||||
|
) {
|
||||||
|
var description: String?
|
||||||
|
get() = draft.description
|
||||||
|
set(value) {
|
||||||
|
draft.description = value?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultValue: PermissionDefault
|
||||||
|
get() = draft.defaultValue
|
||||||
|
set(value) {
|
||||||
|
draft.defaultValue = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var wildcard: Boolean
|
||||||
|
get() = draft.wildcard
|
||||||
|
set(value) {
|
||||||
|
draft.wildcard = value
|
||||||
|
}
|
||||||
|
|
||||||
|
val tags: MutableSet<String>
|
||||||
|
get() = draft.tags
|
||||||
|
|
||||||
|
fun tag(value: String) {
|
||||||
|
if (value.isNotBlank()) {
|
||||||
|
draft.tags += value.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun child(id: String, value: Boolean = true) {
|
||||||
|
val permissionId = PermissionId.of(qualifyRelative(this.id, id))
|
||||||
|
draft.children[permissionId] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun childAbsolute(id: String, value: Boolean = true) {
|
||||||
|
val permissionId = PermissionId.of(qualify(id))
|
||||||
|
draft.children[permissionId] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun node(id: String, block: MutableNode.() -> Unit = {}) {
|
||||||
|
val permissionId = PermissionId.of(qualifyRelative(this.id, id))
|
||||||
|
draft.children[permissionId] = true
|
||||||
|
val childDraft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
|
||||||
|
MutableNode(permissionId, childDraft).apply(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeChild(id: String) {
|
||||||
|
draft.children.remove(PermissionId.of(qualify(id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(namespace: String): MutablePermissionTree =
|
||||||
|
MutablePermissionTree(namespace.trim().lowercase(), linkedMapOf())
|
||||||
|
|
||||||
|
fun from(tree: PermissionTree): MutablePermissionTree =
|
||||||
|
MutablePermissionTree(
|
||||||
|
namespace = tree.namespace,
|
||||||
|
drafts = tree.nodes.mapValues { PermissionNodeDraft.from(it.value) }.toMutableMap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value object that represents a normalized Bukkit permission node identifier.
|
||||||
|
*
|
||||||
|
* The constructor is private to ensure every instance passes through [of] where we enforce the
|
||||||
|
* naming constraints (lowercase alphanumeric with dots/dashes/underscores) and drop leading/trailing
|
||||||
|
* whitespace.
|
||||||
|
*/
|
||||||
|
@JvmInline
|
||||||
|
value class PermissionId private constructor(val value: String) {
|
||||||
|
override fun toString(): String = value
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val VALID_PATTERN = Regex("""^[a-z0-9_.-]+$""")
|
||||||
|
|
||||||
|
fun of(raw: String): PermissionId {
|
||||||
|
val normalized = raw.trim().lowercase()
|
||||||
|
require(normalized.isNotEmpty()) { "Permission id must not be blank." }
|
||||||
|
require(VALID_PATTERN.matches(normalized)) {
|
||||||
|
"Permission id '$raw' must match ${VALID_PATTERN.pattern}"
|
||||||
|
}
|
||||||
|
return PermissionId(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
import org.bukkit.permissions.PermissionDefault
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable description of a permission node in the tree.
|
||||||
|
*
|
||||||
|
* The [children] boolean flag behaves like Bukkit's `Permission.children` where `true` propagates a
|
||||||
|
* grant while `false` explicitly revokes.
|
||||||
|
*/
|
||||||
|
data class PermissionNode(
|
||||||
|
val id: PermissionId,
|
||||||
|
val description: String? = null,
|
||||||
|
val defaultValue: PermissionDefault = PermissionDefault.FALSE,
|
||||||
|
val children: Map<PermissionId, Boolean> = emptyMap(),
|
||||||
|
val tags: Set<String> = emptySet(),
|
||||||
|
val wildcard: Boolean = true
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(children.keys.none { it == id }) { "Permission node cannot be a child of itself." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
import org.bukkit.permissions.PermissionDefault
|
||||||
|
|
||||||
|
internal data class PermissionNodeDraft(
|
||||||
|
val id: PermissionId,
|
||||||
|
var description: String? = null,
|
||||||
|
var defaultValue: PermissionDefault = PermissionDefault.FALSE,
|
||||||
|
val children: MutableMap<PermissionId, Boolean> = linkedMapOf(),
|
||||||
|
val tags: MutableSet<String> = linkedSetOf(),
|
||||||
|
var wildcard: Boolean = true
|
||||||
|
) {
|
||||||
|
fun toNode(): PermissionNode =
|
||||||
|
PermissionNode(
|
||||||
|
id = id,
|
||||||
|
description = description,
|
||||||
|
defaultValue = defaultValue,
|
||||||
|
children = children.toMap(),
|
||||||
|
tags = tags.toSet(),
|
||||||
|
wildcard = wildcard
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(node: PermissionNode): PermissionNodeDraft =
|
||||||
|
PermissionNodeDraft(
|
||||||
|
id = node.id,
|
||||||
|
description = node.description,
|
||||||
|
defaultValue = node.defaultValue,
|
||||||
|
children = node.children.toMutableMap(),
|
||||||
|
tags = node.tags.toMutableSet(),
|
||||||
|
wildcard = node.wildcard
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable aggregate of permission nodes.
|
||||||
|
*/
|
||||||
|
class PermissionTree internal constructor(
|
||||||
|
val namespace: String,
|
||||||
|
internal val nodes: Map<PermissionId, PermissionNode>
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(namespace.isNotBlank()) { "Permission namespace must not be blank." }
|
||||||
|
}
|
||||||
|
|
||||||
|
val size: Int get() = nodes.size
|
||||||
|
|
||||||
|
operator fun get(id: PermissionId): PermissionNode? = nodes[id]
|
||||||
|
|
||||||
|
fun toSnapshot(): TreeSnapshot = TreeSnapshot(nodes)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun empty(namespace: String): PermissionTree = PermissionTree(namespace, emptyMap())
|
||||||
|
|
||||||
|
fun from(namespace: String, rawNodes: Map<PermissionId, PermissionNode>): PermissionTree {
|
||||||
|
val augmented = WildcardAugmentor.apply(rawNodes)
|
||||||
|
PermissionTreeValidator.validate(augmented)
|
||||||
|
return PermissionTree(namespace, augmented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
internal object PermissionTreeValidator {
|
||||||
|
fun validate(nodes: Map<PermissionId, PermissionNode>) {
|
||||||
|
checkForCycles(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkForCycles(nodes: Map<PermissionId, PermissionNode>) {
|
||||||
|
val visiting = mutableSetOf<PermissionId>()
|
||||||
|
val visited = mutableSetOf<PermissionId>()
|
||||||
|
|
||||||
|
fun dfs(id: PermissionId) {
|
||||||
|
if (!visiting.add(id)) {
|
||||||
|
error("Detected cycle that includes permission '${id.value}'")
|
||||||
|
}
|
||||||
|
val node = nodes[id] ?: return
|
||||||
|
for (child in node.children.keys) {
|
||||||
|
if (child !in visited) {
|
||||||
|
dfs(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visiting.remove(id)
|
||||||
|
visited.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.keys.forEach { id ->
|
||||||
|
if (id !in visited) {
|
||||||
|
dfs(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiff.kt
Normal file
12
src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiff.kt
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
data class TreeDiff(
|
||||||
|
val added: List<PermissionNode>,
|
||||||
|
val removed: List<PermissionNode>,
|
||||||
|
val updated: List<UpdatedNode>
|
||||||
|
) {
|
||||||
|
val hasChanges: Boolean
|
||||||
|
get() = added.isNotEmpty() || removed.isNotEmpty() || updated.isNotEmpty()
|
||||||
|
|
||||||
|
data class UpdatedNode(val before: PermissionNode, val after: PermissionNode)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
object TreeDiffer {
|
||||||
|
fun diff(previous: TreeSnapshot?, next: TreeSnapshot): TreeDiff {
|
||||||
|
val prevNodes = previous?.nodes.orEmpty()
|
||||||
|
val nextNodes = next.nodes
|
||||||
|
|
||||||
|
val added = mutableListOf<PermissionNode>()
|
||||||
|
val removed = mutableListOf<PermissionNode>()
|
||||||
|
val updated = mutableListOf<TreeDiff.UpdatedNode>()
|
||||||
|
|
||||||
|
val allKeys = (prevNodes.keys + nextNodes.keys).toSet()
|
||||||
|
for (key in allKeys) {
|
||||||
|
val before = prevNodes[key]
|
||||||
|
val after = nextNodes[key]
|
||||||
|
when {
|
||||||
|
before == null && after != null -> added += after
|
||||||
|
before != null && after == null -> removed += before
|
||||||
|
before != null && after != null && before != after -> updated += TreeDiff.UpdatedNode(before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TreeDiff(
|
||||||
|
added = added.sortedBy { it.id.value },
|
||||||
|
removed = removed.sortedBy { it.id.value },
|
||||||
|
updated = updated.sortedBy { it.after.id.value }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of a tree at a specific point in time. Holds a deterministic digest useful for caching.
|
||||||
|
*/
|
||||||
|
class TreeSnapshot internal constructor(
|
||||||
|
internal val nodes: Map<PermissionId, PermissionNode>
|
||||||
|
) {
|
||||||
|
val digest: String = computeDigest(nodes)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val EMPTY = TreeSnapshot(emptyMap())
|
||||||
|
|
||||||
|
private fun computeDigest(nodes: Map<PermissionId, PermissionNode>): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
nodes.entries
|
||||||
|
.sortedBy { it.key.value }
|
||||||
|
.forEach { (id, node) ->
|
||||||
|
digest.update(id.value.toByteArray())
|
||||||
|
digest.update(node.description.orEmpty().toByteArray())
|
||||||
|
digest.update(node.defaultValue.name.toByteArray())
|
||||||
|
node.children.toSortedMap(compareBy { it.value }).forEach { (childId, flag) ->
|
||||||
|
digest.update(childId.value.toByteArray())
|
||||||
|
digest.update(if (flag) 1 else 0)
|
||||||
|
}
|
||||||
|
node.tags.sorted().forEach { tag ->
|
||||||
|
digest.update(tag.toByteArray())
|
||||||
|
}
|
||||||
|
digest.update(if (node.wildcard) 1 else 0)
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package net.hareworks.permits_lib.domain
|
||||||
|
|
||||||
|
import org.bukkit.permissions.PermissionDefault
|
||||||
|
|
||||||
|
internal object WildcardAugmentor {
|
||||||
|
fun apply(nodes: Map<PermissionId, PermissionNode>): Map<PermissionId, PermissionNode> {
|
||||||
|
if (nodes.isEmpty()) return nodes
|
||||||
|
val result = nodes.toMutableMap()
|
||||||
|
|
||||||
|
nodes.values.forEach { node ->
|
||||||
|
if (!node.wildcard) return@forEach
|
||||||
|
if (node.id.value.endsWith(".*")) return@forEach
|
||||||
|
|
||||||
|
val wildcardId = parentWildcardId(node.id) ?: return@forEach
|
||||||
|
val existing = result[wildcardId]
|
||||||
|
val updatedChildren = (existing?.children ?: emptyMap()).toMutableMap()
|
||||||
|
val alreadyPresent = updatedChildren[node.id] == true
|
||||||
|
if (!alreadyPresent) {
|
||||||
|
updatedChildren[node.id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
result[wildcardId] = PermissionNode(
|
||||||
|
id = wildcardId,
|
||||||
|
description = "Wildcard for ${wildcardId.value}",
|
||||||
|
defaultValue = node.defaultValue,
|
||||||
|
children = updatedChildren,
|
||||||
|
tags = setOf("wildcard"),
|
||||||
|
wildcard = false
|
||||||
|
)
|
||||||
|
} else if (!alreadyPresent) {
|
||||||
|
result[wildcardId] = existing.copy(children = updatedChildren)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parentWildcardId(id: PermissionId): PermissionId? {
|
||||||
|
val value = id.value
|
||||||
|
val lastDot = value.lastIndexOf('.')
|
||||||
|
if (lastDot <= 0) return null
|
||||||
|
val parent = value.substring(0, lastDot)
|
||||||
|
return PermissionId.of("$parent.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package net.hareworks.permits_lib.dsl
|
||||||
|
|
||||||
|
@DslMarker
|
||||||
|
annotation class PermissionDsl
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package net.hareworks.permits_lib.dsl
|
||||||
|
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionId
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionNodeDraft
|
||||||
|
import org.bukkit.permissions.PermissionDefault
|
||||||
|
|
||||||
|
@PermissionDsl
|
||||||
|
class PermissionNodeBuilder internal constructor(
|
||||||
|
private val treeBuilder: PermissionTreeBuilder,
|
||||||
|
private val draft: PermissionNodeDraft
|
||||||
|
) {
|
||||||
|
var description: String?
|
||||||
|
get() = draft.description
|
||||||
|
set(value) {
|
||||||
|
draft.description = value?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultValue: PermissionDefault
|
||||||
|
get() = draft.defaultValue
|
||||||
|
set(value) {
|
||||||
|
draft.defaultValue = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var wildcard: Boolean
|
||||||
|
get() = draft.wildcard
|
||||||
|
set(value) {
|
||||||
|
draft.wildcard = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tag(value: String) {
|
||||||
|
if (value.isNotBlank()) {
|
||||||
|
draft.tags += value.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun child(id: String, value: Boolean = true) {
|
||||||
|
treeBuilder.childRelative(draft, id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun child(id: PermissionId, value: Boolean = true) {
|
||||||
|
treeBuilder.childAbsolute(draft, id.value, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun childAbsolute(id: String, value: Boolean = true) {
|
||||||
|
treeBuilder.childAbsolute(draft, id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a nested node whose id is derived from the current node:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* node("command") {
|
||||||
|
* node("reload") { ... } // -> namespace.command.reload
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun node(id: String, block: PermissionNodeBuilder.() -> Unit = {}) {
|
||||||
|
treeBuilder.nestedNode(draft, id, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package net.hareworks.permits_lib.dsl
|
||||||
|
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionId
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionNodeDraft
|
||||||
|
import net.hareworks.permits_lib.domain.PermissionTree
|
||||||
|
|
||||||
|
@PermissionDsl
|
||||||
|
class PermissionTreeBuilder internal constructor(
|
||||||
|
private val namespace: String
|
||||||
|
) {
|
||||||
|
private val drafts = linkedMapOf<PermissionId, PermissionNodeDraft>()
|
||||||
|
|
||||||
|
fun node(id: String, block: PermissionNodeBuilder.() -> Unit = {}) {
|
||||||
|
val permissionId = PermissionId.of(qualify(id))
|
||||||
|
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
|
||||||
|
PermissionNodeBuilder(this, draft).apply(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun qualify(id: String): String =
|
||||||
|
if (id.startsWith(namespace)) id else "$namespace.$id"
|
||||||
|
|
||||||
|
internal fun child(
|
||||||
|
parent: PermissionNodeDraft,
|
||||||
|
id: String,
|
||||||
|
value: Boolean,
|
||||||
|
relative: Boolean
|
||||||
|
) {
|
||||||
|
val permissionId = PermissionId.of(if (relative) qualifyRelative(parent.id, id) else qualify(id))
|
||||||
|
parent.children[permissionId] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun childRelative(
|
||||||
|
parent: PermissionNodeDraft,
|
||||||
|
id: String,
|
||||||
|
value: Boolean
|
||||||
|
) = child(parent, id, value, relative = true)
|
||||||
|
|
||||||
|
internal fun childAbsolute(
|
||||||
|
parent: PermissionNodeDraft,
|
||||||
|
id: String,
|
||||||
|
value: Boolean
|
||||||
|
) = child(parent, id, value, relative = false)
|
||||||
|
|
||||||
|
internal fun nestedNode(
|
||||||
|
parent: PermissionNodeDraft,
|
||||||
|
id: String,
|
||||||
|
block: PermissionNodeBuilder.() -> Unit
|
||||||
|
) {
|
||||||
|
val permissionId = PermissionId.of(qualifyRelative(parent.id, id))
|
||||||
|
parent.children[permissionId] = true
|
||||||
|
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
|
||||||
|
PermissionNodeBuilder(this, draft).apply(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(): PermissionTree =
|
||||||
|
PermissionTree.from(namespace, drafts.mapValues { it.value.toNode() })
|
||||||
|
|
||||||
|
private fun qualifyRelative(parent: PermissionId, childSegment: String): String {
|
||||||
|
val normalized = childSegment.trim().lowercase().trimStart('.')
|
||||||
|
require(normalized.isNotEmpty()) { "Child id must not be blank." }
|
||||||
|
return if (normalized.startsWith(namespace)) {
|
||||||
|
normalized
|
||||||
|
} else {
|
||||||
|
"${parent.value}.$normalized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun permissionTree(namespace: String, block: PermissionTreeBuilder.() -> Unit): PermissionTree =
|
||||||
|
PermissionTreeBuilder(namespace.trim().lowercase()).apply(block).build()
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package net.hareworks.permits_lib.util
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit
|
||||||
|
|
||||||
|
internal object ThreadChecks {
|
||||||
|
fun ensurePrimaryThread(action: String) {
|
||||||
|
check(Bukkit.isPrimaryThread()) {
|
||||||
|
"$action must be invoked from the primary server thread."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user