feat(rbac): implement NIP-29 room roles and permission gating (#47) #220

Closed
Khushvendra wants to merge 5 commits from Khushvendra/flotilla:feat/nip29-rbac-47 into dev
2 changed files with 56 additions and 111 deletions
Showing only changes of commit ba07c339eb - Show all commits
+2 -8
View File
@@ -17,11 +17,7 @@
import Report from "@app/components/Report.svelte"
import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {
deriveUserIsSpaceAdmin,
deriveHasPermission,
ROOM_PERMISSION_DELETE_EVENT,
} from "@app/core/roles"
import {deriveHasPermission, ROOM_PERMISSION_DELETE_EVENT} from "@app/core/roles"
import {hasNip29} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
@@ -39,9 +35,7 @@
const isRoot = event.kind !== COMMENT
const h = getTagValue("h", event.tags)
const canDelete = h
? deriveHasPermission(url, h, ROOM_PERMISSION_DELETE_EVENT)
: deriveUserIsSpaceAdmin(url)
const canDelete = deriveHasPermission(url, h, ROOM_PERMISSION_DELETE_EVENT)
const report = () => pushModal(Report, {url, event})
1
+54 -103
View File
@@ -1,7 +1,7 @@
import {derived, readable, type Readable} from "svelte/store"
import {first, memoize, removeUndefined, simpleCache, sortBy, uniq} from "@welshman/lib"
import {deriveArray, deriveEventsByIdForUrl} from "@welshman/store"
import {pubkey, repository, tracker, manageRelay} from "@welshman/app"
import {pubkey, manageRelay} from "@welshman/app"
import {deriveEventsForUrl} from "@app/core/state"
import {
ManagementMethod,
ROOM_ADD_MEMBER,
@@ -13,7 +13,7 @@ import {
getTagValue,
isRelayUrl,
} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
export const ROOM_ROLES = 39003
2
@@ -79,22 +79,12 @@ type ParsedRoleState = {
hasPermissionTags: boolean
}
type RoomSnapshot = {
h: string
rolesState: ParsedRoleState
members: RoomMember[]
admins: RoomMember[]
}
type SpaceRoleState = {
hasPermissionTags: boolean
userPermissions: Set<number>
memberRoles: Map<string, RoleDefinition[]>
}
const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
const makeRoleDefinition = (name: string): RoleDefinition => ({
name,
permissions: [],
1
@@ -121,14 +111,6 @@ const asAccess = (value: string | undefined): RoleAccess | undefined => {
}
}
const getLatestEventByKind = (events: TrustedEvent[], kind: number, h: string) =>
first(
sortBy(
event => -event.created_at,
events.filter(event => event.kind === kind && getTagValue("d", event.tags) === h),
),
)
const parseRoleState = (event?: TrustedEvent): ParsedRoleState => {
const roles = new Map<string, RoleDefinition>()
let hasPermissionTags = false
@@ -250,27 +232,19 @@ export const parseRoomMembers = (tags: string[][]) => {
}))
}
const deriveRoomListStore = simpleCache(([url, h]: [string, string]) =>
deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS, ROOM_ADMINS], "#d": [h]}]),
)
export const deriveRoomMembers = (url: string, h: string) =>
derived(deriveRoomListStore(url, h), $events => {
const event = getLatestEventByKind($events, ROOM_MEMBERS, h)
return parseRoomMembers(event?.tags || [])
})
derived(deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS], "#d": [h]}]), ([event]) =>
parseRoomMembers(event?.tags || []),
)
export const deriveRoomAdmins = (url: string, h: string) =>
derived(deriveRoomListStore(url, h), $events => {
const event = getLatestEventByKind($events, ROOM_ADMINS, h)
return parseRoomMembers(event?.tags || [])
})
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [h]}]), ([event]) =>
parseRoomMembers(event?.tags || []),
)
const deriveRoomRoleState = simpleCache(([url, h]: [string, string]) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ROLES], "#d": [h]}]), $events =>
parseRoleState(getLatestEventByKind($events, ROOM_ROLES, h)),
derived(deriveEventsForUrl(url, [{kinds: [ROOM_ROLES], "#d": [h]}]), ([event]) =>
parseRoleState(event),
),
)
4
@@ -395,52 +369,6 @@ export const deriveUserPermissions = (url: string, h: string) =>
},
)
const buildRoomSnapshots = (events: TrustedEvent[]) => {
const latestByH = new Map<
string,
{roles?: TrustedEvent; members?: TrustedEvent; admins?: TrustedEvent}
>()
for (const event of sortBy(x => -x.created_at, events)) {
const h = getTagValue("d", event.tags)
if (!h) {
continue
}
if (!latestByH.has(h)) {
latestByH.set(h, {})
}
const entry = latestByH.get(h)!
if (event.kind === ROOM_ROLES && !entry.roles) {
entry.roles = event
}
if (event.kind === ROOM_MEMBERS && !entry.members) {
entry.members = event
}
if (event.kind === ROOM_ADMINS && !entry.admins) {
entry.admins = event
}
}
const snapshots: RoomSnapshot[] = []
for (const [h, {roles, members, admins}] of latestByH.entries()) {
snapshots.push({
h,
rolesState: parseRoleState(roles),
members: parseRoomMembers(members?.tags || []),
admins: parseRoomMembers(admins?.tags || []),
})
}
return snapshots
}
const mergeRoleDefinitions = (left: RoleDefinition[], right: RoleDefinition[]) => {
const merged = new Map<string, RoleDefinition>()
@@ -488,28 +416,48 @@ const deriveSpaceRoleState = simpleCache(([url]: [string]) =>
([$pubkey, $events]): SpaceRoleState => {
const userPermissions = new Set<number>()
const memberRoles = new Map<string, RoleDefinition[]>()
const snapshots = buildRoomSnapshots($events)
const hasPermissionTags = snapshots.some(snapshot => snapshot.rolesState.hasPermissionTags)
const rolesByH = new Map<string, ReturnType<typeof parseRoleState>>()
let hasPermissionTags = false
for (const snapshot of snapshots) {
const allMembers = [...snapshot.members, ...snapshot.admins]
for (const member of allMembers) {
const resolvedRoles = getResolvedRoles(snapshot.rolesState.roles, member.roles)
if (resolvedRoles.length === 0) {
continue
for (const event of $events) {
if (event.kind === ROOM_ROLES) {
const h = getTagValue("d", event.tags)
if (h) {
const parsed = parseRoleState(event)
rolesByH.set(h, parsed)
if (parsed.hasPermissionTags) {
hasPermissionTags = true
}
}
}
}
memberRoles.set(
member.pubkey,
mergeRoleDefinitions(memberRoles.get(member.pubkey) || [], resolvedRoles),
)
for (const event of $events) {
if (event.kind === ROOM_MEMBERS || event.kind === ROOM_ADMINS) {
const h = getTagValue("d", event.tags)
if (!h) continue
if ($pubkey === member.pubkey && hasPermissionTags) {
for (const role of resolvedRoles) {
for (const permission of role.permissions) {
userPermissions.add(permission)
const rolesState = rolesByH.get(h)
if (!rolesState) continue
Khushvendra marked this conversation as resolved Outdated
Outdated
Review

I don't understand the purpose of snapshots, couldn't you just use the latest event of each kind and parse members on demand? Or do you expect that to be computationally intensive? It seems odd to couple roles/members/admins like this instead of access each individually. Since this function is used in only one place it might just make sense to fold the logic in there to avoid the entire idea of snapshots.

I don't understand the purpose of snapshots, couldn't you just use the latest event of each kind and parse members on demand? Or do you expect that to be computationally intensive? It seems odd to couple roles/members/admins like this instead of access each individually. Since this function is used in only one place it might just make sense to fold the logic in there to avoid the entire idea of snapshots.
const members = parseRoomMembers(event.tags)
for (const member of members) {
const resolvedRoles = getResolvedRoles(rolesState.roles, member.roles)
if (resolvedRoles.length === 0) {
continue
}
memberRoles.set(
member.pubkey,
mergeRoleDefinitions(memberRoles.get(member.pubkey) || [], resolvedRoles),
)
if ($pubkey === member.pubkey && rolesState.hasPermissionTags) {
for (const role of resolvedRoles) {
for (const permission of role.permissions) {
userPermissions.add(permission)
}
}
}
}
1
@@ -575,11 +523,14 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) =>
([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.size > 0,
)
export const deriveHasPermission = (url: string, h: string, kind: number) =>
derived(
export const deriveHasPermission = (url: string, h: string | undefined, kind: number) => {
if (!h) return deriveUserIsSpaceAdmin(url)
return derived(
[deriveUserPermissions(url, h), deriveUserIsSpaceAdmin(url)],
([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.has(kind),
)
}
export const deriveRoomRoleDefinitions = (url: string, h: string) =>
derived(deriveRoomRoles(url, h), $roomRoles =>