From 105c8cea7fc9d70f0c2d002a12b2c5ebb9693be0 Mon Sep 17 00:00:00 2001 From: Piotr Suwala Date: Thu, 7 Mar 2024 13:13:06 +0100 Subject: [PATCH 1/5] feat(lib): check permissions before sending signals --- lib/src/entities/chat.ts | 24 ++- lib/src/pubnub-access-manager.ts | 43 ++++ lib/src/types.ts | 1 + lib/tests/pubnub-access-manager.test.ts | 256 ++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 lib/src/pubnub-access-manager.ts create mode 100644 lib/tests/pubnub-access-manager.test.ts diff --git a/lib/src/entities/chat.ts b/lib/src/entities/chat.ts index 7398c8c..c88fb12 100644 --- a/lib/src/entities/chat.ts +++ b/lib/src/entities/chat.ts @@ -23,6 +23,7 @@ import { getErrorProxiedEntity, ErrorLogger } from "../error-logging" import { cyrb53a } from "../hash" import { uuidv4 } from "../uuidv4" import { defaultEditActionName, defaultDeleteActionName } from "../default-values" +import { PubnubAccessManager } from "../pubnub-access-manager" export type ChatConfig = { saveDebugLog: boolean @@ -47,6 +48,7 @@ export type ChatConfig = { editMessageActionName?: string deleteMessageActionName?: string } + authKey?: string } type ChatConstructor = Partial & PubNub.PubnubConfig @@ -69,6 +71,8 @@ export class Chat { readonly editMessageActionName: string /** @internal */ readonly deleteMessageActionName: string + /** @internal */ + readonly pubnubAccessManager: PubnubAccessManager /** @internal */ private constructor(params: ChatConstructor) { @@ -136,7 +140,10 @@ export class Chat { getMessagePublishBody: customPayloads?.getMessagePublishBody, getMessageResponseBody: customPayloads?.getMessageResponseBody, }, + authKey: pubnubConfig.authKey, } as ChatConfig + + this.pubnubAccessManager = new PubnubAccessManager(this) } static async init(params: ChatConstructor) { @@ -185,7 +192,22 @@ export class Chat { /* @internal */ signal(params: { channel: string; message: any }) { - return this.sdk.signal(params) + const canISendSignal = this.pubnubAccessManager.canI({ + permission: "write", + resourceName: params.channel, + resourceType: "channels", + }) + if (canISendSignal) { + return this.sdk.signal(params) + } + + if (this.config.saveDebugLog) { + console.warn( + `You tried to send a signal containing message: ${JSON.stringify( + params.message + )} to channel: ${params.channel} but PubNub Access Manager prevented you from doing so.` + ) + } } /** diff --git a/lib/src/pubnub-access-manager.ts b/lib/src/pubnub-access-manager.ts new file mode 100644 index 0000000..11e6210 --- /dev/null +++ b/lib/src/pubnub-access-manager.ts @@ -0,0 +1,43 @@ +import { GrantTokenPermissions } from "pubnub" +import type { Chat } from "./entities/chat" + +export class PubnubAccessManager { + chat: Chat + + constructor(chat: Chat) { + this.chat = chat + } + + canI({ + permission, + resourceType, + resourceName, + }: { + permission: keyof GrantTokenPermissions + resourceName: string + resourceType: "channels" | "uuids" + }) { + const authKey = this.chat.config.authKey + // we assume PAM is not enabled + if (!authKey) { + return true + } + + const parsedToken = this.chat.sdk.parseToken(authKey) + const resourcePermission = parsedToken.resources?.[resourceType]?.[resourceName]?.[permission] + if (typeof resourcePermission === "boolean") { + return resourcePermission + } + const resourcePatterns = parsedToken.patterns?.[resourceType] || {} + const resourcePatternsKeys = Object.keys(resourcePatterns) + for (const pattern of resourcePatternsKeys) { + const regexp = new RegExp(pattern) + const matches = regexp.test(resourceName) + if (matches) { + return resourcePatterns[pattern][permission] || false + } + } + + return false + } +} diff --git a/lib/src/types.ts b/lib/src/types.ts index 5182fe3..e9a7679 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -7,6 +7,7 @@ import PubNub, { import { User } from "./entities/user" import { Message } from "./entities/message" import { Event } from "./entities/event" +import { Chat } from "./entities/chat" export type ChannelType = "direct" | "group" | "public" | "unknown" diff --git a/lib/tests/pubnub-access-manager.test.ts b/lib/tests/pubnub-access-manager.test.ts new file mode 100644 index 0000000..a46b7bf --- /dev/null +++ b/lib/tests/pubnub-access-manager.test.ts @@ -0,0 +1,256 @@ +import { createChatInstance } from "./utils" +import { Chat } from "../src" +import { jest } from "@jest/globals" + +const parseTokenReturnValue = { + resources: { + channels: { + "channel-a": { + read: true, + write: true, + manage: false, + delete: false, + get: true, + update: false, + join: true, + }, + "channel-b": { + read: true, + write: false, + manage: false, + delete: false, + get: true, + update: false, + join: true, + }, + }, + uuids: { + some_uuid: { + read: true, + write: true, + manage: true, + delete: true, + get: true, + update: true, + join: true, + }, + random_uuid: { + read: true, + write: false, + manage: false, + delete: false, + get: false, + update: false, + join: false, + }, + }, + }, + patterns: { + channels: { + "^(?:group-room-){1}(?:.*)$": { + read: true, + write: true, + manage: false, + delete: false, + get: true, + update: true, + join: true, + }, + "^(?:public-room-){1}(?:.*)$": { + read: true, + write: false, + manage: false, + delete: false, + get: false, + update: false, + join: true, + }, + "^(?:unknown-room-){1}(?:.*)$": { + read: true, + write: false, + manage: false, + delete: false, + get: true, + update: false, + join: false, + }, + }, + }, +} + +describe("Pubnub Access Manager", () => { + let chat: Chat + + test("should acknowledge proper access on 'channels' based on resources", async () => { + chat = await createChatInstance({ + config: { + authKey: "abc", + }, + }) + + const parseTokenSpy = jest.spyOn(chat.sdk, "parseToken").mockImplementation(() => { + return parseTokenReturnValue + }) + + const channelAPermissions = parseTokenReturnValue.resources.channels["channel-a"] + for (const key of Object.keys(channelAPermissions)) { + expect( + chat.pubnubAccessManager.canI({ + resourceName: "channel-a", + resourceType: "channels", + permission: key, + }) + ).toBe(channelAPermissions[key]) + } + const channelBPermissions = parseTokenReturnValue.resources.channels["channel-b"] + for (const key of Object.keys(channelBPermissions)) { + expect( + chat.pubnubAccessManager.canI({ + resourceName: "channel-b", + resourceType: "channels", + permission: key, + }) + ).toBe(channelBPermissions[key]) + } + const someUuidPermissions = parseTokenReturnValue.resources.uuids["some_uuid"] + for (const key of Object.keys(someUuidPermissions)) { + expect( + chat.pubnubAccessManager.canI({ + resourceName: "some_uuid", + resourceType: "uuids", + permission: key, + }) + ).toBe(someUuidPermissions[key]) + } + const randomUuidPermissions = parseTokenReturnValue.resources.uuids["random_uuid"] + for (const key of Object.keys(randomUuidPermissions)) { + expect( + chat.pubnubAccessManager.canI({ + resourceName: "random_uuid", + resourceType: "uuids", + permission: key, + }) + ).toBe(randomUuidPermissions[key]) + } + + expect( + chat.pubnubAccessManager.canI({ + resourceName: "channel-c", + resourceType: "channels", + permission: "write", + }) + ).toBe(false) + expect( + chat.pubnubAccessManager.canI({ + resourceName: "channel-v", + resourceType: "channels", + permission: "join", + }) + ).toBe(false) + + parseTokenSpy.mockRestore() + }) + + test("should acknowledge proper access on 'channels' based on patterns", async () => { + chat = await createChatInstance({ + config: { + authKey: "hello", + }, + }) + + const parseTokenSpy = jest.spyOn(chat.sdk, "parseToken").mockImplementation(() => { + return parseTokenReturnValue + }) + + const groupRoomsPermissions = + parseTokenReturnValue.patterns.channels["^(?:group-room-){1}(?:.*)$"] + for (const key of Object.keys(groupRoomsPermissions)) { + expect( + chat.pubnubAccessManager.canI({ + resourceName: "group-room-hello", + resourceType: "channels", + permission: key, + }) + ).toBe(groupRoomsPermissions[key]) + } + const publicRoomsPermissions = + parseTokenReturnValue.patterns.channels["^(?:public-room-){1}(?:.*)$"] + for (const key of Object.keys(publicRoomsPermissions)) { + expect( + chat.pubnubAccessManager.canI({ + resourceName: "public-room-pubnub", + resourceType: "channels", + permission: key, + }) + ).toBe(publicRoomsPermissions[key]) + } + const unknownRoomsPermissions = + parseTokenReturnValue.patterns.channels["^(?:unknown-room-){1}(?:.*)$"] + for (const key of Object.keys(unknownRoomsPermissions)) { + expect( + chat.pubnubAccessManager.canI({ + resourceName: "unknown-room-pubnub", + resourceType: "channels", + permission: key, + }) + ).toBe(unknownRoomsPermissions[key]) + } + expect( + chat.pubnubAccessManager.canI({ + resourceName: "some_jibberish", + resourceType: "channels", + permission: "manage", + }) + ).toBe(false) + + parseTokenSpy.mockRestore() + }) + + test("should return false for every resource and pattern which is not found", async () => { + chat = await createChatInstance({ + config: { + authKey: "hello-world", + }, + }) + + const parseTokenSpy = jest.spyOn(chat.sdk, "parseToken").mockImplementation(() => { + return parseTokenReturnValue + }) + + expect( + chat.pubnubAccessManager.canI({ + resourceName: "some-channel", + resourceType: "channels", + permission: "join", + }) + ).toBe(false) + expect( + chat.pubnubAccessManager.canI({ + resourceName: "some-kind-of-uuid", + resourceType: "uuids", + permission: "update", + }) + ).toBe(false) + + parseTokenSpy.mockRestore() + }) + + test("should return true when auth key is not defined", async () => { + chat = await createChatInstance({ shouldCreateNewInstance: true }) + + expect( + chat.pubnubAccessManager.canI({ + resourceName: "some-channel", + resourceType: "channels", + permission: "join", + }) + ).toBe(true) + expect( + chat.pubnubAccessManager.canI({ + resourceName: "some-kind-of-uuid", + resourceType: "uuids", + permission: "update", + }) + ).toBe(true) + }) +}) From 6e73aa3a4ef7272b1c351eacd796c0aefbd35fc1 Mon Sep 17 00:00:00 2001 From: Piotr Suwala Date: Thu, 7 Mar 2024 13:50:19 +0100 Subject: [PATCH 2/5] feat(lib): remove 'pubnub' prefix --- ...ub-access-manager.ts => access-manager.ts} | 2 +- lib/src/entities/chat.ts | 8 +- ...manager.test.ts => access-manager.test.ts} | 90 ++++++++++++++++--- 3 files changed, 81 insertions(+), 19 deletions(-) rename lib/src/{pubnub-access-manager.ts => access-manager.ts} (96%) rename lib/tests/{pubnub-access-manager.test.ts => access-manager.test.ts} (76%) diff --git a/lib/src/pubnub-access-manager.ts b/lib/src/access-manager.ts similarity index 96% rename from lib/src/pubnub-access-manager.ts rename to lib/src/access-manager.ts index 11e6210..082d52d 100644 --- a/lib/src/pubnub-access-manager.ts +++ b/lib/src/access-manager.ts @@ -1,7 +1,7 @@ import { GrantTokenPermissions } from "pubnub" import type { Chat } from "./entities/chat" -export class PubnubAccessManager { +export class AccessManager { chat: Chat constructor(chat: Chat) { diff --git a/lib/src/entities/chat.ts b/lib/src/entities/chat.ts index c88fb12..eb5bbe4 100644 --- a/lib/src/entities/chat.ts +++ b/lib/src/entities/chat.ts @@ -23,7 +23,7 @@ import { getErrorProxiedEntity, ErrorLogger } from "../error-logging" import { cyrb53a } from "../hash" import { uuidv4 } from "../uuidv4" import { defaultEditActionName, defaultDeleteActionName } from "../default-values" -import { PubnubAccessManager } from "../pubnub-access-manager" +import { AccessManager } from "../access-manager" export type ChatConfig = { saveDebugLog: boolean @@ -72,7 +72,7 @@ export class Chat { /** @internal */ readonly deleteMessageActionName: string /** @internal */ - readonly pubnubAccessManager: PubnubAccessManager + readonly accessManager: AccessManager /** @internal */ private constructor(params: ChatConstructor) { @@ -143,7 +143,7 @@ export class Chat { authKey: pubnubConfig.authKey, } as ChatConfig - this.pubnubAccessManager = new PubnubAccessManager(this) + this.accessManager = new AccessManager(this) } static async init(params: ChatConstructor) { @@ -192,7 +192,7 @@ export class Chat { /* @internal */ signal(params: { channel: string; message: any }) { - const canISendSignal = this.pubnubAccessManager.canI({ + const canISendSignal = this.accessManager.canI({ permission: "write", resourceName: params.channel, resourceType: "channels", diff --git a/lib/tests/pubnub-access-manager.test.ts b/lib/tests/access-manager.test.ts similarity index 76% rename from lib/tests/pubnub-access-manager.test.ts rename to lib/tests/access-manager.test.ts index a46b7bf..f4325e2 100644 --- a/lib/tests/pubnub-access-manager.test.ts +++ b/lib/tests/access-manager.test.ts @@ -95,7 +95,7 @@ describe("Pubnub Access Manager", () => { const channelAPermissions = parseTokenReturnValue.resources.channels["channel-a"] for (const key of Object.keys(channelAPermissions)) { expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "channel-a", resourceType: "channels", permission: key, @@ -105,7 +105,7 @@ describe("Pubnub Access Manager", () => { const channelBPermissions = parseTokenReturnValue.resources.channels["channel-b"] for (const key of Object.keys(channelBPermissions)) { expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "channel-b", resourceType: "channels", permission: key, @@ -115,7 +115,7 @@ describe("Pubnub Access Manager", () => { const someUuidPermissions = parseTokenReturnValue.resources.uuids["some_uuid"] for (const key of Object.keys(someUuidPermissions)) { expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "some_uuid", resourceType: "uuids", permission: key, @@ -125,7 +125,7 @@ describe("Pubnub Access Manager", () => { const randomUuidPermissions = parseTokenReturnValue.resources.uuids["random_uuid"] for (const key of Object.keys(randomUuidPermissions)) { expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "random_uuid", resourceType: "uuids", permission: key, @@ -134,14 +134,14 @@ describe("Pubnub Access Manager", () => { } expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "channel-c", resourceType: "channels", permission: "write", }) ).toBe(false) expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "channel-v", resourceType: "channels", permission: "join", @@ -166,7 +166,7 @@ describe("Pubnub Access Manager", () => { parseTokenReturnValue.patterns.channels["^(?:group-room-){1}(?:.*)$"] for (const key of Object.keys(groupRoomsPermissions)) { expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "group-room-hello", resourceType: "channels", permission: key, @@ -177,7 +177,7 @@ describe("Pubnub Access Manager", () => { parseTokenReturnValue.patterns.channels["^(?:public-room-){1}(?:.*)$"] for (const key of Object.keys(publicRoomsPermissions)) { expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "public-room-pubnub", resourceType: "channels", permission: key, @@ -188,7 +188,7 @@ describe("Pubnub Access Manager", () => { parseTokenReturnValue.patterns.channels["^(?:unknown-room-){1}(?:.*)$"] for (const key of Object.keys(unknownRoomsPermissions)) { expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "unknown-room-pubnub", resourceType: "channels", permission: key, @@ -196,7 +196,7 @@ describe("Pubnub Access Manager", () => { ).toBe(unknownRoomsPermissions[key]) } expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "some_jibberish", resourceType: "channels", permission: "manage", @@ -218,14 +218,14 @@ describe("Pubnub Access Manager", () => { }) expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "some-channel", resourceType: "channels", permission: "join", }) ).toBe(false) expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "some-kind-of-uuid", resourceType: "uuids", permission: "update", @@ -239,18 +239,80 @@ describe("Pubnub Access Manager", () => { chat = await createChatInstance({ shouldCreateNewInstance: true }) expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "some-channel", resourceType: "channels", permission: "join", }) ).toBe(true) expect( - chat.pubnubAccessManager.canI({ + chat.accessManager.canI({ resourceName: "some-kind-of-uuid", resourceType: "uuids", permission: "update", }) ).toBe(true) }) + + test("should work if only 'resources' are provided", async () => { + chat = await createChatInstance({ + config: { + authKey: "hello", + }, + }) + + const parseTokenSpy = jest.spyOn(chat.sdk, "parseToken").mockImplementation(() => { + return { + resources: parseTokenReturnValue.resources, + } + }) + + expect( + chat.accessManager.canI({ + resourceName: "some-channel", + resourceType: "channels", + permission: "join", + }) + ).toBe(false) + expect( + chat.accessManager.canI({ + resourceName: "channel-b", + resourceType: "channels", + permission: "get", + }) + ).toBe(true) + + parseTokenSpy.mockRestore() + }) + + test("should work if only 'patterns' are provided", async () => { + chat = await createChatInstance({ + config: { + authKey: "hello", + }, + }) + + const parseTokenSpy = jest.spyOn(chat.sdk, "parseToken").mockImplementation(() => { + return { + patterns: parseTokenReturnValue.patterns, + } + }) + + expect( + chat.accessManager.canI({ + resourceName: "public-room-hello", + resourceType: "channels", + permission: "update", + }) + ).toBe(false) + expect( + chat.accessManager.canI({ + resourceName: "pattern-not-found-room", + resourceType: "channels", + permission: "get", + }) + ).toBe(false) + + parseTokenSpy.mockRestore() + }) }) From 8554bb81dbfaf09fa294305d639404f20da940e5 Mon Sep 17 00:00:00 2001 From: Piotr Suwala Date: Thu, 7 Mar 2024 14:13:31 +0100 Subject: [PATCH 3/5] feat(lib): rm an unused import --- lib/src/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/types.ts b/lib/src/types.ts index e9a7679..5182fe3 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -7,7 +7,6 @@ import PubNub, { import { User } from "./entities/user" import { Message } from "./entities/message" import { Event } from "./entities/event" -import { Chat } from "./entities/chat" export type ChannelType = "direct" | "group" | "public" | "unknown" From 6421724794ccec438344abdb2de923922422f603 Mon Sep 17 00:00:00 2001 From: Piotr Suwala Date: Thu, 7 Mar 2024 14:55:50 +0100 Subject: [PATCH 4/5] feat(lib): be more aggressive towards error --- lib/src/entities/chat.ts | 12 +++++------- lib/src/entities/membership.ts | 20 ++++++++++++++++---- lib/tests/access-manager.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/lib/src/entities/chat.ts b/lib/src/entities/chat.ts index eb5bbe4..4449580 100644 --- a/lib/src/entities/chat.ts +++ b/lib/src/entities/chat.ts @@ -201,13 +201,11 @@ export class Chat { return this.sdk.signal(params) } - if (this.config.saveDebugLog) { - console.warn( - `You tried to send a signal containing message: ${JSON.stringify( - params.message - )} to channel: ${params.channel} but PubNub Access Manager prevented you from doing so.` - ) - } + throw new Error( + `You tried to send a signal containing message: ${JSON.stringify( + params.message + )} to channel: ${params.channel} but PubNub Access Manager prevented you from doing so.` + ) } /** diff --git a/lib/src/entities/membership.ts b/lib/src/entities/membership.ts index 7f935df..6a2bba3 100644 --- a/lib/src/entities/membership.ts +++ b/lib/src/entities/membership.ts @@ -144,11 +144,23 @@ export class Membership { custom: { ...this.custom, lastReadMessageTimetoken: timetoken }, }) - await this.chat.emitEvent({ - channel: this.channel.id, - type: "receipt", - payload: { messageTimetoken: timetoken }, + const canISendSignal = this.chat.accessManager.canI({ + permission: "write", + resourceName: this.channel.id, + resourceType: "channels", }) + if (canISendSignal) { + await this.chat.emitEvent({ + channel: this.channel.id, + type: "receipt", + payload: { messageTimetoken: timetoken }, + }) + } + if (!canISendSignal && this.chat.config.saveDebugLog) { + console.warn( + `'receipt' event was not sent to channel '${this.channel.id}' because PAM did not allow it.` + ) + } return response } catch (error) { diff --git a/lib/tests/access-manager.test.ts b/lib/tests/access-manager.test.ts index f4325e2..84e902e 100644 --- a/lib/tests/access-manager.test.ts +++ b/lib/tests/access-manager.test.ts @@ -315,4 +315,30 @@ describe("Pubnub Access Manager", () => { parseTokenSpy.mockRestore() }) + + test("should throw an error when PAM does not grant 'write' access", async () => { + chat = await createChatInstance({ + config: { + authKey: "hello", + }, + }) + + const parseTokenSpy = jest.spyOn(chat.sdk, "parseToken").mockImplementation(() => { + return { + patterns: parseTokenReturnValue.patterns, + } + }) + let thrownErrorMessage = "" + try { + await chat.signal({ channel: "forbidden-channel", message: { signaling: "forbidden" } }) + } catch (e) { + thrownErrorMessage = e.message + } + + expect(thrownErrorMessage).toBe( + 'You tried to send a signal containing message: {"signaling":"forbidden"} to channel: forbidden-channel but PubNub Access Manager prevented you from doing so.' + ) + + parseTokenSpy.mockRestore() + }) }) From d4711b37f3374a0f7f07da288b1c0f8ece922341 Mon Sep 17 00:00:00 2001 From: PubNub Release Bot <120067856+pubnub-release-bot@users.noreply.github.com> Date: Mon, 11 Mar 2024 09:19:58 +0000 Subject: [PATCH 5/5] PubNub SDK v0.6.0 release. --- .pubnub.yml | 9 ++++++++- lib/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.pubnub.yml b/.pubnub.yml index 53c9954..95e2298 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,11 +1,18 @@ --- name: pubnub-js-chat -version: v0.5.2 +version: v0.6.0 scm: github.com/pubnub/js-chat schema: 1 files: - lib/dist/index.js changelog: + - date: 2024-03-11 + version: v0.6.0 + changes: + - type: feature + text: "Check PAM permissions before sending signals." + - type: feature + text: "Allow custom payloads while sending and receiving messages." - date: 2024-01-16 version: v0.5.2 changes: diff --git a/lib/package.json b/lib/package.json index 7685062..0668b02 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,6 +1,6 @@ { "name": "@pubnub/chat", - "version": "0.5.2", + "version": "0.6.0", "description": "PubNub JavaScript Chat SDK", "author": "PubNub ", "license": "SEE LICENSE IN LICENSE",