From 16b1f0dffbc8d48b829a3c598bc2780bca6ae8ce Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:18:31 -0400 Subject: [PATCH 01/14] Update submodules --- 3rd-party/auth | 2 +- 3rd-party/qss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/3rd-party/auth b/3rd-party/auth index 018421c01a..addffec0f7 160000 --- a/3rd-party/auth +++ b/3rd-party/auth @@ -1 +1 @@ -Subproject commit 018421c01a6840bcf0329ae8144730edda84e5c5 +Subproject commit addffec0f78db710577d8e4d2de3ef8caf215c88 diff --git a/3rd-party/qss b/3rd-party/qss index 63b00eb043..a3495e5af1 160000 --- a/3rd-party/qss +++ b/3rd-party/qss @@ -1 +1 @@ -Subproject commit 63b00eb04308eed7264fed07f3fcd961ee211720 +Subproject commit a3495e5af117699793bc4210bc5e71f57ec1f15c From f7e54d650c0cc494c9bebee2601753cb1f5e0d4e Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:20:32 -0400 Subject: [PATCH 02/14] Add helpers for checking if users have permissions to create channels --- .../services/roles/channel.service.spec.ts | 11 ++++++++ .../auth/services/roles/channel.service.ts | 28 ++++++++++++++++++- .../src/nest/auth/services/roles/roles.ts | 3 ++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts index a28268f2d5..07efde9241 100644 --- a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts +++ b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts @@ -7,6 +7,7 @@ import { generateProof, InviteResult, MemberContext, redactKeys, Team } from '@l import { InviteLockboxMetadata } from '../crypto/types' import { RANDOM_TEAM_NAME_LENGTH } from '../../types' import { RANDOM_USERNAME_LENGTH } from '../members/types' +import { randomUUID } from 'crypto' const logger = createLogger('auth:services:channels.spec') @@ -31,6 +32,8 @@ describe('channels', () => { expect(adminSigChain.user.userName.length).toBe(RANDOM_USERNAME_LENGTH) expect(adminSigChain.roles.amIAdmin()).toBe(true) expect(adminSigChain.roles.amIMember()).toBe(true) + expect(adminSigChain.channels.canICreatePrivateChannel()).toBe(true) + expect(adminSigChain.channels.canICreatePublicChannel()).toBe(true) }) it('should create channel and admin should be added as member', () => { const channel = adminSigChain.channels.create(channelId) @@ -106,4 +109,12 @@ describe('channels', () => { adminSigChain.channels.addMember(secondSigChain.context.user.userId, channelId) expect(adminSigChain.channels.memberInChannel(secondSigChain.context.user.userId, channelId)).toBe(true) }) + it('should fail to create channel on second user', () => { + expect(secondSigChain.roles.amIAdmin()).toBe(false) + expect(secondSigChain.channels.canICreatePublicChannel()).toBe(false) + expect(secondSigChain.channels.canICreatePrivateChannel()).toBe(false) + + const failedToCreateChannel = () => secondSigChain.channels.create(randomUUID()) + expect(failedToCreateChannel).toThrow() + }) }) diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.ts b/packages/backend/src/nest/auth/services/roles/channel.service.ts index 16e1e90f02..64e57a892c 100644 --- a/packages/backend/src/nest/auth/services/roles/channel.service.ts +++ b/packages/backend/src/nest/auth/services/roles/channel.service.ts @@ -7,7 +7,7 @@ import { ChainServiceBase } from '../chainServiceBase' import { Member } from '@localfirst/auth' import { createLogger } from '../../../common/logger' import { hash } from '@localfirst/crypto' -import { NotAdminError, RoleName } from './roles' +import { NotAdminError, PRIVATE_CHANNEL_MODIFICATION_ROLES, PUBLIC_CHANNEL_MODIFICATION_ROLES, RoleName } from './roles' const logger = createLogger('auth:channelService') @@ -64,6 +64,32 @@ class ChannelService extends ChainServiceBase { this.sigChain.roles.delete(roleName) } + public canMemberCreatePublicChannel(memberId: string): boolean { + for (const allowedRoleName of PUBLIC_CHANNEL_MODIFICATION_ROLES) { + if (this.sigChain.roles.memberHasRole(memberId, allowedRoleName)) { + return true + } + } + return false + } + + public canMemberCreatePrivateChannel(memberId: string): boolean { + for (const allowedRoleName of PRIVATE_CHANNEL_MODIFICATION_ROLES) { + if (this.sigChain.roles.memberHasRole(memberId, allowedRoleName)) { + return true + } + } + return false + } + + public canICreatePublicChannel(): boolean { + return this.canMemberCreatePublicChannel(this.sigChain.user.userId) + } + + public canICreatePrivateChannel(): boolean { + return this.canMemberCreatePrivateChannel(this.sigChain.user.userId) + } + public generateChannelRoleName(channelId: string): string { return hash(this.sigChain.team!.id, `private_channel_${channelId}`) } diff --git a/packages/backend/src/nest/auth/services/roles/roles.ts b/packages/backend/src/nest/auth/services/roles/roles.ts index 5f6cab7d51..911e6ceeae 100644 --- a/packages/backend/src/nest/auth/services/roles/roles.ts +++ b/packages/backend/src/nest/auth/services/roles/roles.ts @@ -11,6 +11,9 @@ export const MEMBER_SCOPE: EncryptionScope = { type: EncryptionScopeType.ROLE, } +export const PUBLIC_CHANNEL_MODIFICATION_ROLES = [RoleName.ADMIN] +export const PRIVATE_CHANNEL_MODIFICATION_ROLES = [RoleName.ADMIN] + export const SELF_ASSIGN_ROLES: (RoleName | string)[] = [RoleName.MEMBER] export type RoleMemberInfo = { From f4d714e2f1f4457377f1e4756255e3a016158cd4 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:50:50 -0400 Subject: [PATCH 03/14] Retool checks, add checks for other role/channel operations --- .gitmodules | 2 +- 3rd-party/auth | 2 +- .../services/roles/channel.service.spec.ts | 14 ++++++- .../auth/services/roles/channel.service.ts | 42 ++++++++++++++----- .../nest/auth/services/roles/role.service.ts | 32 ++++++++++++++ .../auth/services/roles/roles.service.spec.ts | 8 ++++ .../src/nest/auth/services/roles/roles.ts | 1 - 7 files changed, 87 insertions(+), 14 deletions(-) diff --git a/.gitmodules b/.gitmodules index 0f244fa210..6ab633963a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "3rd-party/auth"] path = 3rd-party/auth url = https://github.com/TryQuiet/auth.git - branch = main + branch = feat/3344-allow-checking-for-role-permissions [submodule "3rd-party/js-libp2p-noise"] path = 3rd-party/js-libp2p-noise url = https://github.com/TryQuiet/js-libp2p-noise.git diff --git a/3rd-party/auth b/3rd-party/auth index addffec0f7..f4d3c1e07f 160000 --- a/3rd-party/auth +++ b/3rd-party/auth @@ -1 +1 @@ -Subproject commit addffec0f78db710577d8e4d2de3ef8caf215c88 +Subproject commit f4d3c1e07fde3144fd0a3975d59d2e96b8baa03c diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts index 07efde9241..d43b56ef0e 100644 --- a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts +++ b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts @@ -39,7 +39,10 @@ describe('channels', () => { const channel = adminSigChain.channels.create(channelId) expect(channel).toBeDefined() expect(channel).toBe(adminSigChain.channels.generateChannelRoleName(channelId)) - expect(adminSigChain.channels.amIMemberOfChannel(channelId)) + expect(adminSigChain.channels.amIMemberOfChannel(channelId)).toBe(true) + expect(adminSigChain.channels.canIAddMembersToPrivateChannel(channelId)).toBe(true) + expect(adminSigChain.channels.canIRemoveMembersFromPrivateChannel(channelId)).toBe(true) + expect(adminSigChain.channels.canIDeletePrivateChannel(channelId)).toBe(true) }) it('should create an invite', () => { invite = adminSigChain.invites.createUserInvite() @@ -108,6 +111,15 @@ describe('channels', () => { it('should add second user to channel', () => { adminSigChain.channels.addMember(secondSigChain.context.user.userId, channelId) expect(adminSigChain.channels.memberInChannel(secondSigChain.context.user.userId, channelId)).toBe(true) + expect( + adminSigChain.channels.canMemberAddMembersToPrivateChannel(secondSigChain.context.user.userId, channelId) + ).toBe(false) + expect( + adminSigChain.channels.canMemberRemoveMembersFromPrivateChannel(secondSigChain.context.user.userId, channelId) + ).toBe(false) + expect(adminSigChain.channels.canMemberDeletePrivateChannel(secondSigChain.context.user.userId, channelId)).toBe( + false + ) }) it('should fail to create channel on second user', () => { expect(secondSigChain.roles.amIAdmin()).toBe(false) diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.ts b/packages/backend/src/nest/auth/services/roles/channel.service.ts index 64e57a892c..b0ef83e76f 100644 --- a/packages/backend/src/nest/auth/services/roles/channel.service.ts +++ b/packages/backend/src/nest/auth/services/roles/channel.service.ts @@ -7,7 +7,7 @@ import { ChainServiceBase } from '../chainServiceBase' import { Member } from '@localfirst/auth' import { createLogger } from '../../../common/logger' import { hash } from '@localfirst/crypto' -import { NotAdminError, PRIVATE_CHANNEL_MODIFICATION_ROLES, PUBLIC_CHANNEL_MODIFICATION_ROLES, RoleName } from './roles' +import { PUBLIC_CHANNEL_MODIFICATION_ROLES } from './roles' const logger = createLogger('auth:channelService') @@ -73,23 +73,45 @@ class ChannelService extends ChainServiceBase { return false } - public canMemberCreatePrivateChannel(memberId: string): boolean { - for (const allowedRoleName of PRIVATE_CHANNEL_MODIFICATION_ROLES) { - if (this.sigChain.roles.memberHasRole(memberId, allowedRoleName)) { - return true - } - } - return false - } - public canICreatePublicChannel(): boolean { return this.canMemberCreatePublicChannel(this.sigChain.user.userId) } + public canMemberCreatePrivateChannel(memberId: string): boolean { + return this.sigChain.roles.canMemberCreateRole(memberId) + } + public canICreatePrivateChannel(): boolean { return this.canMemberCreatePrivateChannel(this.sigChain.user.userId) } + public canMemberAddMembersToPrivateChannel(memberId: string, channelId: string): boolean { + const roleName = this.generateChannelRoleName(channelId) + return this.sigChain.roles.canMemberAddMembersRole(memberId, roleName) + } + + public canIAddMembersToPrivateChannel(channelId: string): boolean { + return this.canMemberAddMembersToPrivateChannel(this.sigChain.user.userId, channelId) + } + + public canMemberRemoveMembersFromPrivateChannel(memberId: string, channelId: string): boolean { + const roleName = this.generateChannelRoleName(channelId) + return this.sigChain.roles.canMemberRemoveMembersFromRole(memberId, roleName) + } + + public canIRemoveMembersFromPrivateChannel(channelId: string): boolean { + return this.canMemberRemoveMembersFromPrivateChannel(this.sigChain.user.userId, channelId) + } + + public canMemberDeletePrivateChannel(memberId: string, channelId: string): boolean { + const roleName = this.generateChannelRoleName(channelId) + return this.sigChain.roles.canMemberDeleteRole(memberId, roleName) + } + + public canIDeletePrivateChannel(channelId: string): boolean { + return this.canMemberDeletePrivateChannel(this.sigChain.user.userId, channelId) + } + public generateChannelRoleName(channelId: string): string { return hash(this.sigChain.team!.id, `private_channel_${channelId}`) } diff --git a/packages/backend/src/nest/auth/services/roles/role.service.ts b/packages/backend/src/nest/auth/services/roles/role.service.ts index c4f59bf511..bac7b8b9cc 100644 --- a/packages/backend/src/nest/auth/services/roles/role.service.ts +++ b/packages/backend/src/nest/auth/services/roles/role.service.ts @@ -114,6 +114,38 @@ class RoleService extends ChainServiceBase { return this.sigChain.team!.membersInRole(roleName) } + public canMemberCreateRole(memberId: string): boolean { + return this.sigChain.team!.memberCanCreateRole(memberId) + } + + public canICreateRole(): boolean { + return this.canMemberCreateRole(this.sigChain.user.userId) + } + + public canMemberAddMembersRole(memberId: string, roleName: string): boolean { + return this.sigChain.team!.memberCanAddMembersToRole(roleName, memberId) + } + + public canIAddMembersToRole(roleName: string): boolean { + return this.canMemberAddMembersRole(this.sigChain.user.userId, roleName) + } + + public canMemberRemoveMembersFromRole(memberId: string, roleName: string): boolean { + return this.sigChain.team!.memberCanRemoveMembersFromRole(roleName, memberId) + } + + public canIRemoveMembersFromRole(roleName: string): boolean { + return this.canMemberRemoveMembersFromRole(this.sigChain.user.userId, roleName) + } + + public canMemberDeleteRole(memberId: string, roleName: string): boolean { + return this.sigChain.team!.memberCanDeleteRole(roleName, memberId) + } + + public canIDeleteRole(roleName: string): boolean { + return this.canMemberDeleteRole(this.sigChain.user.userId, roleName) + } + private roleToQuietRole(role: Role): QuietRole { const members = this.sigChain.roles.getMembersForRole(role.roleName) const hasRole = this.sigChain.roles.amIMemberOfRole(role.roleName as RoleName) diff --git a/packages/backend/src/nest/auth/services/roles/roles.service.spec.ts b/packages/backend/src/nest/auth/services/roles/roles.service.spec.ts index 41a5f05367..86871f954c 100644 --- a/packages/backend/src/nest/auth/services/roles/roles.service.spec.ts +++ b/packages/backend/src/nest/auth/services/roles/roles.service.spec.ts @@ -30,6 +30,10 @@ describe('roles', () => { expect(adminSigChain.user.userName.length).toBe(RANDOM_USERNAME_LENGTH) expect(adminSigChain.roles.amIAdmin()).toBe(true) expect(adminSigChain.roles.amIMemberOfRole(RoleName.MEMBER)).toBe(true) + expect(adminSigChain.roles.canICreateRole()).toBe(true) + expect(adminSigChain.roles.canIAddMembersToRole(RoleName.MEMBER)).toBe(true) + expect(adminSigChain.roles.canIRemoveMembersFromRole(RoleName.MEMBER)).toBe(true) + expect(adminSigChain.roles.canIDeleteRole(RoleName.MEMBER)).toBe(true) }) it('should create an invite', () => { invite = adminSigChain.invites.createUserInvite() @@ -89,6 +93,10 @@ describe('roles', () => { it('should self-assign MEMBER role on second user', () => { secondSigChain.roles.addSelf(RoleName.MEMBER, seed, salt) expect(secondSigChain.roles.amIMemberOfRole(RoleName.MEMBER)).toBe(true) + expect(secondSigChain.roles.canICreateRole()).toBe(false) + expect(secondSigChain.roles.canIAddMembersToRole(RoleName.MEMBER)).toBe(true) + expect(secondSigChain.roles.canIRemoveMembersFromRole(RoleName.MEMBER)).toBe(false) + expect(secondSigChain.roles.canIDeleteRole(RoleName.MEMBER)).toBe(false) }) it('should fail to self-assign ADMIN role on second user', () => { const failedSelfAssign = () => secondSigChain.roles.addSelf(RoleName.ADMIN, seed, salt) diff --git a/packages/backend/src/nest/auth/services/roles/roles.ts b/packages/backend/src/nest/auth/services/roles/roles.ts index 911e6ceeae..949dfc4e38 100644 --- a/packages/backend/src/nest/auth/services/roles/roles.ts +++ b/packages/backend/src/nest/auth/services/roles/roles.ts @@ -12,7 +12,6 @@ export const MEMBER_SCOPE: EncryptionScope = { } export const PUBLIC_CHANNEL_MODIFICATION_ROLES = [RoleName.ADMIN] -export const PRIVATE_CHANNEL_MODIFICATION_ROLES = [RoleName.ADMIN] export const SELF_ASSIGN_ROLES: (RoleName | string)[] = [RoleName.MEMBER] From f184853d5cbd5998073768e8bb7d4fa829f0f2df Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:54:49 -0400 Subject: [PATCH 04/14] Use chain-level checks for determining metadata write permissions --- .../services/roles/channel.service.spec.ts | 2 + .../auth/services/roles/channel.service.ts | 13 ++ .../storage/channels/channels.service.spec.ts | 18 ++ .../nest/storage/channels/channels.service.ts | 76 +++++--- .../ChannelMetadataAccessController.ts | 50 ++++-- ...annelMetadataAccessController.unit.spec.ts | 163 ++++++++++++++---- .../nest/storage/orbitDb/orbitDb.service.ts | 8 + .../src/tests/multipleClients.test.ts | 31 ++-- packages/types/src/channel.ts | 1 + 9 files changed, 279 insertions(+), 83 deletions(-) diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts index d43b56ef0e..aae4a59b4a 100644 --- a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts +++ b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts @@ -34,6 +34,7 @@ describe('channels', () => { expect(adminSigChain.roles.amIMember()).toBe(true) expect(adminSigChain.channels.canICreatePrivateChannel()).toBe(true) expect(adminSigChain.channels.canICreatePublicChannel()).toBe(true) + expect(adminSigChain.channels.canIDeletePublicChannel()).toBe(true) }) it('should create channel and admin should be added as member', () => { const channel = adminSigChain.channels.create(channelId) @@ -125,6 +126,7 @@ describe('channels', () => { expect(secondSigChain.roles.amIAdmin()).toBe(false) expect(secondSigChain.channels.canICreatePublicChannel()).toBe(false) expect(secondSigChain.channels.canICreatePrivateChannel()).toBe(false) + expect(secondSigChain.channels.canIDeletePublicChannel()).toBe(false) const failedToCreateChannel = () => secondSigChain.channels.create(randomUUID()) expect(failedToCreateChannel).toThrow() diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.ts b/packages/backend/src/nest/auth/services/roles/channel.service.ts index b0ef83e76f..1c369cf642 100644 --- a/packages/backend/src/nest/auth/services/roles/channel.service.ts +++ b/packages/backend/src/nest/auth/services/roles/channel.service.ts @@ -77,6 +77,19 @@ class ChannelService extends ChainServiceBase { return this.canMemberCreatePublicChannel(this.sigChain.user.userId) } + public canMemberDeletePublicChannel(memberId: string): boolean { + for (const allowedRoleName of PUBLIC_CHANNEL_MODIFICATION_ROLES) { + if (this.sigChain.roles.memberHasRole(memberId, allowedRoleName)) { + return true + } + } + return false + } + + public canIDeletePublicChannel(): boolean { + return this.canMemberCreatePublicChannel(this.sigChain.user.userId) + } + public canMemberCreatePrivateChannel(memberId: string): boolean { return this.sigChain.roles.canMemberCreateRole(memberId) } diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 24f016725c..b01d80ef91 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -987,6 +987,24 @@ describe('ChannelsService', () => { ) }) + it('rejects public channel metadata when owner lacks permissions', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const publicChannel = await factory.build('PublicChannel', { + owner: malloryChain.user.userId, + teamId: community.teamId!, + }) + const entry = malloryChain.crypto.encryptAndSign(publicChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expectChannelEntryValidation( + channelPutEntry(publicChannel.id, entry, 'non-admin-public'), + malloryChain.user.userId, + false + ) + }) + it('rejects public channel metadata when owner does not match the entry signature author', async () => { const malloryChain = createNonAdminMemberChain('mallory') const publicChannel = await factory.build('PublicChannel', { diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index cf2941d59d..48e9c36db5 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -128,8 +128,8 @@ export class ChannelsService extends EventEmitter { if (this.channels == null || this.privateChannels == null) { throw new Error('Channels database must be initialized before updating metadata!') } - OrbitDbService.updateMetadata(this.channels, metadata) - OrbitDbService.updateMetadata(this.privateChannels, metadata) + OrbitDbService.updateMetadata(this.channels, { ...metadata, isPublic: true }) + OrbitDbService.updateMetadata(this.privateChannels, { ...metadata, isPublic: false }) for (const repo of this.channelsRepos.values()) { repo.store.updateMetadata(metadata) } @@ -246,15 +246,20 @@ export class ChannelsService extends EventEmitter { } private async openChannelsDb(): Promise> { - return await this.openMetadataDb(PUBLIC_CHANNEL_METADATA_STORE_NAME, this.validatePublicChannelMetadataEntry) + return await this.openMetadataDb(PUBLIC_CHANNEL_METADATA_STORE_NAME, true, this.validatePublicChannelMetadataEntry) } private async openPrivateChannelsDb(): Promise> { - return await this.openMetadataDb(PRIVATE_CHANNEL_METADATA_STORE_NAME, this.validatePrivateChannelMetadataEntry) + return await this.openMetadataDb( + PRIVATE_CHANNEL_METADATA_STORE_NAME, + false, + this.validatePrivateChannelMetadataEntry + ) } private async openMetadataDb( dbName: string, + isPublic: boolean, validateFunc: typeof this.validatePublicChannelMetadataEntry | typeof this.validatePrivateChannelMetadataEntry ): Promise> { return await this.orbitDbService.open>(dbName, { @@ -263,6 +268,7 @@ export class ChannelsService extends EventEmitter { AccessController: this.channelMetadataAccessController.createAccessControllerFunc({ write: ['*'], sigchainService: this.sigchainService, + isPublic, }), }) } @@ -382,19 +388,17 @@ export class ChannelsService extends EventEmitter { return false } - const writerIsAdmin = await this.writerIsAdmin(writerIdentity.id) - if (decEntry.owner !== writerIdentity.id && !writerIsAdmin) { - this.logger.error('Failed to validate channel entry: owner must match entry signature author:', entry.hash, { + const writerHasPermissions = expectedPublic + ? chain.channels.canMemberCreatePublicChannel(writerIdentity.id) + : chain.channels.canMemberCreatePrivateChannel(writerIdentity.id) + if (!writerHasPermissions) { + this.logger.error('Failed to validate channel entry: writer lacks proper permissions', entry.hash, { owner: decEntry.owner, entrySignatureAuthor: writerIdentity.id, }) return false } - if (!writerIsAdmin && !(await this.validateChannelOwnership(entry, decEntry, writerIdentity.id, metadataStore))) { - return false - } - if (!(decEntry.public ?? true)) { return this.validatePrivateChannelEntry(entry, encPayload, decEntry, sigAuthor) } @@ -613,7 +617,10 @@ export class ChannelsService extends EventEmitter { return true } - private async validateChannelDeleteEntry(entry: LogEntry): Promise { + private async validateChannelDeleteEntry( + entry: LogEntry, + expectedPublic: boolean + ): Promise { const key = entry.payload.key if (!key) { this.logger.error('Delete channel entry is missing key:', entry.hash) @@ -643,10 +650,17 @@ export class ChannelsService extends EventEmitter { return false } - if (!chain.roles.memberIsAdmin(writerIdentity.id)) { - this.logger.error('Failed to validate delete channel entry: writer must be a sigchain admin:', entry.hash, { - writerId: writerIdentity.id, - }) + const writerHasPermissions = expectedPublic + ? chain.channels.canMemberDeletePublicChannel(writerIdentity.id) + : chain.channels.canMemberDeletePrivateChannel(writerIdentity.id, entry.key) + if (!writerHasPermissions) { + this.logger.error( + 'Failed to validate delete channel entry: writer must have channel deletion permissions on chain:', + entry.hash, + { + writerId: writerIdentity.id, + } + ) return false } @@ -679,7 +693,7 @@ export class ChannelsService extends EventEmitter { } } if (entry.payload.op === 'DEL') { - if (!(await this.validateChannelDeleteEntry(entry))) { + if (!(await this.validateChannelDeleteEntry(entry, expectedPublic ?? true))) { return false } } @@ -948,19 +962,27 @@ export class ChannelsService extends EventEmitter { */ public async handleCreateChannel(payload: CreateChannelPayload): Promise { const id = await this.generateChannelId() + const sigChain = this.sigchainService.getActiveChain() const channelData: PublicChannel = { id: id, name: payload.name, description: payload.description ?? '', - owner: this.sigchainService.getActiveChain().user.userId, + owner: sigChain.user.userId, timestamp: DateTime.utc().valueOf(), public: payload.public ?? true, teamId: payload.teamId, } let roleName: string | undefined = undefined if (!(channelData.public ?? true)) { - roleName = this.sigchainService.getActiveChain().channels.create(channelData.id) + if (!sigChain.channels.canICreatePrivateChannel()) { + throw new Error('User is missing permissions to create private channels') + } + roleName = sigChain.channels.create(channelData.id) channelData.roleName = roleName + } else { + if (!sigChain.channels.canICreatePublicChannel()) { + throw new Error('User is missing permissions to create public channels') + } } const store = await this.createChannel(channelData) if (!store) { @@ -1093,14 +1115,16 @@ export class ChannelsService extends EventEmitter { this.logger.error(`Channel ${channelId} not found`) return { channelId, deleted: true } as DeleteChannelResponse } - const iAmAdmin = this.sigchainService.roles.amIAdmin() - const iOwnThisChannel = channel?.owner === this.sigchainService.getActiveChain().user.userId + const iCanDeleteChannel = + (channel.public ?? true) + ? this.sigchainService.activeChain.channels.canIDeletePublicChannel() + : this.sigchainService.activeChain.channels.canIDeletePrivateChannel(channelId) // NOTE: this doesn't prevent other users from deleting channels they don't own if they modify the client // TODO: invalidate removals from non-owners - if (iAmAdmin || iOwnThisChannel) { + if (iCanDeleteChannel) { await this.deleteChannelMetadata(channelId) } else { - this.logger.error(`User is not the owner of the channel ${channelId}`) + this.logger.error(`User is not allowed to delete channel ${channelId}`) return { channelId, deleted: false } as DeleteChannelResponse } @@ -1152,6 +1176,12 @@ export class ChannelsService extends EventEmitter { return { channelId, status: AddMembersChannelStatus.NOT_MEMBER } } + const canAddMembers = this.sigchainService.activeChain.channels.canIAddMembersToPrivateChannel(channelId) + if (!canAddMembers) { + this.logger.error(`You don't have permission to add members to private channel ${channelId}!`) + return { channelId, status: AddMembersChannelStatus.NOT_PERMITTED } + } + const repo = this.channelsRepos.get(channelId) const store = repo?.store if (store == null) { diff --git a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts index 66e6a781b4..0f1f6fd467 100644 --- a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts +++ b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts @@ -22,15 +22,24 @@ import { EncryptedAndSignedPayload } from '../../../auth/services/crypto/types' import { createLogger } from '../../../common/logger' import { QuietLogger } from '@quiet/logger' import { posixJoin } from '../../orbitDb/util' +import type { SigChain } from '../../../auth/sigchain' const TYPE = 'channelmetadataaccess' const codec = dagCbor const hasher = sha256 const hashStringEncoding = base58btc -const AccessControlList = async ({ storage, params }: { storage: Storage; params: Record }) => { +const AccessControlList = async ({ + storage, + params, + isPublic, +}: { + storage: Storage + params: Record + isPublic: boolean +}) => { const manifest = { - type: TYPE, + type: `${isPublic ? 'public' : 'private'}${TYPE}`, ...params, } const { cid, bytes } = await Block.encode({ value: manifest, codec, hasher }) @@ -50,6 +59,7 @@ const getAccessControllerManifestHash = (address: string): string => { interface ChannelMetadataAccessControllerConfig { write: string[] sigchainService: SigChainService + isPublic: boolean } interface ChannelMetadataWriterIdentity { @@ -100,7 +110,7 @@ export class ChannelMetadataAccessController { // @ts-ignore write = value.write } else { - address = await AccessControlList({ storage, params: { write } }) + address = await AccessControlList({ storage, params: { write }, isPublic: config.isPublic }) address = posixJoin('/', TYPE, address) } @@ -151,14 +161,6 @@ export class ChannelMetadataAccessController { return false } - if (entry.payload.op === 'PUT' && !(await this.canAppendPutForKey(entry, getLog(), writerIdentity.id))) { - return false - } - - if (chain.roles.memberIsAdmin(writerIdentity.id)) { - return true - } - if (!chain.roles.memberHasRole(writerIdentity.id, RoleName.MEMBER)) { this.logger.warn(`Channel metadata writer is not a team member`, { writerId: writerIdentity.id, @@ -166,8 +168,18 @@ export class ChannelMetadataAccessController { return false } - if (entry.payload.op === 'DEL') { - this.logger.warn(`Channel metadata deletes require an admin writer`, { + if ( + entry.payload.op === 'PUT' && + !(await this.canAppendPutForKey(entry, getLog(), writerIdentity.id, chain, config)) + ) { + return false + } + + const canDelete = config.isPublic + ? chain.channels.canMemberCreatePrivateChannel(writerIdentity.id) + : chain.channels.canMemberCreatePublicChannel(writerIdentity.id) + if (entry.payload.op === 'DEL' && !canDelete) { + this.logger.warn(`Channel metadata DEL rejected due to missing chain permissions`, { writerId: writerIdentity.id, }) return false @@ -180,7 +192,9 @@ export class ChannelMetadataAccessController { private async canAppendPutForKey( entry: LogEntry, log: LogType | undefined, - writerId: string + writerId: string, + chain: SigChain, + config: ChannelMetadataAccessControllerConfig ): Promise { const channelId = entry.payload.key if (channelId == null) { @@ -198,6 +212,14 @@ export class ChannelMetadataAccessController { return false } + const canCreateChannel = config.isPublic + ? chain.channels.canMemberCreatePublicChannel(writerId) + : chain.channels.canMemberCreatePrivateChannel(writerId) + if (!canCreateChannel) { + this.logger.warn(`Channel metadata PUT rejected due to missing chain permissions`, { entryHash: entry.hash }) + return false + } + try { for await (const existingEntry of log.traverse(null, async () => false)) { if ( diff --git a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts index 3e420ebcde..e386001329 100644 --- a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts +++ b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts @@ -44,6 +44,12 @@ const createSigchainService = ({ ), memberIsAdmin: jest.fn((memberId: string) => memberId === 'writer-id' && admin), }, + channels: { + canMemberCreatePrivateChannel: jest.fn((memberId: string) => memberId === 'writer-id' && admin), + canMemberCreatePublicChannel: jest.fn((memberId: string) => memberId === 'writer-id' && admin), + canMemberDeletePrivateChannel: jest.fn((memberId: string) => memberId === 'writer-id' && admin), + canMemberDeletePublicChannel: jest.fn((memberId: string) => memberId === 'writer-id' && admin), + }, }), }) as any @@ -72,9 +78,9 @@ const attachLogContext = (access: any, entries: LogEntry { +const createAccess = async (sigchainService: any, isPublic: boolean) => { const controller = new ChannelMetadataAccessController(sigchainService) - const factory = controller.createAccessControllerFunc({ write: ['*'], sigchainService }) + const factory = controller.createAccessControllerFunc({ write: ['*'], sigchainService, isPublic }) return (factory as any)({ orbitdb: { identity: { id: 'local-orbitdb-identity' }, @@ -91,7 +97,7 @@ describe('ChannelMetadataAccessController', () => { it('loads the ACL manifest from a persisted typed access-controller address', async () => { const sigchainService = createSigchainService({}) const controller = new ChannelMetadataAccessController(sigchainService) - const factory = controller.createAccessControllerFunc({ write: ['writer-id'], sigchainService }) + const factory = controller.createAccessControllerFunc({ write: ['writer-id'], sigchainService, isPublic: true }) const orbitdb = { identity: { id: 'local-orbitdb-identity' }, ipfs: createInMemoryIpfs(), @@ -108,28 +114,57 @@ describe('ChannelMetadataAccessController', () => { }) }) - it('allows channel metadata PUT entries from team members', async () => { - const access = await createAccess(createSigchainService({ member: true })) + it('allows public channel metadata PUT entries from team members with correct permissions', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), true) + attachLogContext(access) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(true) + }) + + it('allows private channel metadata PUT entries from team members with correct permissions', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), false) attachLogContext(access) await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(true) }) - it('rejects channel metadata PUT entries from non-members', async () => { - const access = await createAccess(createSigchainService({ member: false })) + it('rejects public channel metadata PUT entries from non-members', async () => { + const access = await createAccess(createSigchainService({ member: false }), true) + attachLogContext(access) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) + }) + + it('rejects private channel metadata PUT entries from non-members', async () => { + const access = await createAccess(createSigchainService({ member: false }), false) attachLogContext(access) await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) }) - it('rejects channel metadata PUT entries when log state is unavailable', async () => { - const access = await createAccess(createSigchainService({ member: true })) + it('rejects public channel metadata PUT entries when log state is unavailable', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), true) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) + }) + + it('rejects private channel metadata PUT entries when log state is unavailable', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), false) await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) }) - it('rejects channel metadata PUT entries when the entry key is missing', async () => { - const access = await createAccess(createSigchainService({ member: true })) + it('rejects public channel metadata PUT entries when the entry key is missing', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), true) + attachLogContext(access) + const entry = createEntry('PUT') + ;(entry.payload as any).key = undefined + + await expect(access.canAppend(entry)).resolves.toBe(false) + }) + + it('rejects private channel metadata PUT entries when the entry key is missing', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), false) attachLogContext(access) const entry = createEntry('PUT') ;(entry.payload as any).key = undefined @@ -137,8 +172,21 @@ describe('ChannelMetadataAccessController', () => { await expect(access.canAppend(entry)).resolves.toBe(false) }) - it('rejects channel metadata PUT entries when log traversal throws', async () => { - const access = await createAccess(createSigchainService({ member: true })) + it('rejects public channel metadata PUT entries when log traversal throws', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), true) + access.setLogContext({ + traverse: () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => Promise.reject(new Error('log read failed')), + }), + }), + }) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) + }) + + it('rejects private channel metadata PUT entries when log traversal throws', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), false) access.setLogContext({ traverse: () => ({ [Symbol.asyncIterator]: () => ({ @@ -150,8 +198,17 @@ describe('ChannelMetadataAccessController', () => { await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) }) - it('rejects channel metadata PUT entries when a previous PUT exists for the same key', async () => { - const access = await createAccess(createSigchainService({ member: true })) + it('rejects public channel metadata PUT entries when a previous PUT exists for the same key', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), true) + const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') + const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') + attachLogContext(access, [previousEntry]) + + await expect(access.canAppend(nextEntry)).resolves.toBe(false) + }) + + it('rejects private channel metadata PUT entries when a previous PUT exists for the same key', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), false) const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') attachLogContext(access, [previousEntry]) @@ -159,8 +216,8 @@ describe('ChannelMetadataAccessController', () => { await expect(access.canAppend(nextEntry)).resolves.toBe(false) }) - it('allows channel metadata PUT entries when previous PUTs are for different keys', async () => { - const access = await createAccess(createSigchainService({ member: true })) + it('allows public channel metadata PUT entries when previous PUTs are for different keys', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), true) const previousEntry = createEntry('PUT', 'other-channel-id') const nextEntry = createEntry('PUT', 'channel-id') attachLogContext(access, [previousEntry]) @@ -168,8 +225,27 @@ describe('ChannelMetadataAccessController', () => { await expect(access.canAppend(nextEntry)).resolves.toBe(true) }) - it('rejects channel metadata PUT entries after a prior PUT even when the latest entry is a DEL', async () => { - const access = await createAccess(createSigchainService({ member: true })) + it('allows private channel metadata PUT entries when previous PUTs are for different keys', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), false) + const previousEntry = createEntry('PUT', 'other-channel-id') + const nextEntry = createEntry('PUT', 'channel-id') + attachLogContext(access, [previousEntry]) + + await expect(access.canAppend(nextEntry)).resolves.toBe(true) + }) + + it('rejects public channel metadata PUT entries after a prior PUT even when the latest entry is a DEL', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), true) + const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') + const deleteEntry = createEntry('DEL', 'channel-id', 'deleted-channel') + const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') + attachLogContext(access, [deleteEntry, previousEntry]) + + await expect(access.canAppend(nextEntry)).resolves.toBe(false) + }) + + it('rejects private channel metadata PUT entries after a prior PUT even when the latest entry is a DEL', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), false) const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') const deleteEntry = createEntry('DEL', 'channel-id', 'deleted-channel') const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') @@ -178,8 +254,17 @@ describe('ChannelMetadataAccessController', () => { await expect(access.canAppend(nextEntry)).resolves.toBe(false) }) - it('rejects duplicate channel metadata PUT entries from admins', async () => { - const access = await createAccess(createSigchainService({ member: false, admin: true })) + it('rejects duplicate public channel metadata PUT entries from admins', async () => { + const access = await createAccess(createSigchainService({ member: false, admin: true }), true) + const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') + const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') + attachLogContext(access, [previousEntry]) + + await expect(access.canAppend(nextEntry)).resolves.toBe(false) + }) + + it('rejects duplicate private channel metadata PUT entries from admins', async () => { + const access = await createAccess(createSigchainService({ member: false, admin: true }), true) const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') attachLogContext(access, [previousEntry]) @@ -187,23 +272,43 @@ describe('ChannelMetadataAccessController', () => { await expect(access.canAppend(nextEntry)).resolves.toBe(false) }) - it('rejects channel metadata DEL entries from non-admin members', async () => { - const access = await createAccess(createSigchainService({ member: true, admin: false })) + it('rejects public channel metadata DEL entries from non-admin members', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: false }), true) + + await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(false) + }) + + it('rejects private channel metadata DEL entries from non-admin members', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: false }), false) await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(false) }) - it('allows channel metadata DEL entries from admins', async () => { - const access = await createAccess(createSigchainService({ member: true, admin: true })) + it('allows public channel metadata DEL entries from admins', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), true) await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(true) }) - it('allows channel metadata entries from admins even without the member role', async () => { - const access = await createAccess(createSigchainService({ member: false, admin: true })) - attachLogContext(access) + it('allows private channel metadata DEL entries from admins', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true }), false) - await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(true) await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(true) }) + + it('rejects public channel metadata entries from admins even without the member role', async () => { + const access = await createAccess(createSigchainService({ member: false, admin: true }), true) + attachLogContext(access) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) + await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(false) + }) + + it('rejects private channel metadata entries from admins even without the member role', async () => { + const access = await createAccess(createSigchainService({ member: false, admin: true }), false) + attachLogContext(access) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) + await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(false) + }) }) diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 1a0edecad8..b0935e3e6a 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -218,6 +218,14 @@ export class OrbitDbService { this.channelMetadataAccessController.createAccessControllerFunc({ write: ['*'], sigchainService: this.sigChainService, + isPublic: true, + }) as any + ) + orbitDbUseAccessController( + this.channelMetadataAccessController.createAccessControllerFunc({ + write: ['*'], + sigchainService: this.sigChainService, + isPublic: false, }) as any ) diff --git a/packages/e2e-tests/src/tests/multipleClients.test.ts b/packages/e2e-tests/src/tests/multipleClients.test.ts index 2d6f2d59fe..5d7ab6d81e 100644 --- a/packages/e2e-tests/src/tests/multipleClients.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.test.ts @@ -333,7 +333,6 @@ describe('Multiple Clients', () => { describe('Owner Creates New Channel', () => { it('Owner creates second channel', async () => { sidebarOwner = new Sidebar(users.owner.app.driver) - // TODO: revert when private channels are added back await sidebarOwner.addNewChannel(newChannelName, true, true) await sidebarOwner.switchChannel(newChannelName) const channels = await sidebarOwner.getChannelList() @@ -379,11 +378,10 @@ describe('Multiple Clients', () => { describe('Channel Deletion', () => { it('Owner deletes second channel', async () => { channelContextMenuOwner = new ChannelContextMenu(users.owner.app.driver) - const { iconVisible, menuOpened, menuButton } = await channelContextMenuOwner.openMenu(false) + const { iconVisible, menuOpened, menuButton } = await channelContextMenuOwner.openMenu() expect(menuButton).toBe(true) expect(menuOpened).toBe(true) - // TODO: revert when private channels are added back - expect(iconVisible).toBeUndefined() + expect(iconVisible).toBe(true) await channelContextMenuOwner.openDeletionChannelModal() await channelContextMenuOwner.deleteChannel() const channels = await sidebarOwner.getChannelList() @@ -421,20 +419,19 @@ describe('Multiple Clients', () => { expect(channels.length).toEqual(1) }) - it('User can create channel with the same name and is fresh channel', async () => { - // TODO: revert when private channels are added back - await sidebarUser1.addNewChannel(newChannelName, true, true) - await sidebarUser1.switchChannel(newChannelName) - const messages = await secondChannelUser1.getUserMessages(users.user1.username) + it('Owner can create channel with the same name and is fresh channel', async () => { + await sidebarOwner.addNewChannel(newChannelName, true, true) + await sidebarOwner.switchChannel(newChannelName) + const messages = await secondChannelOwner.getUserMessages(users.owner.username) expect(messages.length).toEqual(1) - expect(await secondChannelUser1.isReady()).toBeTruthy() - const channels = await sidebarUser1.getChannelList() + expect(await secondChannelOwner.isReady()).toBeTruthy() + const channels = await sidebarOwner.getChannelList() expect(channels.length).toEqual(2) }) - it('Owner sees the recreated second channel', async () => { - expect(await secondChannelOwner.isReady(30_000)).toBeTruthy() - const channels = await sidebarOwner.getChannelList() + it('First user sees the recreated second channel', async () => { + expect(await secondChannelUser1.isReady(30_000)).toBeTruthy() + const channels = await sidebarUser1.getChannelList() expect(channels.length).toEqual(2) }) @@ -457,17 +454,17 @@ describe('Multiple Clients', () => { // Delete general channel while guest is absent it('Owner recreates general channel', async () => { logger.info('TEST 3') + await sidebarOwner.switchChannel(generalChannelName, true, true) expect(await generalChannelOwner.isReady()).toBeTruthy() expect(await generalChannelOwner.isOpen()).toBeTruthy() expect(await generalChannelOwner.isMessageInputReady()).toBeTruthy() channelContextMenuOwner = new ChannelContextMenu(users.owner.app.driver) - const { menuOpened, menuButton, iconVisible } = await channelContextMenuOwner.openMenu(false) + const { menuOpened, menuButton, iconVisible } = await channelContextMenuOwner.openMenu() await channelContextMenuOwner.openDeletionChannelModal() await channelContextMenuOwner.deleteChannel() expect(menuButton).toBe(true) expect(menuOpened).toBe(true) - // TODO: revert when private channels are added back - expect(iconVisible).toBeUndefined() + expect(iconVisible).toBe(true) }) it('Owner sees recreated general channel', async () => { diff --git a/packages/types/src/channel.ts b/packages/types/src/channel.ts index 82f582267a..89c198b948 100644 --- a/packages/types/src/channel.ts +++ b/packages/types/src/channel.ts @@ -196,6 +196,7 @@ export enum AddMembersChannelStatus { INVALID_CHANNEL_TYPE = 'INVALID_CHANNEL_TYPE', NOT_ADMIN = 'NOT_ADMIN', NOT_CHANNEL_OWNER = 'NOT_CHANNEL_OWNER', + NOT_PERMITTED = 'NOT_PERMITTED', } export interface AddMembersChannelResponse { From 20044af5ae40a31240b231b31396e0d850f30084 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:18:11 -0400 Subject: [PATCH 05/14] Pass permissions to state-manager --- .../connections-manager.service.ts | 55 ++++++++++++++----- .../nest/storage/channels/channels.types.ts | 4 ++ .../publicChannels.selectors.ts | 5 ++ .../publicChannels/publicChannels.slice.ts | 9 +++ .../startConnection/startConnection.saga.ts | 6 ++ packages/types/src/channel.ts | 41 ++++++++++++++ packages/types/src/socket.ts | 3 + 7 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 packages/backend/src/nest/storage/channels/channels.types.ts diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 0f239bced4..32e6c61ee4 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -56,6 +56,8 @@ import { UserProfilesUpdatedPayload, UpdateCommunityPayload, ChannelOperationStatus, + type ChannelPermissions, + type PrivateChannelPermissions, } from '@quiet/types' import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const' import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service' @@ -81,6 +83,7 @@ import { SigchainEvents } from '../auth/types' import { QPSService } from '../qps/qps.service' import { CaptchaService } from '../captcha/captcha.service' import { SigChain } from '../auth/sigchain' +import { Member } from '@localfirst/auth' /** * A monolith service that handles lots of events received from the state-manager. @@ -978,24 +981,50 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI } else { channelMapping = await this.storageService.channels.getPrivateChannelsByRolename() } + + const _handleUser = (member: Member, sigChain: SigChain): User => { + const privateChannelIds = + channelMapping != null + ? member.roles.filter(roleName => roleName in channelMapping).map(roleName => channelMapping[roleName].id) + : [] + if (member.userId === sigChain.user.userId) { + const channelSpecific: { [channelId: string]: PrivateChannelPermissions } = {} + for (const channelId of privateChannelIds) { + channelSpecific[channelId] = { + addMembers: sigChain.channels.canMemberAddMembersToPrivateChannel(member.userId, channelId), + removeMembers: sigChain.channels.canMemberRemoveMembersFromPrivateChannel(member.userId, channelId), + } + } + const channelPermissions: ChannelPermissions = { + generic: { + public: { + create: sigChain.channels.canMemberCreatePublicChannel(member.userId), + delete: sigChain.channels.canMemberDeletePublicChannel(member.userId), + }, + private: { + create: sigChain.channels.canMemberCreatePrivateChannel(member.userId), + }, + }, + channelSpecific, + } + this.serverIoProvider.io.emit(SocketEvents.CHANNEL_PERMISSIONS_UPDATED, { channelPermissions }) + } + return { + userId: member.userId, + roles: member.roles, + channelIds: privateChannelIds, + isRegistered: true, + isDuplicated: false, + } + } + /** * TODO: clean this up so we are only updating users that are actually updated * * (Can we base these updates on the graph itself vs pulling directly from the Team object?) */ - const users = this.sigChainService - .getChain(teamId) - .team?.members() - .map(user => ({ - userId: user.userId, - roles: user.roles, - channelIds: - channelMapping != null - ? user.roles.filter(roleName => roleName in channelMapping).map(roleName => channelMapping[roleName].id) - : [], - isRegistered: true, - isDuplicated: false, - })) as User[] + const sigChain = this.sigChainService.getChain(teamId) + const users = sigChain.team?.members().map((member): User => _handleUser(member, sigChain)) this.serverIoProvider.io.emit(SocketEvents.USERS_UPDATED, { users }) } diff --git a/packages/backend/src/nest/storage/channels/channels.types.ts b/packages/backend/src/nest/storage/channels/channels.types.ts new file mode 100644 index 0000000000..0978b056f1 --- /dev/null +++ b/packages/backend/src/nest/storage/channels/channels.types.ts @@ -0,0 +1,4 @@ +export interface GetChannelsFilters { + public: boolean + private: boolean +} diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts index 224dcd5b2a..f3b296c32b 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts @@ -339,6 +339,10 @@ export const canCreatePrivateChannel = createSelector(selectState, () => { return true }) +export const channelPermissions = createSelector(selectState, state => { + return state.channelPermissions +}) + export const publicChannelsSelectors = { publicChannels, subscribedChannels, @@ -366,4 +370,5 @@ export const publicChannelsSelectors = { areChannelsLoaded, canCreateChannel, canCreatePrivateChannel, + channelPermissions, } diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts index cbcc6ad162..429d02185c 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts @@ -29,6 +29,9 @@ import { type UpdateNewestMessagePayload, type AddMembersChannelPayload, ChannelOperationStatus, + type ChannelPermissions, + DEFAULT_CHANNEL_PERMISSIONS, + type SetChannelPermissionsPayload, } from '@quiet/types' import { createLogger } from '../../utils/logger' @@ -45,6 +48,8 @@ export class PublicChannelsState { public channelsSubscriptions: EntityState = publicChannelsSubscriptionsAdapter.getInitialState() + + public channelPermissions: ChannelPermissions = DEFAULT_CHANNEL_PERMISSIONS } export const publicChannelsSlice = createSlice({ @@ -156,6 +161,10 @@ export const publicChannelsSlice = createSlice({ }, }) }, + setChannelPermissions: (state, action: PayloadAction) => { + const { channelPermissions } = action.payload + state.channelPermissions = channelPermissions + }, // Utility action for testing purposes test_message: ( state, diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index 3ea54824bb..bbe0b2243b 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -37,6 +37,7 @@ import { HCaptchaChallengeRequest, InviteResultWithSalt, UpdateCommunityPayload, + type SetChannelPermissionsPayload, } from '@quiet/types' import { createLogger } from '../../../utils/logger' @@ -64,6 +65,7 @@ export function subscribe(socket: Socket) { | ReturnType | ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -165,6 +167,10 @@ export function subscribe(socket: Socket) { logger.info(`${SocketEvents.CHANNEL_SUBSCRIBED}`, payload) emit(publicChannelsActions.setChannelSubscribed(payload)) }) + socket.on(SocketEvents.CHANNEL_PERMISSIONS_UPDATED, (payload: SetChannelPermissionsPayload) => { + logger.info(`${SocketEvents.CHANNEL_PERMISSIONS_UPDATED}`, payload) + emit(publicChannelsActions.setChannelPermissions(payload)) + }) // Messages socket.on(SocketEvents.MESSAGE_IDS_STORED, (payload: ChannelMessageIdsResponse) => { logger.info(`${SocketEvents.MESSAGE_IDS_STORED}`, payload) diff --git a/packages/types/src/channel.ts b/packages/types/src/channel.ts index 89c198b948..b6e93cc076 100644 --- a/packages/types/src/channel.ts +++ b/packages/types/src/channel.ts @@ -207,3 +207,44 @@ export interface AddMembersChannelResponse { export function instanceOfChannelMessage(object: ChannelMessage): boolean { return 'channelId' in object } + +export interface GenericPublicPermissions { + create: boolean + delete: boolean +} + +export interface GenericPrivatePermissions { + create: boolean +} + +export interface GenericChannelPermissions { + public: GenericPublicPermissions + private: GenericPrivatePermissions +} + +export interface PrivateChannelPermissions { + addMembers: boolean + removeMembers: boolean +} + +export interface ChannelPermissions { + generic: GenericChannelPermissions + channelSpecific: Record +} + +export interface SetChannelPermissionsPayload { + channelPermissions: ChannelPermissions +} + +export const DEFAULT_CHANNEL_PERMISSIONS: ChannelPermissions = { + generic: { + public: { + create: false, + delete: false, + }, + private: { + create: false, + }, + }, + channelSpecific: {}, +} diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index dd60b08e6a..ac33d344a5 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -17,6 +17,7 @@ import { ChannelMessage, AddMembersChannelPayload, AddMembersChannelResponse, + type SetChannelPermissionsPayload, } from './channel' import { DownloadStatus, @@ -130,6 +131,7 @@ export enum SocketEvents { // ====== Channels ====== CHANNEL_SUBSCRIBED = 'channelSubscribed', CHANNELS_STORED = 'channelsStored', + CHANNEL_PERMISSIONS_UPDATED = 'channelPermissionsUpdated', // ====== Messages ====== MESSAGE_IDS_STORED = 'messageIdsStored', @@ -251,6 +253,7 @@ export interface SocketEventsMap { // ====== Channels ====== [SocketEvents.CHANNEL_SUBSCRIBED]: EmitEvent [SocketEvents.CHANNELS_STORED]: EmitEvent + [SocketEvents.CHANNEL_PERMISSIONS_UPDATED]: EmitEvent // ====== Messages ====== [SocketEvents.MESSAGE_IDS_STORED]: EmitEvent From 02905687b598968fdea873b8b81aec14b899f547 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:50:18 -0400 Subject: [PATCH 06/14] Add delete to permissions type --- .../src/nest/connections-manager/connections-manager.service.ts | 1 + packages/types/src/channel.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 32e6c61ee4..efe85ae4da 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -993,6 +993,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI channelSpecific[channelId] = { addMembers: sigChain.channels.canMemberAddMembersToPrivateChannel(member.userId, channelId), removeMembers: sigChain.channels.canMemberRemoveMembersFromPrivateChannel(member.userId, channelId), + delete: sigChain.channels.canMemberDeletePrivateChannel(member.userId, channelId), } } const channelPermissions: ChannelPermissions = { diff --git a/packages/types/src/channel.ts b/packages/types/src/channel.ts index b6e93cc076..dcac5aec89 100644 --- a/packages/types/src/channel.ts +++ b/packages/types/src/channel.ts @@ -225,6 +225,7 @@ export interface GenericChannelPermissions { export interface PrivateChannelPermissions { addMembers: boolean removeMembers: boolean + delete: boolean } export interface ChannelPermissions { From a7fa5d651dac0cf731212b4e1eb5727117dc8125 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:52:23 -0400 Subject: [PATCH 07/14] Use permissions checks to gate channel creation, deletion, and membership in UI (desktop) --- .../Channel/CreateChannel/CreateChannel.tsx | 48 ++++++++++++++++--- .../menus/ChannelContextMenu.container.tsx | 32 ++++++++++--- .../renderer/components/Sidebar/Sidebar.tsx | 19 ++++++-- packages/types/src/errors.ts | 2 + 4 files changed, 85 insertions(+), 16 deletions(-) diff --git a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx index eef7345309..664b6a1d73 100644 --- a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx +++ b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import CreateChannelComponent from './CreateChannelComponent' -import { communities, errors, identity, publicChannels, users } from '@quiet/state-manager' +import { communities, errors, identity, publicChannels } from '@quiet/state-manager' import { CreateChannelPayload, ErrorCodes, ErrorMessages, SocketActions } from '@quiet/types' import { useModal } from '../../../containers/hooks' import { ModalName } from '../../../sagas/modals/modals.types' @@ -13,13 +13,14 @@ export const CreateChannel = () => { const dispatch = useDispatch() const [newChannel, setNewChannel] = useState(null) + const [canCreateChannel, setCanCreateChannel] = useState(undefined) + const [canCreatePrivateChannel, setCanCreatePrivateChannel] = useState(undefined) const user = useSelector(identity.selectors.currentIdentity) const communityId = useSelector(communities.selectors.currentCommunityId) const community = useSelector(communities.selectors.currentCommunity) const channels = useSelector(publicChannels.selectors.publicChannels) - const canCreateChannel = useSelector(publicChannels.selectors.canCreateChannel) - const canCreatePrivateChannel = useSelector(publicChannels.selectors.canCreatePrivateChannel) + const channelPermissions = useSelector(publicChannels.selectors.channelPermissions) const communityErrors = useSelector(errors.selectors.currentCommunityErrors) const error = communityErrors[SocketActions.CREATE_CHANNEL] @@ -40,6 +41,16 @@ export const CreateChannel = () => { } }, [channels]) + useEffect(() => { + if (channelPermissions == null) { + setCanCreateChannel(undefined) + setCanCreatePrivateChannel(undefined) + } else { + setCanCreateChannel(channelPermissions.generic.public.create) + setCanCreatePrivateChannel(channelPermissions.generic.private.create) + } + }, [channelPermissions]) + const clearErrors = () => { if (error) { dispatch(errors.actions.clearError(error)) @@ -86,6 +97,31 @@ export const CreateChannel = () => { ) return } + const hasPermission = isPublic ? canCreateChannel : canCreatePrivateChannel + if (hasPermission == null) { + logger.error('Channel permissions are nullish') + dispatch( + errors.actions.addError({ + type: SocketActions.CREATE_CHANNEL, + code: ErrorCodes.NOT_FOUND, + message: ErrorMessages.CHANNEL_PERMISSIONS_NOT_FOUND, + community: communityId, + }) + ) + return + } + if (!hasPermission) { + logger.error('User lacks permissions to perform this action') + dispatch( + errors.actions.addError({ + type: SocketActions.CREATE_CHANNEL, + code: ErrorCodes.FORBIDDEN, + message: ErrorMessages.CHANNEL_PERMISSIONS_INVALID, + community: communityId, + }) + ) + return + } const payload = { name: name, description: `Welcome to #${name}`, @@ -97,14 +133,14 @@ export const CreateChannel = () => { } return ( <> - {canCreateChannel && communityId && ( + {(canCreateChannel || canCreatePrivateChannel) && communityId && ( )} diff --git a/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx b/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx index 6cf5c47309..8a1ecc865d 100644 --- a/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx +++ b/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx @@ -16,12 +16,12 @@ import ChannelTypeIcon from '../../widgets/channels/ChannelTypeIcon' export const ChannelContextMenu: FC = () => { const [showDebug, setShowDebug] = useState(false) - const [isChannelOwner, setIsChannelOwner] = useState(false) + const [canDelete, setCanDelete] = useState(false) + const [canAddMembers, setCanAddMembers] = useState(false) - const isOwner = useSelector(communities.selectors.isOwner) const channel = useSelector(publicChannels.selectors.currentChannel) const channelMessages = useSelector(publicChannels.selectors.currentChannelMessagesMergedBySender) - const me = useSelector(users.selectors.myUserProfile) + const channelPermissions = useSelector(publicChannels.selectors.channelPermissions) let title = '' if (channel) { @@ -36,11 +36,29 @@ export const ChannelContextMenu: FC = () => { const items: ContextMenuItemProps[] = [] useEffect(() => { - setIsChannelOwner(me != null && channel != null && channel.owner === me.userId) - }, [channel, me]) + if (channelPermissions == null || channel == null) { + setCanAddMembers(false) + setCanDelete(false) + return + } + + if (channel.public ?? true) { + setCanAddMembers(false) + setCanDelete(channelPermissions.generic.public.delete) + } else { + const channelSpecificPermissions = channelPermissions.channelSpecific[channel.id] + if (channelSpecificPermissions == null) { + setCanAddMembers(false) + setCanDelete(false) + } else { + setCanAddMembers(channelSpecificPermissions.addMembers) + setCanDelete(channelSpecificPermissions.delete) + } + } + }, [channel, channelPermissions]) // TODO: update this to actually use the LFA admin role - if (!(channel?.public ?? true)) { + if (canAddMembers === true) { items.push({ title: 'Add members', action: () => { @@ -50,7 +68,7 @@ export const ChannelContextMenu: FC = () => { }) } - if (isOwner) { + if (canDelete) { items.push({ title: 'Delete', action: () => { diff --git a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx index 2013ce6ce0..5f180bd398 100644 --- a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useModal } from '../../containers/hooks' import { useContextMenu } from '../../../hooks/useContextMenu' @@ -14,6 +14,9 @@ import { DirectMessagesPanelProps } from './DirectMessagesPanel/DirectMessagesPa const Sidebar = () => { const dispatch = useDispatch() + const [canCreateChannel, setCanCreateChannel] = useState(undefined) + const [canCreatePrivateChannel, setCanCreatePrivateChannel] = useState(undefined) + const createChannelModal = useModal(ModalName.createChannel) const accountSettingsModal = useModal(ModalName.accountSettingsModal) @@ -26,7 +29,7 @@ const Sidebar = () => { const currentChannelId = useSelector(publicChannels.selectors.currentChannelId) const currentIdentity = useSelector(identity.selectors.currentIdentity) const userProfile = useSelector(users.selectors.myUserProfile) - const canCreateChannel = useSelector(publicChannels.selectors.canCreateChannel) + const channelPermissions = useSelector(publicChannels.selectors.channelPermissions) const userId = userProfile?.userId || '' // Workaround for Redux bug, issue: https://github.com/TryQuiet/quiet/issues/1332 @@ -34,6 +37,16 @@ const Sidebar = () => { const publicChannelsSelector = useSelector(publicChannels.selectors.publicChannels) const isTorInitialized = useSelector(connection.selectors.isTorInitialized) + useEffect(() => { + if (channelPermissions == null) { + setCanCreateChannel(undefined) + setCanCreatePrivateChannel(undefined) + } else { + setCanCreateChannel(channelPermissions.generic.public.create) + setCanCreatePrivateChannel(channelPermissions.generic.private.create) + } + }, [channelPermissions]) + const setCurrentChannel = (id: string) => { dispatch( publicChannels.actions.setCurrentChannel({ @@ -60,7 +73,7 @@ const Sidebar = () => { currentChannelId: currentChannelId, createChannelModal: createChannelModal, isTorInitialized: isTorInitialized, - canCreateChannel: canCreateChannel, + canCreateChannel: (canCreateChannel || canCreatePrivateChannel) ?? false, } const userProfilePanelProps: UserProfilePanelProps = { diff --git a/packages/types/src/errors.ts b/packages/types/src/errors.ts index ed28c6c9c9..9316f4f699 100644 --- a/packages/types/src/errors.ts +++ b/packages/types/src/errors.ts @@ -55,6 +55,8 @@ export enum ErrorMessages { // Channels CHANNEL_NAME_TAKEN = 'Channel with this name already exists', + CHANNEL_PERMISSIONS_NOT_FOUND = `Channel permissions couldn't be determined`, + CHANNEL_PERMISSIONS_INVALID = `You don't have permission to perform this channel action`, // General GENERAL = 'Something went wrong', From 372edf160be6d2aff16e78c8dd76e7320575e6b6 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:33:37 -0400 Subject: [PATCH 08/14] Better format for storing permissions in state-manager --- .../connections-manager.service.ts | 17 +++++------ .../Channel/CreateChannel/CreateChannel.tsx | 6 ++-- .../menus/ChannelContextMenu.container.tsx | 16 +++++------ .../renderer/components/Sidebar/Sidebar.tsx | 6 ++-- .../publicChannels/publicChannels.adapter.ts | 5 ++++ .../publicChannels.selectors.ts | 25 +++++++++++++++-- .../publicChannels/publicChannels.slice.ts | 16 +++++++---- .../publicChannels.transform.ts | 7 ++++- packages/types/src/channel.ts | 28 ++++++++----------- 9 files changed, 78 insertions(+), 48 deletions(-) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index efe85ae4da..18da5ed157 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -56,8 +56,8 @@ import { UserProfilesUpdatedPayload, UpdateCommunityPayload, ChannelOperationStatus, - type ChannelPermissions, type PrivateChannelPermissions, + type SetChannelPermissionsPayload, } from '@quiet/types' import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const' import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service' @@ -988,16 +988,17 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI ? member.roles.filter(roleName => roleName in channelMapping).map(roleName => channelMapping[roleName].id) : [] if (member.userId === sigChain.user.userId) { - const channelSpecific: { [channelId: string]: PrivateChannelPermissions } = {} + const channelSpecificPermissions: PrivateChannelPermissions[] = [] for (const channelId of privateChannelIds) { - channelSpecific[channelId] = { + channelSpecificPermissions.push({ + channelId, addMembers: sigChain.channels.canMemberAddMembersToPrivateChannel(member.userId, channelId), removeMembers: sigChain.channels.canMemberRemoveMembersFromPrivateChannel(member.userId, channelId), delete: sigChain.channels.canMemberDeletePrivateChannel(member.userId, channelId), - } + }) } - const channelPermissions: ChannelPermissions = { - generic: { + const payload: SetChannelPermissionsPayload = { + genericPermissions: { public: { create: sigChain.channels.canMemberCreatePublicChannel(member.userId), delete: sigChain.channels.canMemberDeletePublicChannel(member.userId), @@ -1006,9 +1007,9 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI create: sigChain.channels.canMemberCreatePrivateChannel(member.userId), }, }, - channelSpecific, + channelSpecificPermissions, } - this.serverIoProvider.io.emit(SocketEvents.CHANNEL_PERMISSIONS_UPDATED, { channelPermissions }) + this.serverIoProvider.io.emit(SocketEvents.CHANNEL_PERMISSIONS_UPDATED, payload) } return { userId: member.userId, diff --git a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx index 664b6a1d73..0ac85edf3b 100644 --- a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx +++ b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx @@ -20,7 +20,7 @@ export const CreateChannel = () => { const communityId = useSelector(communities.selectors.currentCommunityId) const community = useSelector(communities.selectors.currentCommunity) const channels = useSelector(publicChannels.selectors.publicChannels) - const channelPermissions = useSelector(publicChannels.selectors.channelPermissions) + const channelPermissions = useSelector(publicChannels.selectors.genericChannelPermissions) const communityErrors = useSelector(errors.selectors.currentCommunityErrors) const error = communityErrors[SocketActions.CREATE_CHANNEL] @@ -46,8 +46,8 @@ export const CreateChannel = () => { setCanCreateChannel(undefined) setCanCreatePrivateChannel(undefined) } else { - setCanCreateChannel(channelPermissions.generic.public.create) - setCanCreatePrivateChannel(channelPermissions.generic.private.create) + setCanCreateChannel(channelPermissions.public.create) + setCanCreatePrivateChannel(channelPermissions.private.create) } }, [channelPermissions]) diff --git a/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx b/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx index 8a1ecc865d..ec353f7eb6 100644 --- a/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx +++ b/packages/desktop/src/renderer/components/ContextMenu/menus/ChannelContextMenu.container.tsx @@ -21,7 +21,8 @@ export const ChannelContextMenu: FC = () => { const channel = useSelector(publicChannels.selectors.currentChannel) const channelMessages = useSelector(publicChannels.selectors.currentChannelMessagesMergedBySender) - const channelPermissions = useSelector(publicChannels.selectors.channelPermissions) + const genericChannelPermissions = useSelector(publicChannels.selectors.genericChannelPermissions) + const currentChannelPermissions = useSelector(publicChannels.selectors.currentChannelPermissions) let title = '' if (channel) { @@ -36,7 +37,7 @@ export const ChannelContextMenu: FC = () => { const items: ContextMenuItemProps[] = [] useEffect(() => { - if (channelPermissions == null || channel == null) { + if (channel == null) { setCanAddMembers(false) setCanDelete(false) return @@ -44,18 +45,17 @@ export const ChannelContextMenu: FC = () => { if (channel.public ?? true) { setCanAddMembers(false) - setCanDelete(channelPermissions.generic.public.delete) + setCanDelete(genericChannelPermissions.public.delete) } else { - const channelSpecificPermissions = channelPermissions.channelSpecific[channel.id] - if (channelSpecificPermissions == null) { + if (currentChannelPermissions == null) { setCanAddMembers(false) setCanDelete(false) } else { - setCanAddMembers(channelSpecificPermissions.addMembers) - setCanDelete(channelSpecificPermissions.delete) + setCanAddMembers(currentChannelPermissions.addMembers) + setCanDelete(currentChannelPermissions.delete) } } - }, [channel, channelPermissions]) + }, [channel, genericChannelPermissions, currentChannelPermissions]) // TODO: update this to actually use the LFA admin role if (canAddMembers === true) { diff --git a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx index 5f180bd398..4d10ad370d 100644 --- a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx @@ -29,7 +29,7 @@ const Sidebar = () => { const currentChannelId = useSelector(publicChannels.selectors.currentChannelId) const currentIdentity = useSelector(identity.selectors.currentIdentity) const userProfile = useSelector(users.selectors.myUserProfile) - const channelPermissions = useSelector(publicChannels.selectors.channelPermissions) + const channelPermissions = useSelector(publicChannels.selectors.genericChannelPermissions) const userId = userProfile?.userId || '' // Workaround for Redux bug, issue: https://github.com/TryQuiet/quiet/issues/1332 @@ -42,8 +42,8 @@ const Sidebar = () => { setCanCreateChannel(undefined) setCanCreatePrivateChannel(undefined) } else { - setCanCreateChannel(channelPermissions.generic.public.create) - setCanCreatePrivateChannel(channelPermissions.generic.private.create) + setCanCreateChannel(channelPermissions.public.create) + setCanCreatePrivateChannel(channelPermissions.private.create) } }, [channelPermissions]) diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.adapter.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.adapter.ts index 704768b4c3..b40538a4ea 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.adapter.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.adapter.ts @@ -1,4 +1,5 @@ import { + PrivateChannelPermissions, type ChannelMessage, type PublicChannelStatus, type PublicChannelStorage, @@ -19,3 +20,7 @@ export const publicChannelsSubscriptionsAdapter = createEntityAdapter() + +export const channelSpecificPermissionsAdapter = createEntityAdapter({ + selectId: permissions => permissions.channelId, +}) diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts index f3b296c32b..112398e131 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts @@ -5,6 +5,7 @@ import { channelMessagesAdapter, publicChannelsStatusAdapter, publicChannelsSubscriptionsAdapter, + channelSpecificPermissionsAdapter, } from './publicChannels.adapter' import { type CreatedSelectors, type StoreState } from '../store.types' import { userProfiles } from '../users/userProfile/userProfile.selectors' @@ -339,10 +340,26 @@ export const canCreatePrivateChannel = createSelector(selectState, () => { return true }) -export const channelPermissions = createSelector(selectState, state => { - return state.channelPermissions +export const genericChannelPermissions = createSelector(selectState, state => { + return state.genericChannelPermissions }) +export const allChannelSpecificPermissions = createSelector(selectState, state => { + return channelSpecificPermissionsAdapter.getSelectors().selectEntities(state.channelSpecificPermissions) +}) + +export const currentChannelPermissions = createSelector( + selectState, + currentChannelId, + currentChannel, + (state, currentChannelId, currentChannel) => { + if (currentChannelId == null || currentChannel == null || currentChannel.public) return undefined + return channelSpecificPermissionsAdapter + .getSelectors() + .selectById(state.channelSpecificPermissions, currentChannelId) + } +) + export const publicChannelsSelectors = { publicChannels, subscribedChannels, @@ -370,5 +387,7 @@ export const publicChannelsSelectors = { areChannelsLoaded, canCreateChannel, canCreatePrivateChannel, - channelPermissions, + genericChannelPermissions, + allChannelSpecificPermissions, + currentChannelPermissions, } diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts index 429d02185c..3d8bebd0cc 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.slice.ts @@ -5,6 +5,7 @@ import { channelMessagesAdapter, publicChannelsStatusAdapter, publicChannelsSubscriptionsAdapter, + channelSpecificPermissionsAdapter, } from './publicChannels.adapter' import { @@ -29,9 +30,10 @@ import { type UpdateNewestMessagePayload, type AddMembersChannelPayload, ChannelOperationStatus, - type ChannelPermissions, - DEFAULT_CHANNEL_PERMISSIONS, + type GenericChannelPermissions, + DEFAULT_GENERIC_CHANNEL_PERMISSIONS, type SetChannelPermissionsPayload, + type PrivateChannelPermissions, } from '@quiet/types' import { createLogger } from '../../utils/logger' @@ -49,7 +51,10 @@ export class PublicChannelsState { public channelsSubscriptions: EntityState = publicChannelsSubscriptionsAdapter.getInitialState() - public channelPermissions: ChannelPermissions = DEFAULT_CHANNEL_PERMISSIONS + public genericChannelPermissions: GenericChannelPermissions = DEFAULT_GENERIC_CHANNEL_PERMISSIONS + + public channelSpecificPermissions: EntityState = + channelSpecificPermissionsAdapter.getInitialState() } export const publicChannelsSlice = createSlice({ @@ -162,8 +167,9 @@ export const publicChannelsSlice = createSlice({ }) }, setChannelPermissions: (state, action: PayloadAction) => { - const { channelPermissions } = action.payload - state.channelPermissions = channelPermissions + const { genericPermissions, channelSpecificPermissions } = action.payload + state.genericChannelPermissions = genericPermissions + channelSpecificPermissionsAdapter.upsertMany(state.channelSpecificPermissions, channelSpecificPermissions) }, // Utility action for testing purposes test_message: ( diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.transform.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.transform.ts index 7847549acb..c4fa179043 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.transform.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.transform.ts @@ -1,7 +1,11 @@ import { INITIAL_CURRENT_CHANNEL_ID } from '@quiet/types' import { createTransform } from 'redux-persist' import { StoreKeys } from '../store.keys' -import { publicChannelsAdapter, publicChannelsSubscriptionsAdapter } from './publicChannels.adapter' +import { + channelSpecificPermissionsAdapter, + publicChannelsAdapter, + publicChannelsSubscriptionsAdapter, +} from './publicChannels.adapter' import { type PublicChannelsState } from './publicChannels.slice' import { createLogger } from '../../utils/logger' @@ -20,6 +24,7 @@ export const PublicChannelsTransform = createTransform( ...transformedOutboundState, currentChannelId: generalChannelId, channelsSubscriptions: publicChannelsSubscriptionsAdapter.getInitialState(), + channelSpecificPermissions: channelSpecificPermissionsAdapter.getInitialState(), } }, { whitelist: [StoreKeys.PublicChannels] } diff --git a/packages/types/src/channel.ts b/packages/types/src/channel.ts index dcac5aec89..7babd68838 100644 --- a/packages/types/src/channel.ts +++ b/packages/types/src/channel.ts @@ -223,29 +223,23 @@ export interface GenericChannelPermissions { } export interface PrivateChannelPermissions { + channelId: string addMembers: boolean removeMembers: boolean delete: boolean } -export interface ChannelPermissions { - generic: GenericChannelPermissions - channelSpecific: Record +export interface SetChannelPermissionsPayload { + genericPermissions: GenericChannelPermissions + channelSpecificPermissions: PrivateChannelPermissions[] } -export interface SetChannelPermissionsPayload { - channelPermissions: ChannelPermissions -} - -export const DEFAULT_CHANNEL_PERMISSIONS: ChannelPermissions = { - generic: { - public: { - create: false, - delete: false, - }, - private: { - create: false, - }, +export const DEFAULT_GENERIC_CHANNEL_PERMISSIONS: GenericChannelPermissions = { + public: { + create: false, + delete: false, + }, + private: { + create: false, }, - channelSpecific: {}, } From 19ab2f86e76424b1297a170b4529530aedae613b Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:06:38 -0400 Subject: [PATCH 09/14] Use permissions checks in mobile --- .../Channel/CreateChannel/CreateChannel.tsx | 17 +- .../renderer/components/Sidebar/Sidebar.tsx | 13 +- .../ChannelMembership.component.tsx | 53 +- .../ChannelMembership.stories.tsx | 1 + .../ChannelMembership.test.tsx | 528 +++++++++++++++++- .../ChannelMembership.types.ts | 1 + .../ContextMenu/ContextMenu.component.tsx | 1 + .../ContextMenu/ContextMenu.test.tsx | 6 + .../menus/ChannelContextMenu.container.tsx | 34 +- .../menus/ChannelContextMenu.test.tsx | 162 ++++++ .../menus/CommunityContextMenu.container.tsx | 10 +- .../menus/CommunityContextMenu.test.tsx | 24 + .../ChannelMembership.screen.tsx | 3 +- .../CreateChannel/CreateChannel.screen.tsx | 21 +- .../CreateChannel/CreateChannel.test.tsx | 23 + .../src/utils/tests/factories.ts | 18 + 16 files changed, 854 insertions(+), 61 deletions(-) create mode 100644 packages/mobile/src/components/ContextMenu/menus/ChannelContextMenu.test.tsx diff --git a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx index 0ac85edf3b..0d006451ef 100644 --- a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx +++ b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx @@ -13,8 +13,8 @@ export const CreateChannel = () => { const dispatch = useDispatch() const [newChannel, setNewChannel] = useState(null) - const [canCreateChannel, setCanCreateChannel] = useState(undefined) - const [canCreatePrivateChannel, setCanCreatePrivateChannel] = useState(undefined) + const [canCreateChannel, setCanCreateChannel] = useState(false) + const [canCreatePrivateChannel, setCanCreatePrivateChannel] = useState(false) const user = useSelector(identity.selectors.currentIdentity) const communityId = useSelector(communities.selectors.currentCommunityId) @@ -42,13 +42,8 @@ export const CreateChannel = () => { }, [channels]) useEffect(() => { - if (channelPermissions == null) { - setCanCreateChannel(undefined) - setCanCreatePrivateChannel(undefined) - } else { - setCanCreateChannel(channelPermissions.public.create) - setCanCreatePrivateChannel(channelPermissions.private.create) - } + setCanCreateChannel(channelPermissions.public.create) + setCanCreatePrivateChannel(channelPermissions.private.create) }, [channelPermissions]) const clearErrors = () => { @@ -139,8 +134,8 @@ export const CreateChannel = () => { channelCreationError={error?.message} createChannel={createChannel} clearErrorsDispatch={clearErrors} - canCreateChannel={canCreateChannel!} - canCreatePrivateChannel={canCreatePrivateChannel!} + canCreateChannel={canCreateChannel} + canCreatePrivateChannel={canCreatePrivateChannel} /> )} diff --git a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx index 4d10ad370d..3ba92605f4 100644 --- a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx @@ -14,8 +14,8 @@ import { DirectMessagesPanelProps } from './DirectMessagesPanel/DirectMessagesPa const Sidebar = () => { const dispatch = useDispatch() - const [canCreateChannel, setCanCreateChannel] = useState(undefined) - const [canCreatePrivateChannel, setCanCreatePrivateChannel] = useState(undefined) + const [canCreateChannel, setCanCreateChannel] = useState(false) + const [canCreatePrivateChannel, setCanCreatePrivateChannel] = useState(false) const createChannelModal = useModal(ModalName.createChannel) const accountSettingsModal = useModal(ModalName.accountSettingsModal) @@ -38,13 +38,8 @@ const Sidebar = () => { const isTorInitialized = useSelector(connection.selectors.isTorInitialized) useEffect(() => { - if (channelPermissions == null) { - setCanCreateChannel(undefined) - setCanCreatePrivateChannel(undefined) - } else { - setCanCreateChannel(channelPermissions.public.create) - setCanCreatePrivateChannel(channelPermissions.private.create) - } + setCanCreateChannel(channelPermissions.public.create) + setCanCreatePrivateChannel(channelPermissions.private.create) }, [channelPermissions]) const setCurrentChannel = (id: string) => { diff --git a/packages/mobile/src/components/ChannelMembership/ChannelMembership.component.tsx b/packages/mobile/src/components/ChannelMembership/ChannelMembership.component.tsx index 4ee3f42f6b..02f7dda89d 100644 --- a/packages/mobile/src/components/ChannelMembership/ChannelMembership.component.tsx +++ b/packages/mobile/src/components/ChannelMembership/ChannelMembership.component.tsx @@ -12,12 +12,12 @@ import { defaultTheme } from '../../styles/themes/default.theme' import { useDispatch, useSelector } from 'react-redux' import { navigationActions } from '../../store/navigation/navigation.slice' import { ScreenNames } from '../../const/ScreenNames.enum' -import { communities } from '@quiet/state-manager' +import { publicChannels } from '@quiet/state-manager' const logger = createLogger('ChannelMembership') -const TITLE = 'Permissions' -const NON_OWNER_TITLE = 'Members' +const MODIFIABLE_MEMBERSHIP_TITLE = 'Permissions' +const NON_MODIFIABLE_MEMBERSHIP_TITLE = 'Members' export const ChannelMembership: React.FC = ({ channelName, @@ -25,6 +25,7 @@ export const ChannelMembership: React.FC = ({ community, members, memberCount, + canAddMembers, handleBackButton, }) => { const dispatch = useDispatch() @@ -32,7 +33,7 @@ export const ChannelMembership: React.FC = ({ const [loading, setLoading] = useState(false) const [headerTitle, setHeaderTitle] = useState('') - const isOwner = useSelector(communities.selectors.isOwner) + const channel = useSelector(publicChannels.selectors.currentChannel) const onPress = useCallback(() => { setLoading(true) @@ -61,8 +62,8 @@ export const ChannelMembership: React.FC = ({ }, [channelName]) useEffect(() => { - setHeaderTitle(isOwner ? TITLE : NON_OWNER_TITLE) - }, [isOwner]) + setHeaderTitle(canAddMembers ? MODIFIABLE_MEMBERSHIP_TITLE : NON_MODIFIABLE_MEMBERSHIP_TITLE) + }, [canAddMembers]) return ( = ({ gap: 32, }} > - - -