From 71db306f8da99156beea7cee29f07cf814b9dae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:56:09 +0000 Subject: [PATCH] feat(concord): add owner-rooted authority resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord04Roles/AuthorityResolver.kt | 174 ++++++++++++++++++ .../concord/cord04Roles/ConcordPermissions.kt | 3 + .../concord/cord04Roles/ControlEntities.kt | 86 +++++++++ .../cord04Roles/AuthorityResolverTest.kt | 166 +++++++++++++++++ 4 files changed, 429 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt new file mode 100644 index 0000000000..c5084d47cd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -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, + private val memberRoles: Map>, + private val banned: Set, +) { + 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, + 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() + for (e in heads) { + if (e.entityKind != ControlEntityKind.ROLE) continue + val r = ConcordJson.decodeOrNull(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(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() + for (e in heads) { + if (e.entityKind != ControlEntityKind.BANLIST) continue + ConcordJson.decodeBanlist(e.content)?.forEach { banned.add(it.lowercase()) } + } + + val memberRoles = HashMap>() + + 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, + val granter: String, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt index 9edce5c798..8250599d78 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt new file mode 100644 index 0000000000..6e8c02a583 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -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 decodeOrNull(content: String): T? = + try { + instance.decodeFromString(content) + } catch (_: Exception) { + null + } + + /** Parses a Banlist edition's content (a bare JSON array of hex pubkeys). */ + fun decodeBanlist(content: String): List? = + 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 = emptyList(), +) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt new file mode 100644 index 0000000000..0d21a74dd0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -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, + 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 + } +}