mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
feat(concord): add owner-rooted authority resolver
Implement CORD-04 authority resolution over a folded Control Plane: - ControlEntities: Role and Grant content DTOs (kotlinx.serialization, lenient + extensible) and a Banlist array parser; ConcordJson facility - AuthorityResolver: builds roster state from the entity heads + known owner and answers rank (lower = higher; owner = 0), effectivePermissions (union of a member's roles), isBanned, hasPermission, and canActOn (holds the bit AND strictly outranks the target — equal cannot act on equal; owner unremovable) Grants are validated by an owner-rooted fixpoint: a Grant is honored only when its signer already outranks every assigned Role and holds MANAGE_ROLES, so the roster grows strictly outward from the owner and self-referential cycles never bootstrap. Deleted/position-0 roles and banned members are dropped. Banlists heal to their union. Tests cover owner-rooted ranks/permissions, fixpoint order-independence, unauthorized/insufficient-rank grant rejection, ban vanishing, and invalid-role dropping. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
/**
|
||||
* Resolves the owner-rooted authority state of a Concord community from its
|
||||
* folded Control Plane (CORD-04).
|
||||
*
|
||||
* "The Roster is owner-rooted: every Grant and Role is signed by an npub the
|
||||
* Roster ranks strictly above it, and the chain terminates at the owner."
|
||||
*
|
||||
* Build one with [resolve] from the current entity heads (the values of
|
||||
* [EditionFold.fold]) plus the community's known owner pubkey. It then answers:
|
||||
* - [rank] — a member's authority (lower is higher; owner is [OWNER_RANK]; a
|
||||
* member with no validly-granted role has no rank).
|
||||
* - [effectivePermissions] — the union of a member's roles' bits (owner: all).
|
||||
* - [isBanned] — membership in the healed banlist union.
|
||||
* - [canActOn] — whether an actor may take a permissioned action on a target:
|
||||
* the actor must hold the bit, must strictly outrank the target (equal cannot
|
||||
* act on equal), and the owner is unremovable.
|
||||
*
|
||||
* Grants are validated by a fixpoint that only ever empowers members reachable
|
||||
* from the owner: a Grant is honored when its signer already outranks every
|
||||
* assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never
|
||||
* touch the owner can never bootstrap themselves.
|
||||
*/
|
||||
class AuthorityResolver private constructor(
|
||||
private val ownerLower: String,
|
||||
private val roles: Map<String, RoleEntity>,
|
||||
private val memberRoles: Map<String, Set<String>>,
|
||||
private val banned: Set<String>,
|
||||
) {
|
||||
fun isOwner(pubKey: String): Boolean = pubKey.lowercase() == ownerLower
|
||||
|
||||
fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned
|
||||
|
||||
/** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */
|
||||
fun rank(pubKey: String): Long? {
|
||||
val m = pubKey.lowercase()
|
||||
if (m == ownerLower) return OWNER_RANK
|
||||
val held = memberRoles[m] ?: return null
|
||||
return held.mapNotNull { roles[it]?.position }.minOrNull()
|
||||
}
|
||||
|
||||
/** The union of a member's roles' permission bits (owner holds every bit). */
|
||||
fun effectivePermissions(pubKey: String): ConcordPermissions {
|
||||
val m = pubKey.lowercase()
|
||||
if (m == ownerLower) return ConcordPermissions.ALL
|
||||
val held = memberRoles[m] ?: return ConcordPermissions.NONE
|
||||
var acc = ConcordPermissions.NONE
|
||||
for (id in held) roles[id]?.let { acc = acc union it.permissionBits() }
|
||||
return acc
|
||||
}
|
||||
|
||||
/** True if the member holds [bit] and is not banned. */
|
||||
fun hasPermission(
|
||||
pubKey: String,
|
||||
bit: Int,
|
||||
): Boolean = !isBanned(pubKey) && effectivePermissions(pubKey).has(bit)
|
||||
|
||||
/**
|
||||
* Whether [actor] may take the action guarded by permission [bit] against
|
||||
* [target]. Requires: actor not banned, actor holds [bit], the owner is never
|
||||
* a valid target (unremovable), and actor strictly outranks target — "equal
|
||||
* cannot act on equal".
|
||||
*/
|
||||
fun canActOn(
|
||||
actor: String,
|
||||
target: String,
|
||||
bit: Int,
|
||||
): Boolean {
|
||||
if (!hasPermission(actor, bit)) return false
|
||||
if (isOwner(target)) return false
|
||||
val actorRank = rank(actor) ?: return false
|
||||
val targetRank = rank(target) ?: Long.MAX_VALUE // no roles ⇒ lowest authority
|
||||
return actorRank < targetRank
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The owner's rank — supreme and unremovable. No Role may claim it. */
|
||||
const val OWNER_RANK = 0L
|
||||
|
||||
fun resolve(
|
||||
heads: Collection<ControlEdition>,
|
||||
ownerPubKey: String,
|
||||
): AuthorityResolver {
|
||||
val ownerLower = ownerPubKey.lowercase()
|
||||
|
||||
// Roles: keyed by role id (the edition entity id). Drop deleted roles
|
||||
// and any that illegally claim position 0 (owner-peer).
|
||||
val roles = HashMap<String, RoleEntity>()
|
||||
for (e in heads) {
|
||||
if (e.entityKind != ControlEntityKind.ROLE) continue
|
||||
val r = ConcordJson.decodeOrNull<RoleEntity>(e.content) ?: continue
|
||||
if (r.deleted || r.position < 1) continue
|
||||
roles[e.entityIdHex] = r
|
||||
}
|
||||
|
||||
// Grants: one head per member; remember the signing actor (granter).
|
||||
val grants =
|
||||
heads.mapNotNull { e ->
|
||||
if (e.entityKind != ControlEntityKind.GRANT) return@mapNotNull null
|
||||
val g = ConcordJson.decodeOrNull<GrantEntity>(e.content) ?: return@mapNotNull null
|
||||
ParsedGrant(g.member.lowercase(), g.roleIds, e.author.lowercase())
|
||||
}
|
||||
|
||||
// Banlist: heal to the union of every banlist head.
|
||||
val banned = HashSet<String>()
|
||||
for (e in heads) {
|
||||
if (e.entityKind != ControlEntityKind.BANLIST) continue
|
||||
ConcordJson.decodeBanlist(e.content)?.forEach { banned.add(it.lowercase()) }
|
||||
}
|
||||
|
||||
val memberRoles = HashMap<String, Set<String>>()
|
||||
|
||||
fun rankOf(member: String): Long? {
|
||||
if (member == ownerLower) return OWNER_RANK
|
||||
val held = memberRoles[member] ?: return null
|
||||
return held.mapNotNull { roles[it]?.position }.minOrNull()
|
||||
}
|
||||
|
||||
fun holdsManageRoles(member: String): Boolean {
|
||||
if (member == ownerLower) return true
|
||||
val held = memberRoles[member] ?: return false
|
||||
return held.any { roles[it]?.permissionBits()?.has(ConcordPermissions.MANAGE_ROLES) == true }
|
||||
}
|
||||
|
||||
// Fixpoint: apply grants whose signer outranks every assigned role and
|
||||
// holds MANAGE_ROLES. Only the owner is empowered a priori, so the
|
||||
// roster grows strictly outward from the owner.
|
||||
var changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (g in grants) {
|
||||
val granterRank = rankOf(g.granter) ?: continue
|
||||
if (!holdsManageRoles(g.granter)) continue
|
||||
val assigned = g.roleIds.filter { roles.containsKey(it) }
|
||||
if (assigned.any { granterRank >= roles.getValue(it).position }) continue // must strictly outrank each
|
||||
val newSet = assigned.toSet()
|
||||
if (memberRoles[g.member] != newSet) {
|
||||
memberRoles[g.member] = newSet
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthorityResolver(ownerLower, roles, memberRoles, banned)
|
||||
}
|
||||
}
|
||||
|
||||
private class ParsedGrant(
|
||||
val member: String,
|
||||
val roleIds: List<String>,
|
||||
val granter: String,
|
||||
)
|
||||
}
|
||||
+3
@@ -52,6 +52,9 @@ value class ConcordPermissions(
|
||||
companion object {
|
||||
val NONE = ConcordPermissions(0uL)
|
||||
|
||||
/** Every bit set — the owner's implicit, supreme permission set. */
|
||||
val ALL = ConcordPermissions(ULong.MAX_VALUE)
|
||||
|
||||
// Frozen bit positions (CORD-04 §Permission Bits).
|
||||
const val MANAGE_ROLES = 0
|
||||
const val MANAGE_CHANNELS = 1
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* JSON facility for Concord Control Plane entity content. Unknown keys are
|
||||
* ignored because entity shapes are deliberately client-extensible (CORD-03/04),
|
||||
* and defaults are lenient so a partial entity never throws mid-fold.
|
||||
*/
|
||||
object ConcordJson {
|
||||
val instance =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
inline fun <reified T> decodeOrNull(content: String): T? =
|
||||
try {
|
||||
instance.decodeFromString<T>(content)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Parses a Banlist edition's content (a bare JSON array of hex pubkeys). */
|
||||
fun decodeBanlist(content: String): List<String>? =
|
||||
try {
|
||||
instance.decodeFromString(ListSerializer(String.serializer()), content)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Role's content (CORD-04): a named bundle of permissions at a [position].
|
||||
* The role's id is the edition's entity id, not a content field. Lower [position]
|
||||
* ranks higher; no role may claim position 0 (reserved for the owner).
|
||||
*/
|
||||
@Serializable
|
||||
class RoleEntity(
|
||||
val name: String = "",
|
||||
val position: Long = 0,
|
||||
/** u64 permission bitfield as a decimal string. */
|
||||
val permissions: String = "0",
|
||||
/** "server"-wide, or a channel id for a channel-scoped role. Null = server. */
|
||||
val scope: String? = null,
|
||||
val color: Long = 0,
|
||||
val deleted: Boolean = false,
|
||||
) {
|
||||
fun permissionBits(): ConcordPermissions = ConcordPermissions.fromWireOrNull(permissions) ?: ConcordPermissions.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* A Grant's content (CORD-04): maps a [member] to the set of [roleIds] they hold.
|
||||
* Honored only if the granting actor outranks every assigned Role and the chain
|
||||
* terminates at the owner (see [AuthorityResolver]).
|
||||
*/
|
||||
@Serializable
|
||||
class GrantEntity(
|
||||
val member: String = "",
|
||||
@SerialName("role_ids") val roleIds: List<String> = emptyList(),
|
||||
)
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.KICK
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AuthorityResolverTest {
|
||||
private val owner = "0f".repeat(32)
|
||||
private val alice = "a1".repeat(32)
|
||||
private val bob = "b2".repeat(32)
|
||||
private val carol = "c3".repeat(32)
|
||||
private val dave = "d4".repeat(32)
|
||||
|
||||
private val adminRole = "11".repeat(32)
|
||||
private val modRole = "22".repeat(32)
|
||||
|
||||
// admin: position 1, KICK|BAN|MANAGE_ROLES = 8|16|1 = 25
|
||||
private val adminJson = """{"name":"Admin","position":1,"permissions":"25"}"""
|
||||
|
||||
// mod: position 5, KICK only = 8 (no MANAGE_ROLES)
|
||||
private val modJson = """{"name":"Mod","position":5,"permissions":"8"}"""
|
||||
|
||||
private fun role(
|
||||
roleId: String,
|
||||
json: String,
|
||||
) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0)
|
||||
|
||||
private fun grant(
|
||||
grantId: String,
|
||||
member: String,
|
||||
roleIds: List<String>,
|
||||
granter: String,
|
||||
) = ControlEdition(
|
||||
ControlEntityKind.GRANT,
|
||||
grantId.hexToByteArray(),
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
|
||||
granter,
|
||||
"grant-$grantId",
|
||||
0,
|
||||
)
|
||||
|
||||
private fun banlist(vararg banned: String) =
|
||||
ControlEdition(
|
||||
ControlEntityKind.BANLIST,
|
||||
"44".repeat(32).hexToByteArray(),
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
"[${banned.joinToString(",") { "\"$it\"" }}]",
|
||||
owner,
|
||||
"ban",
|
||||
0,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun ranksPermissionsAndActionAuthorityAreOwnerRooted() {
|
||||
val heads =
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
role(modRole, modJson),
|
||||
// alice's grant appears BEFORE the owner's grant to prove fixpoint order-independence
|
||||
grant("ba".repeat(32), bob, listOf(modRole), granter = alice),
|
||||
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
|
||||
assertTrue(r.isOwner(owner))
|
||||
assertEquals(0L, r.rank(owner))
|
||||
assertEquals(1L, r.rank(alice))
|
||||
assertEquals(5L, r.rank(bob))
|
||||
assertNull(r.rank(carol)) // no grant ⇒ no authority
|
||||
|
||||
assertTrue(r.effectivePermissions(alice).has(BAN))
|
||||
assertFalse(r.effectivePermissions(bob).has(BAN))
|
||||
assertTrue(r.effectivePermissions(bob).has(KICK))
|
||||
|
||||
// Higher rank can act on lower; equal cannot act on equal; nobody on owner.
|
||||
assertTrue(r.canActOn(alice, bob, BAN))
|
||||
assertFalse(r.canActOn(bob, alice, KICK)) // lower cannot act on higher
|
||||
assertFalse(r.canActOn(alice, alice, KICK)) // equal-on-equal
|
||||
assertFalse(r.canActOn(alice, owner, BAN)) // owner unremovable
|
||||
assertTrue(r.canActOn(owner, alice, BAN)) // owner supreme
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grantsFromUnauthorizedSignersAreIgnored() {
|
||||
val heads =
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
// carol has no authority, so her grant to dave is dropped
|
||||
grant("cd".repeat(32), dave, listOf(adminRole), granter = carol),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
assertNull(r.rank(dave))
|
||||
assertEquals(ConcordPermissions.NONE.bits, r.effectivePermissions(dave).bits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun granterMustHoldManageRolesAndOutrankAssignedRole() {
|
||||
val heads =
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
role(modRole, modJson),
|
||||
grant("ab".repeat(32), alice, listOf(modRole), granter = owner), // alice is a mod (no MANAGE_ROLES)
|
||||
grant("ae".repeat(32), dave, listOf(adminRole), granter = alice), // mod cannot grant admin
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
assertEquals(5L, r.rank(alice))
|
||||
assertNull(r.rank(dave)) // rejected: alice lacks MANAGE_ROLES and doesn't outrank admin
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bannedMembersVanishFromAuthority() {
|
||||
val heads =
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
|
||||
banlist(alice),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
assertTrue(r.isBanned(alice))
|
||||
// A banned actor can take no action even though the role bit is present.
|
||||
assertFalse(r.hasPermission(alice, BAN))
|
||||
assertFalse(r.canActOn(alice, bob, BAN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletedRolesAndPositionZeroAreDropped() {
|
||||
val heads =
|
||||
listOf(
|
||||
role(adminRole, """{"name":"Admin","position":1,"permissions":"25","deleted":true}"""),
|
||||
role(modRole, """{"name":"Peer","position":0,"permissions":"25"}"""), // illegal position 0
|
||||
grant("ab".repeat(32), alice, listOf(adminRole, modRole), granter = owner),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
assertNull(r.rank(alice)) // both assigned roles are invalid
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user