feat(rbac): implement NIP-29 room roles and permission gating (#47) #220
@@ -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})
|
||||
|
||||
|
||||
+54
-103
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user
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.