From 9bf52347302e244499fa5200ff84e2147db2a9a3 Mon Sep 17 00:00:00 2001 From: Alexey Zinoviev Date: Sat, 5 Apr 2025 17:43:22 +0400 Subject: [PATCH] uberf-9726: manage integrations in accounts (#8475) --- packages/account-client/src/client.ts | 124 +- packages/account-client/src/types.ts | 19 + packages/platform/src/platform.ts | 7 +- .../account/src/__tests__/operations.test.ts | 107 +- server/account/src/__tests__/postgres.test.ts | 6 +- .../src/__tests__/serviceOperations.test.ts | 1009 +++++++++++++++++ server/account/src/__tests__/utils.test.ts | 2 +- server/account/src/collections/mongo.ts | 8 +- server/account/src/collections/postgres.ts | 116 +- server/account/src/operations.ts | 557 +-------- server/account/src/serviceOperations.ts | 805 +++++++++++++ server/account/src/types.ts | 21 + server/account/src/utils.ts | 49 +- 13 files changed, 2156 insertions(+), 674 deletions(-) create mode 100644 server/account/src/__tests__/serviceOperations.test.ts create mode 100644 server/account/src/serviceOperations.ts diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index 7041c8e983..4697cd8ecd 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -38,7 +38,11 @@ import type { WorkspaceLoginInfo, RegionInfo, WorkspaceOperation, - MailboxInfo + MailboxInfo, + Integration, + IntegrationKey, + IntegrationSecret, + IntegrationSecretKey } from './types' /** @public */ @@ -136,6 +140,25 @@ export interface AccountClient { lastName: string ) => Promise<{ uuid: PersonUuid, socialId: PersonId }> addSocialIdToPerson: (person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean) => Promise + createIntegration: (integration: Integration) => Promise + updateIntegration: (integration: Integration) => Promise + deleteIntegration: (integrationKey: IntegrationKey) => Promise + getIntegration: (integrationKey: IntegrationKey) => Promise + listIntegrations: (filter: { + socialId?: PersonId + kind?: string + workspaceUuid?: WorkspaceUuid | null + }) => Promise + addIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise + updateIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise + deleteIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise + getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise + listIntegrationsSecrets: (filter: { + socialId?: PersonId + kind?: string + workspaceUuid?: WorkspaceUuid | null + key?: string + }) => Promise setCookie: () => Promise deleteCookie: () => Promise @@ -721,6 +744,105 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } + async createIntegration (integration: Integration): Promise { + const request = { + method: 'createIntegration' as const, + params: integration + } + + await this.rpc(request) + } + + async updateIntegration (integration: Integration): Promise { + const request = { + method: 'updateIntegration' as const, + params: integration + } + + await this.rpc(request) + } + + async deleteIntegration (integrationKey: IntegrationKey): Promise { + const request = { + method: 'deleteIntegration' as const, + params: integrationKey + } + + await this.rpc(request) + } + + async getIntegration (integrationKey: IntegrationKey): Promise { + const request = { + method: 'getIntegration' as const, + params: integrationKey + } + + return await this.rpc(request) + } + + async listIntegrations (filter: { + socialId?: PersonId + kind?: string + workspaceUuid?: WorkspaceUuid | null + }): Promise { + const request = { + method: 'listIntegrations' as const, + params: filter + } + + return await this.rpc(request) + } + + async addIntegrationSecret (integrationSecret: IntegrationSecret): Promise { + const request = { + method: 'addIntegrationSecret' as const, + params: integrationSecret + } + + await this.rpc(request) + } + + async updateIntegrationSecret (integrationSecret: IntegrationSecret): Promise { + const request = { + method: 'updateIntegrationSecret' as const, + params: integrationSecret + } + + await this.rpc(request) + } + + async deleteIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise { + const request = { + method: 'deleteIntegrationSecret' as const, + params: integrationSecretKey + } + + await this.rpc(request) + } + + async getIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise { + const request = { + method: 'getIntegrationSecret' as const, + params: integrationSecretKey + } + + return await this.rpc(request) + } + + async listIntegrationsSecrets (filter: { + socialId?: PersonId + kind?: string + workspaceUuid?: WorkspaceUuid | null + key?: string + }): Promise { + const request = { + method: 'listIntegrationsSecrets' as const, + params: filter + } + + return await this.rpc(request) + } + async setCookie (): Promise { const url = concatLink(this.url, '/cookie') const response = await fetch(url, { ...this.request, method: 'PUT' }) diff --git a/packages/account-client/src/types.ts b/packages/account-client/src/types.ts index 84ed881b69..2bf6795eea 100644 --- a/packages/account-client/src/types.ts +++ b/packages/account-client/src/types.ts @@ -55,3 +55,22 @@ export interface MailboxInfo { aliases: string[] appPasswords: string[] } + +export interface Integration { + socialId: PersonId + kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + workspaceUuid?: WorkspaceUuid + data?: Record +} + +export type IntegrationKey = Omit + +export interface IntegrationSecret { + socialId: PersonId + kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + workspaceUuid?: WorkspaceUuid + key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc. + secret: string +} + +export type IntegrationSecretKey = Omit diff --git a/packages/platform/src/platform.ts b/packages/platform/src/platform.ts index 624c807cb0..1915429151 100644 --- a/packages/platform/src/platform.ts +++ b/packages/platform/src/platform.ts @@ -13,7 +13,6 @@ // See the License for the specific language governing permissions and // limitations under the License. */ - import { Metadata, PluginLoader, PluginModule, Resources } from '.' /** @@ -152,9 +151,13 @@ export default plugin(platformId, { WorkspaceNotFound: '' as StatusCode<{ workspaceUuid?: string, workspaceName?: string, workspaceUrl?: string }>, WorkspaceArchived: '' as StatusCode<{ workspaceUuid: string }>, WorkspaceMigration: '' as StatusCode<{ workspaceUuid: string }>, - SocialIdNotFound: '' as StatusCode<{ socialId: string, type: string }>, + SocialIdNotFound: '' as StatusCode<{ value?: string, type?: string, _id?: string }>, SocialIdNotConfirmed: '' as StatusCode<{ socialId: string, type: string }>, SocialIdAlreadyConfirmed: '' as StatusCode<{ socialId: string, type: string }>, + IntegrationAlreadyExists: '' as StatusCode, + IntegrationNotFound: '' as StatusCode, + IntegrationSecretAlreadyExists: '' as StatusCode, + IntegrationSecretNotFound: '' as StatusCode, PersonNotFound: '' as StatusCode<{ person: string }>, InvalidPassword: '' as StatusCode<{ account: string }>, AccountAlreadyExists: '' as StatusCode, diff --git a/server/account/src/__tests__/operations.test.ts b/server/account/src/__tests__/operations.test.ts index 6050f5b5a4..f41b1562fe 100644 --- a/server/account/src/__tests__/operations.test.ts +++ b/server/account/src/__tests__/operations.test.ts @@ -13,13 +13,12 @@ // limitations under the License. // -import { AccountRole, MeasureContext, PersonId, PersonUuid, SocialIdType, WorkspaceUuid } from '@hcengineering/core' +import { AccountRole, MeasureContext, PersonUuid, WorkspaceUuid } from '@hcengineering/core' import platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform' import { decodeTokenVerbose } from '@hcengineering/server-token' -import * as utils from '../utils' import { AccountDB } from '../types' -import { createInvite, createInviteLink, sendInvite, resendInvite, addSocialIdToPerson } from '../operations' +import { createInvite, createInviteLink, sendInvite, resendInvite } from '../operations' import { accountPlugin } from '../plugin' // Mock platform @@ -453,106 +452,4 @@ describe('invite operations', () => { expect(global.fetch).toHaveBeenCalled() }) }) - - describe('addSocialIdToPerson', () => { - const mockCtx = { - error: jest.fn() - } as unknown as MeasureContext - - const mockDb = {} as unknown as AccountDB - const mockBranding = null - const mockToken = 'test-token' - - // Create spy only for this test suite - const addSocialIdSpy = jest.spyOn(utils, 'addSocialId') - - beforeEach(() => { - jest.clearAllMocks() - }) - - afterAll(() => { - // Restore the original implementation - addSocialIdSpy.mockRestore() - }) - - test('should allow github service to add social id', async () => { - ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ - extra: { service: 'github' } - }) - const newSocialId = 'new-social-id' as PersonId - addSocialIdSpy.mockResolvedValue(newSocialId) - - const params = { - person: 'test-person' as PersonUuid, - type: SocialIdType.GITHUB, - value: 'test-value', - confirmed: true - } - - const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params) - - expect(result).toBe(newSocialId) - expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed) - }) - - test('should allow admin to add social id', async () => { - ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ - extra: { admin: 'true' } - }) - const newSocialId = 'new-social-id' as PersonId - addSocialIdSpy.mockResolvedValue(newSocialId) - - const params = { - person: 'test-person' as PersonUuid, - type: SocialIdType.GITHUB, - value: 'test-value', - confirmed: false - } - - const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params) - - expect(result).toBe(newSocialId) - expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed) - }) - - test('should throw error for unauthorized service', async () => { - ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ - extra: { service: 'other-service' } - }) - - const params = { - person: 'test-person' as PersonUuid, - type: SocialIdType.GITHUB, - value: 'test-value', - confirmed: false - } - - await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow( - new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - ) - - expect(addSocialIdSpy).not.toHaveBeenCalled() - }) - - test('should throw error for regular user', async () => { - ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ - account: 'test-account', - workspace: 'test-workspace', - extra: {} - }) - - const params = { - person: 'test-person' as PersonUuid, - type: SocialIdType.GITHUB, - value: 'test-value', - confirmed: false - } - - await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow( - new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - ) - - expect(addSocialIdSpy).not.toHaveBeenCalled() - }) - }) }) diff --git a/server/account/src/__tests__/postgres.test.ts b/server/account/src/__tests__/postgres.test.ts index 4e744b780e..4b847d95c2 100644 --- a/server/account/src/__tests__/postgres.test.ts +++ b/server/account/src/__tests__/postgres.test.ts @@ -31,6 +31,8 @@ interface TestWorkspace { lastProcessingTime?: number } +const ns = 'global_account' + describe('PostgresDbCollection', () => { let mockClient: any let collection: PostgresDbCollection @@ -40,7 +42,7 @@ describe('PostgresDbCollection', () => { unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result } - collection = new PostgresDbCollection('workspace', mockClient as Sql, 'uuid') + collection = new PostgresDbCollection('workspace', mockClient as Sql, 'uuid', ns) }) describe('getTableName', () => { @@ -208,7 +210,7 @@ describe('AccountPostgresDbCollection', () => { unsafe: jest.fn().mockResolvedValue([]) } - collection = new AccountPostgresDbCollection(mockClient as Sql) + collection = new AccountPostgresDbCollection(mockClient as Sql, ns) }) describe('getTableName', () => { diff --git a/server/account/src/__tests__/serviceOperations.test.ts b/server/account/src/__tests__/serviceOperations.test.ts new file mode 100644 index 0000000000..4fdf1869f8 --- /dev/null +++ b/server/account/src/__tests__/serviceOperations.test.ts @@ -0,0 +1,1009 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { MeasureContext, PersonId, PersonUuid, SocialIdType, WorkspaceUuid } from '@hcengineering/core' +import platform, { PlatformError, Status, Severity } from '@hcengineering/platform' +import { decodeTokenVerbose } from '@hcengineering/server-token' + +import { AccountDB, Integration, IntegrationKey, IntegrationSecret, IntegrationSecretKey } from '../types' +import * as utils from '../utils' +import { + addIntegrationSecret, + addSocialIdToPerson, + createIntegration, + deleteIntegration, + deleteIntegrationSecret, + getIntegration, + getIntegrationSecret, + listIntegrations, + listIntegrationsSecrets, + updateIntegration, + updateIntegrationSecret +} from '../serviceOperations' + +// Mock platform +jest.mock('@hcengineering/platform', () => { + const actual = jest.requireActual('@hcengineering/platform') + return { + ...actual, + ...actual.default, + getMetadata: jest.fn(), + translate: jest.fn((id, params) => `${id} << ${JSON.stringify(params)}`) + } +}) + +// Mock server-token +jest.mock('@hcengineering/server-token', () => ({ + decodeTokenVerbose: jest.fn(), + generateToken: jest.fn() +})) + +describe('addSocialIdToPerson', () => { + const mockCtx = { + error: jest.fn() + } as unknown as MeasureContext + + const mockDb = {} as unknown as AccountDB + const mockBranding = null + const mockToken = 'test-token' + + // Create spy only for this test suite + const addSocialIdSpy = jest.spyOn(utils, 'addSocialId') + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterAll(() => { + // Restore the original implementation + addSocialIdSpy.mockRestore() + }) + + test('should allow github service to add social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + const newSocialId = 'new-social-id' as PersonId + addSocialIdSpy.mockResolvedValue(newSocialId) + + const params = { + person: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value', + confirmed: true + } + + const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params) + + expect(result).toBe(newSocialId) + expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed) + }) + + test('should allow admin to add social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { admin: 'true' } + }) + const newSocialId = 'new-social-id' as PersonId + addSocialIdSpy.mockResolvedValue(newSocialId) + + const params = { + person: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value', + confirmed: false + } + + const result = await addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params) + + expect(result).toBe(newSocialId) + expect(addSocialIdSpy).toHaveBeenCalledWith(mockDb, params.person, params.type, params.value, params.confirmed) + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'other-service' } + }) + + const params = { + person: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value', + confirmed: false + } + + await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(addSocialIdSpy).not.toHaveBeenCalled() + }) + + test('should throw error for regular user', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: 'test-account', + workspace: 'test-workspace', + extra: {} + }) + + const params = { + person: 'test-person' as PersonUuid, + type: SocialIdType.GITHUB, + value: 'test-value', + confirmed: false + } + + await expect(addSocialIdToPerson(mockCtx, mockDb, mockBranding, mockToken, params)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(addSocialIdSpy).not.toHaveBeenCalled() + }) +}) + +describe('integration methods', () => { + const mockCtx = { + error: jest.fn() + } as unknown as MeasureContext + + const mockDb = { + workspace: { + findOne: jest.fn() + }, + socialId: { + findOne: jest.fn(), + find: jest.fn() + }, + integration: { + findOne: jest.fn(), + insertOne: jest.fn(), + updateOne: jest.fn(), + deleteMany: jest.fn(), + find: jest.fn() + }, + integrationSecret: { + findOne: jest.fn(), + insertOne: jest.fn(), + updateOne: jest.fn(), + deleteMany: jest.fn(), + find: jest.fn() + } + } as unknown as AccountDB + + const mockBranding = null + const mockToken = 'test-token' + + const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox'] + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('createIntegration', () => { + test('should allow allowed services to create integration', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + + const mockSocialId = 'test-social-id' as PersonId + const mockIntegration: Integration = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + data: {} + } + + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + + await createIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration) + + expect(mockDb.integration.insertOne).toHaveBeenCalledWith(mockIntegration) + }) + + test('should throw error when social id not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + + const mockIntegration: Integration = { + socialId: 'nonexistent-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + data: {} + } + + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + + await expect(createIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( + new PlatformError( + new Status(Severity.ERROR, platform.status.SocialIdNotFound, { _id: 'nonexistent-social-id' }) + ) + ) + + expect(mockDb.integration.insertOne).not.toHaveBeenCalled() + }) + + test('should throw error when workspace not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + + const mockSocialId = 'test-social-id' as PersonId + const mockIntegration: Integration = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'nonexistent-workspace' as WorkspaceUuid, + data: {} + } + + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(null) + + await expect(createIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( + new PlatformError( + new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: 'nonexistent-workspace' }) + ) + ) + + expect(mockDb.integration.insertOne).not.toHaveBeenCalled() + }) + + test('should throw error when integration already exists', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + + const mockSocialId = 'test-social-id' as PersonId + const mockIntegration: Integration = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + data: {} + } + + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue({ _id: mockSocialId }) + ;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue({ uuid: 'test-workspace' }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) + + await expect(createIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationAlreadyExists, {})) + ) + + expect(mockDb.integration.insertOne).not.toHaveBeenCalled() + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + + const mockIntegration: Integration = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + data: {} + } + + await expect(createIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integration.insertOne).not.toHaveBeenCalled() + }) + }) + + describe('updateIntegration', () => { + const mockIntegration: Integration = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + data: { someData: 'value' } + } + + test('should allow allowed service to update integration', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({ + ...mockIntegration, + data: { oldData: 'old' } + }) + + await updateIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration) + + expect(mockDb.integration.updateOne).toHaveBeenCalledWith( + { + socialId: mockIntegration.socialId, + kind: mockIntegration.kind, + workspaceUuid: mockIntegration.workspaceUuid + }, + { data: mockIntegration.data } + ) + }) + + test('should throw error when integration not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + + await expect(updateIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) + ) + + expect(mockDb.integration.updateOne).not.toHaveBeenCalled() + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + + await expect(updateIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegration)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integration.updateOne).not.toHaveBeenCalled() + }) + }) + + describe('deleteIntegration', () => { + const mockIntegrationKey: IntegrationKey = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid + } + + test('should allow allowed service to delete integration', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue({ + ...mockIntegrationKey, + data: {} + }) + + await deleteIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegrationKey) + + expect(mockDb.integration.deleteMany).toHaveBeenCalledWith(mockIntegrationKey) + }) + + test('should throw error when integration not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + + await expect(deleteIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegrationKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) + ) + + expect(mockDb.integration.deleteMany).not.toHaveBeenCalled() + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + + await expect(deleteIntegration(mockCtx, mockDb, mockBranding, mockToken, mockIntegrationKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integration.deleteMany).not.toHaveBeenCalled() + }) + }) + + describe('getIntegration', () => { + const mockKey: IntegrationKey = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid + } + + test('should allow verified user to get their integration', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: 'test-account', + extra: {} + }) + + const mockIntegration: Integration = { + ...mockKey, + data: {} + } + + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue([ + { _id: mockKey.socialId, personUuid: 'test-account', verifiedOn: 1 } + ]) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) + + const result = await getIntegration(mockCtx, mockDb, mockBranding, mockToken, mockKey) + expect(result).toEqual(mockIntegration) + expect(mockDb.integration.findOne).toHaveBeenCalledWith(mockKey) + }) + + test('should throw error when there is no matching verified social id', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: 'test-account', + extra: {} + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + + await expect(getIntegration(mockCtx, mockDb, mockBranding, mockToken, mockKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + expect(mockDb.integration.findOne).not.toHaveBeenCalled() + }) + + test('should allow all integration services to get integration', async () => { + for (const service of integrationServices) { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service } + }) + + const mockIntegration: Integration = { + ...mockKey, + data: {} + } + + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) + + const result = await getIntegration(mockCtx, mockDb, mockBranding, mockToken, mockKey) + expect(result).toEqual(mockIntegration) + } + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + ;(mockDb.socialId.findOne as jest.Mock).mockResolvedValue(null) + + await expect(getIntegration(mockCtx, mockDb, mockBranding, mockToken, mockKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + expect(mockDb.integration.findOne).not.toHaveBeenCalled() + }) + + test('should return null when integration not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + + const result = await getIntegration(mockCtx, mockDb, mockBranding, mockToken, mockKey) + expect(result).toBeNull() + }) + }) + + describe('listIntegrations', () => { + const mockSocialId = 'test-social-id' as PersonId + const mockIntegration: Integration = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + data: {} + } + + test('should allow service to list all integrations without filters', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.find as jest.Mock).mockResolvedValue([mockIntegration]) + + const result = await listIntegrations(mockCtx, mockDb, mockBranding, mockToken, {}) + + expect(result).toEqual([mockIntegration]) + expect(mockDb.integration.find).toHaveBeenCalledWith({ kind: undefined, workspaceUuid: undefined }) + }) + + test('should allow service to list integrations with specific socialId', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.find as jest.Mock).mockResolvedValue([mockIntegration]) + + const result = await listIntegrations(mockCtx, mockDb, mockBranding, mockToken, { + socialId: mockSocialId + }) + + expect(result).toEqual([mockIntegration]) + expect(mockDb.integration.find).toHaveBeenCalledWith({ + socialId: { $in: [mockSocialId] }, + kind: undefined, + workspaceUuid: undefined + }) + }) + + test('should allow service to filter by kind and workspace', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.find as jest.Mock).mockResolvedValue([mockIntegration]) + + const result = await listIntegrations(mockCtx, mockDb, mockBranding, mockToken, { + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid + }) + + expect(result).toEqual([mockIntegration]) + expect(mockDb.integration.find).toHaveBeenCalledWith({ + kind: 'test-kind', + workspaceUuid: 'test-workspace' + }) + }) + + test('should allow service to filter by null workspace', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integration.find as jest.Mock).mockResolvedValue([mockIntegration]) + + const result = await listIntegrations(mockCtx, mockDb, mockBranding, mockToken, { + workspaceUuid: null + }) + + expect(result).toEqual([mockIntegration]) + expect(mockDb.integration.find).toHaveBeenCalledWith({ + kind: undefined, + workspaceUuid: null + }) + }) + + test('should allow regular user to list their verified integrations', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: 'test-account', + extra: {} + }) + + const verifiedSocialIds = [ + { _id: mockSocialId, personUuid: 'test-account', verifiedOn: 1 }, + { _id: 'another-social-id' as PersonId, personUuid: 'test-account', verifiedOn: 1 } + ] + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue(verifiedSocialIds) + ;(mockDb.integration.find as jest.Mock).mockResolvedValue([mockIntegration]) + + const result = await listIntegrations(mockCtx, mockDb, mockBranding, mockToken, {}) + + expect(result).toEqual([mockIntegration]) + expect(mockDb.integration.find).toHaveBeenCalledWith({ + socialId: { $in: verifiedSocialIds.map((s) => s._id) }, + kind: undefined, + workspaceUuid: undefined + }) + }) + + test('should allow user to filter their integrations by specific socialId', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: 'test-account', + extra: {} + }) + + const verifiedSocialIds = [{ _id: mockSocialId, personUuid: 'test-account', verifiedOn: 1 }] + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue(verifiedSocialIds) + ;(mockDb.integration.find as jest.Mock).mockResolvedValue([mockIntegration]) + + const result = await listIntegrations(mockCtx, mockDb, mockBranding, mockToken, { + socialId: mockSocialId + }) + + expect(result).toEqual([mockIntegration]) + expect(mockDb.integration.find).toHaveBeenCalledWith({ + socialId: { $in: [mockSocialId] }, + kind: undefined, + workspaceUuid: undefined + }) + }) + + test('should throw error when user has no verified social ids', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: 'test-account', + extra: {} + }) + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue([]) + + await expect(listIntegrations(mockCtx, mockDb, mockBranding, mockToken, {})).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integration.find).not.toHaveBeenCalled() + }) + + test('should throw error when user requests unauthorized socialId', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + account: 'test-account', + extra: {} + }) + + const verifiedSocialIds = [{ _id: 'other-social-id' as PersonId, personUuid: 'test-account', verifiedOn: 1 }] + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue(verifiedSocialIds) + + await expect( + listIntegrations(mockCtx, mockDb, mockBranding, mockToken, { + socialId: mockSocialId // Not in user's verified social ids + }) + ).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))) + + expect(mockDb.integration.find).not.toHaveBeenCalled() + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + ;(mockDb.socialId.find as jest.Mock).mockResolvedValue([]) + + await expect(listIntegrations(mockCtx, mockDb, mockBranding, mockToken, {})).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integration.find).not.toHaveBeenCalled() + }) + }) + + describe('addIntegrationSecret', () => { + test('should allow allowed services to create integration secret', async () => { + for (const service of integrationServices) { + jest.clearAllMocks() + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service } + }) + + const mockSecret: IntegrationSecret = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key', + secret: 'test-secret' + } + + const mockIntegration: Integration = { + socialId: mockSecret.socialId, + kind: mockSecret.kind, + workspaceUuid: mockSecret.workspaceUuid, + data: {} + } + + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + + await addIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret) + + expect(mockDb.integrationSecret.insertOne).toHaveBeenCalledWith(mockSecret) + } + }) + + test('should throw error when integration does not exist', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + + const mockSecret: IntegrationSecret = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key', + secret: 'test-secret' + } + + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(null) + + await expect(addIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) + ) + + expect(mockDb.integrationSecret.insertOne).not.toHaveBeenCalled() + }) + + test('should throw error if secret already exists', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + + const mockSecret: IntegrationSecret = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key', + secret: 'test-secret' + } + + const mockIntegration: Integration = { + socialId: mockSecret.socialId, + kind: mockSecret.kind, + workspaceUuid: mockSecret.workspaceUuid, + data: {} + } + + ;(mockDb.integration.findOne as jest.Mock).mockResolvedValue(mockIntegration) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(mockSecret) + + await expect(addIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretAlreadyExists, {})) + ) + + expect(mockDb.integrationSecret.insertOne).not.toHaveBeenCalled() + }) + + test('should throw for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + + const mockSecret: IntegrationSecret = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key', + secret: 'test-secret' + } + + await expect(addIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.insertOne).not.toHaveBeenCalled() + }) + }) + + describe('updateIntegrationSecret', () => { + const mockSecret: IntegrationSecret = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key', + secret: 'new-secret' + } + + const mockSecretKey: IntegrationSecretKey = { + socialId: mockSecret.socialId, + kind: mockSecret.kind, + workspaceUuid: mockSecret.workspaceUuid, + key: mockSecret.key + } + + test('should allow allowed service to update secret', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue({ + ...mockSecret, + secret: 'old-secret' + }) + + await updateIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret) + + expect(mockDb.integrationSecret.updateOne).toHaveBeenCalledWith(mockSecretKey, { secret: mockSecret.secret }) + }) + + test('should throw error when secret not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + + await expect(updateIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) + ) + + expect(mockDb.integrationSecret.updateOne).not.toHaveBeenCalled() + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + + await expect(updateIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecret)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.findOne).not.toHaveBeenCalled() + expect(mockDb.integrationSecret.updateOne).not.toHaveBeenCalled() + }) + }) + + describe('deleteIntegrationSecret', () => { + const mockSecretKey: IntegrationSecretKey = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key' + } + + test('should allow allowed service to delete secret', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue({ + ...mockSecretKey, + secret: 'test-secret' + }) + + await deleteIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey) + + expect(mockDb.integrationSecret.deleteMany).toHaveBeenCalledWith(mockSecretKey) + }) + + test('should throw error when secret not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + + await expect(deleteIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) + ) + + expect(mockDb.integrationSecret.deleteMany).not.toHaveBeenCalled() + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + + await expect(deleteIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.deleteMany).not.toHaveBeenCalled() + }) + }) + + describe('getIntegrationSecret', () => { + const mockSecretKey: IntegrationSecretKey = { + socialId: 'test-social-id' as PersonId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key' + } + + const mockSecret: IntegrationSecret = { + ...mockSecretKey, + secret: 'test-secret' + } + + test('should allow allowed service to get secret', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(mockSecret) + + const result = await getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey) + + expect(result).toEqual(mockSecret) + expect(mockDb.integrationSecret.findOne).toHaveBeenCalledWith(mockSecretKey) + }) + + test('should throw error when secret not found', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.findOne as jest.Mock).mockResolvedValue(null) + + await expect(getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) + ) + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + + await expect(getIntegrationSecret(mockCtx, mockDb, mockBranding, mockToken, mockSecretKey)).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.findOne).not.toHaveBeenCalled() + }) + }) + + describe('listIntegrationsSecrets', () => { + const mockSocialId = 'test-social-id' as PersonId + const mockSecret: IntegrationSecret = { + socialId: mockSocialId, + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid, + key: 'test-key', + secret: 'test-secret' + } + + test('should allow service to list all secrets without filters', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.find as jest.Mock).mockResolvedValue([mockSecret]) + + const result = await listIntegrationsSecrets(mockCtx, mockDb, mockBranding, mockToken, {}) + + expect(result).toEqual([mockSecret]) + expect(mockDb.integrationSecret.find).toHaveBeenCalledWith({ + socialId: undefined, + kind: undefined, + workspaceUuid: undefined + }) + }) + + test('should allow service to filter by socialId', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.find as jest.Mock).mockResolvedValue([mockSecret]) + + const result = await listIntegrationsSecrets(mockCtx, mockDb, mockBranding, mockToken, { + socialId: mockSocialId + }) + + expect(result).toEqual([mockSecret]) + expect(mockDb.integrationSecret.find).toHaveBeenCalledWith({ + socialId: mockSocialId, + kind: undefined, + workspaceUuid: undefined + }) + }) + + test('should allow service to filter by kind and workspace', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.find as jest.Mock).mockResolvedValue([mockSecret]) + + const result = await listIntegrationsSecrets(mockCtx, mockDb, mockBranding, mockToken, { + kind: 'test-kind', + workspaceUuid: 'test-workspace' as WorkspaceUuid + }) + + expect(result).toEqual([mockSecret]) + expect(mockDb.integrationSecret.find).toHaveBeenCalledWith({ + kind: 'test-kind', + workspaceUuid: 'test-workspace', + socialId: undefined + }) + }) + + test('should allow service to filter by null workspace', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'github' } + }) + ;(mockDb.integrationSecret.find as jest.Mock).mockResolvedValue([mockSecret]) + + const result = await listIntegrationsSecrets(mockCtx, mockDb, mockBranding, mockToken, { + workspaceUuid: null + }) + + expect(result).toEqual([mockSecret]) + expect(mockDb.integrationSecret.find).toHaveBeenCalledWith({ + kind: undefined, + workspaceUuid: null, + socialId: undefined + }) + }) + + test('should throw error for unauthorized service', async () => { + ;(decodeTokenVerbose as jest.Mock).mockReturnValue({ + extra: { service: 'unauthorized-service' } + }) + + await expect(listIntegrationsSecrets(mockCtx, mockDb, mockBranding, mockToken, {})).rejects.toThrow( + new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + ) + + expect(mockDb.integrationSecret.find).not.toHaveBeenCalled() + }) + }) +}) diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts index 13c2e220c5..0ec2be6be8 100644 --- a/server/account/src/__tests__/utils.test.ts +++ b/server/account/src/__tests__/utils.test.ts @@ -932,7 +932,7 @@ describe('account utils', () => { await expect(confirmEmail(mockCtx, mockDb, account, email)).rejects.toThrow( new PlatformError( new Status(Severity.ERROR, platform.status.SocialIdNotFound, { - socialId: email, + value: email, type: SocialIdType.EMAIL }) ) diff --git a/server/account/src/collections/mongo.ts b/server/account/src/collections/mongo.ts index 13040d0e5b..9fc307f89b 100644 --- a/server/account/src/collections/mongo.ts +++ b/server/account/src/collections/mongo.ts @@ -51,7 +51,9 @@ import type { WorkspaceStatusData, Sort, Mailbox, - MailboxSecret + MailboxSecret, + Integration, + IntegrationSecret } from '../types' import { isShallowEqual } from '../utils' @@ -373,6 +375,8 @@ export class MongoAccountDB implements AccountDB { invite: MongoDbCollection mailbox: MongoDbCollection mailboxSecret: MongoDbCollection + integration: MongoDbCollection + integrationSecret: MongoDbCollection workspaceMembers: MongoDbCollection @@ -388,6 +392,8 @@ export class MongoAccountDB implements AccountDB { this.invite = new MongoDbCollection('invite', db, 'id') this.mailbox = new MongoDbCollection('mailbox', db) this.mailboxSecret = new MongoDbCollection('mailboxSecrets', db) + this.integration = new MongoDbCollection('integration', db) + this.integrationSecret = new MongoDbCollection('integrationSecret', db) this.workspaceMembers = new MongoDbCollection('workspaceMembers', db) } diff --git a/server/account/src/collections/postgres.ts b/server/account/src/collections/postgres.ts index f4109d8565..0d02088e81 100644 --- a/server/account/src/collections/postgres.ts +++ b/server/account/src/collections/postgres.ts @@ -41,7 +41,9 @@ import type { WorkspaceInfoWithStatus, Sort, Mailbox, - MailboxSecret + MailboxSecret, + Integration, + IntegrationSecret } from '../types' function toSnakeCase (str: string): string { @@ -86,13 +88,18 @@ function convertKeysToSnakeCase (obj: any): any { return obj } +function formatVar (idx: number, type?: string): string { + return type != null ? `$${idx}::${type}` : `$${idx}` +} + export class PostgresDbCollection, K extends keyof T | undefined = undefined> implements DbCollection { constructor ( readonly name: string, readonly client: Sql, readonly idKey?: K, - readonly ns: string = 'global_account' + readonly ns?: string, + readonly fieldTypes: Record = {} ) {} getTableName (): string { @@ -108,7 +115,14 @@ implements DbCollection { } protected buildWhereClause (query: Query, lastRefIdx: number = 0): [string, any[]] { - if (Object.keys(query).length === 0) { + const filteredQuery = Object.entries(query).reduce>((acc, [key, value]) => { + if (value !== undefined) { + acc[key as keyof Query] = value + } + return acc + }, {}) + + if (Object.keys(filteredQuery).length === 0) { return ['', []] } @@ -116,9 +130,12 @@ implements DbCollection { const values: any[] = [] let currIdx: number = lastRefIdx - for (const key of Object.keys(query)) { - const qKey = query[key] + for (const key of Object.keys(filteredQuery)) { + const qKey = filteredQuery[key] + if (qKey === undefined) continue + const operator = qKey != null && typeof qKey === 'object' ? Object.keys(qKey)[0] : '' + const castType = this.fieldTypes[key] const snakeKey = toSnakeCase(key) switch (operator) { case '$in': { @@ -126,7 +143,7 @@ implements DbCollection { const inVars: string[] = [] for (const val of inVals) { currIdx++ - inVars.push(`$${currIdx}`) + inVars.push(formatVar(currIdx, castType)) values.push(val) } whereChunks.push(`"${snakeKey}" IN (${inVars.join(', ')})`) @@ -134,38 +151,42 @@ implements DbCollection { } case '$lt': { currIdx++ - whereChunks.push(`"${snakeKey}" < $${currIdx}`) + whereChunks.push(`"${snakeKey}" < ${formatVar(currIdx, castType)}`) values.push(Object.values(qKey as object)[0]) break } case '$lte': { currIdx++ - whereChunks.push(`"${snakeKey}" <= $${currIdx}`) + whereChunks.push(`"${snakeKey}" <= ${formatVar(currIdx, castType)}`) values.push(Object.values(qKey as object)[0]) break } case '$gt': { currIdx++ - whereChunks.push(`"${snakeKey}" > $${currIdx}`) + whereChunks.push(`"${snakeKey}" > ${formatVar(currIdx, castType)}`) values.push(Object.values(qKey as object)[0]) break } case '$gte': { currIdx++ - whereChunks.push(`"${snakeKey}" >= $${currIdx}`) + whereChunks.push(`"${snakeKey}" >= ${formatVar(currIdx, castType)}`) values.push(Object.values(qKey as object)[0]) break } case '$ne': { currIdx++ - whereChunks.push(`"${key}" != $${currIdx}`) + whereChunks.push(`"${snakeKey}" != ${formatVar(currIdx, castType)}`) values.push(Object.values(qKey as object)[0]) break } default: { currIdx++ - whereChunks.push(`"${snakeKey}" = $${currIdx}`) - values.push(qKey) + if (qKey !== null) { + whereChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`) + values.push(qKey) + } else { + whereChunks.push(`"${snakeKey}" IS NULL`) + } } } } @@ -253,8 +274,9 @@ implements DbCollection { } default: { const snakeKey = toSnakeCase(key) + const castType = this.fieldTypes[key] currIdx++ - updateChunks.push(`"${snakeKey}" = $${currIdx}`) + updateChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`) values.push(ops[key]) } } @@ -297,8 +319,11 @@ export class AccountPostgresDbCollection implements DbCollection { private readonly passwordKeys = ['hash', 'salt'] - constructor (readonly client: Sql) { - super('account', client, 'uuid') + constructor ( + readonly client: Sql, + readonly ns?: string + ) { + super('account', client, 'uuid', ns) } getPasswordsTableName (): string { @@ -381,21 +406,25 @@ export class PostgresAccountDB implements AccountDB { invite: PostgresDbCollection mailbox: PostgresDbCollection mailboxSecret: PostgresDbCollection + integration: PostgresDbCollection + integrationSecret: PostgresDbCollection constructor ( readonly client: Sql, readonly ns: string = 'global_account' ) { - this.person = new PostgresDbCollection('person', client, 'uuid') - this.account = new AccountPostgresDbCollection(client) - this.socialId = new PostgresDbCollection('social_id', client, '_id') - this.workspaceStatus = new PostgresDbCollection('workspace_status', client) - this.workspace = new PostgresDbCollection('workspace', client, 'uuid') - this.accountEvent = new PostgresDbCollection('account_events', client) - this.otp = new PostgresDbCollection('otp', client) - this.invite = new PostgresDbCollection('invite', client, 'id') - this.mailbox = new PostgresDbCollection('mailbox', client) - this.mailboxSecret = new PostgresDbCollection('mailbox_secrets', client) + this.person = new PostgresDbCollection('person', client, 'uuid', ns) + this.account = new AccountPostgresDbCollection(client, ns) + this.socialId = new PostgresDbCollection('social_id', client, '_id', ns) + this.workspaceStatus = new PostgresDbCollection('workspace_status', client, undefined, ns) + this.workspace = new PostgresDbCollection('workspace', client, 'uuid', ns) + this.accountEvent = new PostgresDbCollection('account_events', client, undefined, ns) + this.otp = new PostgresDbCollection('otp', client, undefined, ns) + this.invite = new PostgresDbCollection('invite', client, 'id', ns) + this.mailbox = new PostgresDbCollection('mailbox', client, undefined, ns) + this.mailboxSecret = new PostgresDbCollection('mailbox_secrets', client, undefined, ns) + this.integration = new PostgresDbCollection('integrations', client, undefined, ns) + this.integrationSecret = new PostgresDbCollection('integration_secrets', client, undefined, ns) } getWsMembersTableName (): string { @@ -646,7 +675,8 @@ export class PostgresAccountDB implements AccountDB { this.getV3Migration(), this.getV4Migration(), this.getV4Migration1(), - this.getV5Migration() + this.getV5Migration(), + this.getV6Migration() ] } @@ -901,4 +931,36 @@ export class PostgresAccountDB implements AccountDB { ` ] } + + private getV6Migration (): [string, string] { + return [ + 'account_db_v6_add_social_id_integrations', + ` + CREATE TABLE IF NOT EXISTS ${this.ns}.integrations ( + social_id INT8 NOT NULL, + kind STRING NOT NULL, + workspace_uuid UUID, + _def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE, + data JSONB, + CONSTRAINT integrations_pk PRIMARY KEY (social_id, kind, _def_ws_uuid), + INDEX integrations_kind_idx (kind), + CONSTRAINT integrations_social_id_fk FOREIGN KEY (social_id) REFERENCES ${this.ns}.social_id(_id), + CONSTRAINT integrations_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${this.ns}.workspace(uuid) + ); + + CREATE TABLE IF NOT EXISTS ${this.ns}.integration_secrets ( + social_id INT8 NOT NULL, + kind STRING NOT NULL, + workspace_uuid UUID, + _def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE, + key STRING, + secret STRING NOT NULL, + CONSTRAINT integration_secrets_pk PRIMARY KEY (social_id, kind, _def_ws_uuid, key), + CONSTRAINT integration_secrets_integrations_fk FOREIGN KEY (social_id, kind, _def_ws_uuid) + REFERENCES ${this.ns}.integrations(social_id, kind, _def_ws_uuid) + ON DELETE CASCADE + ); + ` + ] + } } diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 80aa299271..334b4fc8ca 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -17,31 +17,20 @@ import { AccountRole, buildSocialIdString, concatLink, - Data, isActiveMode, isWorkspaceCreating, MeasureContext, SocialIdType, systemAccountUuid, - Version, - type BackupStatus, type Branding, type Person, type PersonId, type PersonInfo, type PersonUuid, type WorkspaceMemberInfo, - type WorkspaceMode, type WorkspaceUuid } from '@hcengineering/core' -import platform, { - getMetadata, - PlatformError, - Severity, - Status, - translate, - unknownError -} from '@hcengineering/platform' +import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token' import { isAdminEmail } from './admin' @@ -55,13 +44,9 @@ import type { OtpInfo, RegionInfo, SocialId, - Workspace, - WorkspaceEvent, WorkspaceInfoWithStatus, WorkspaceInviteInfo, - WorkspaceLoginInfo, - WorkspaceOperation, - WorkspaceStatus + WorkspaceLoginInfo } from './types' import { checkInvite, @@ -80,12 +65,9 @@ import { getRegions, getRolePower, getMailUrl, - getSocialIdByKey, getWorkspaceById, getWorkspaceInfoWithStatusById, getWorkspaceInvite, - getWorkspaces, - getWorkspacesInfoWithStatusByIds, GUEST_ACCOUNT, isOtpValid, selectWorkspace, @@ -103,11 +85,14 @@ import { isEmail, generatePassword, addSocialId, - releaseSocialId + releaseSocialId, + updateWorkspaceRole } from './utils' +import { type AccountServiceMethods, getServiceMethods } from './serviceOperations' -// Move to config? -const processingTimeoutMs = 30 * 1000 +// Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params +// to the database layer when searching/inserting as they may contain SQL injection +// !!! NEVER PASS "params" DIRECTLY in any DB functions !!! const workspaceLimitPerUser = process.env.WORKSPACE_LIMIT_PER_USER != null ? parseInt(process.env.WORKSPACE_LIMIT_PER_USER) : 10 @@ -947,7 +932,7 @@ export async function requestPasswordReset ( if (emailSocialId == null) { ctx.error('Email social id not found', { email, normalizedEmail }) throw new PlatformError( - new Status(Severity.ERROR, platform.status.SocialIdNotFound, { socialId: email, type: SocialIdType.EMAIL }) + new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL }) ) } @@ -1020,7 +1005,7 @@ export async function restorePassword ( if (emailSocialId == null) { ctx.error('Email social id not found', { email }) throw new PlatformError( - new Status(Severity.ERROR, platform.status.SocialIdNotFound, { socialId: email, type: SocialIdType.EMAIL }) + new Status(Severity.ERROR, platform.status.SocialIdNotFound, { value: email, type: SocialIdType.EMAIL }) ) } @@ -1245,128 +1230,6 @@ export async function getWorkspaceInfo ( return workspace } -export async function listWorkspaces ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { - region?: string | null - mode?: WorkspaceMode | null - } -): Promise { - const { region, mode } = params - const { extra } = decodeTokenVerbose(ctx, token) - - if (!['tool', 'backup', 'admin'].includes(extra?.service) && extra?.admin !== 'true') { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - return await getWorkspaces(db, false, region, mode) -} - -export async function performWorkspaceOperation ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - parameters: { - workspaceId: WorkspaceUuid | WorkspaceUuid[] - event: 'archive' | 'migrate-to' | 'unarchive' | 'delete' | 'reset-attempts' - params: any[] - } -): Promise { - const { workspaceId, event, params } = parameters - const { extra, workspace } = decodeTokenVerbose(ctx, token) - - if (extra?.admin !== 'true') { - if (event !== 'unarchive' || workspaceId !== workspace) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - } - - const workspaceUuids = Array.isArray(workspaceId) ? workspaceId : [workspaceId] - - const workspaces = await getWorkspacesInfoWithStatusByIds(db, workspaceUuids) - if (workspaces.length === 0) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {})) - } - - let ops = 0 - for (const workspace of workspaces) { - const update: Partial = {} - switch (event) { - case 'reset-attempts': - update.processingAttempts = 0 - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - case 'delete': - if (workspace.status.mode !== 'active') { - throw new PlatformError(unknownError('Delete allowed only for active workspaces')) - } - - update.mode = 'pending-deletion' - update.processingAttempts = 0 - update.processingProgress = 0 - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - case 'archive': - if (!isActiveMode(workspace.status.mode)) { - throw new PlatformError(unknownError('Archiving allowed only for active workspaces')) - } - - update.mode = 'archiving-pending-backup' - update.processingAttempts = 0 - update.processingProgress = 0 - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - case 'unarchive': - if (event === 'unarchive') { - if (workspace.status.mode !== 'archived') { - throw new PlatformError(unknownError('Unarchive allowed only for archived workspaces')) - } - } - - update.mode = 'pending-restore' - update.processingAttempts = 0 - update.processingProgress = 0 - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - case 'migrate-to': { - if (!isActiveMode(workspace.status.mode)) { - return false - } - if (params.length !== 1 && params[0] == null) { - throw new PlatformError(unknownError('Invalid region passed to migrate operation')) - } - const regions = getRegions() - if (regions.find((it) => it.region === params[0]) === undefined) { - throw new PlatformError(unknownError('Invalid region passed to migrate operation')) - } - if ((workspace.region ?? '') === params[0]) { - throw new PlatformError(unknownError('Invalid region passed to migrate operation')) - } - - update.mode = 'migration-pending-backup' - // NOTE: will only work for Mongo accounts - update.targetRegion = params[0] - update.processingAttempts = 0 - update.processingProgress = 0 - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - } - default: - break - } - - if (Object.keys(update).length !== 0) { - await db.workspaceStatus.updateOne({ workspaceUuid: workspace.uuid }, update) - ops++ - } - } - return ops > 0 -} - /** * Validates the token and returns the decoded account information. */ @@ -1630,357 +1493,6 @@ export async function getWorkspaceMembers ( return await db.getWorkspaceMembers(workspace) } -export async function updateWorkspaceRoleBySocialKey ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { - socialKey: string - targetRole: AccountRole - } -): Promise { - const { socialKey, targetRole } = params - const { extra } = decodeTokenVerbose(ctx, token) - - if (!['workspace', 'tool'].includes(extra?.service)) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - const socialId = await getSocialIdByKey(db, socialKey.toLowerCase() as PersonId) - if (socialId == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) - } - - await updateWorkspaceRole(ctx, db, branding, token, { targetAccount: socialId.personUuid, targetRole }) -} - -export async function updateWorkspaceRole ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { - targetAccount: PersonUuid - targetRole: AccountRole - } -): Promise { - const { targetAccount, targetRole } = params - - const { account, workspace } = decodeTokenVerbose(ctx, token) - - if (workspace === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace })) - } - - const accRole = account === systemAccountUuid ? AccountRole.Owner : await db.getWorkspaceRole(account, workspace) - - if ( - accRole == null || - getRolePower(accRole) < getRolePower(AccountRole.Maintainer) || - getRolePower(accRole) < getRolePower(targetRole) - ) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - const currentRole = await db.getWorkspaceRole(targetAccount, workspace) - - if (currentRole == null || getRolePower(accRole) < getRolePower(currentRole)) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - if (currentRole === targetRole) return - - if (currentRole === AccountRole.Owner) { - // Check if there are other owners - const owners = (await db.getWorkspaceMembers(workspace)).filter((m) => m.role === AccountRole.Owner) - if (owners.length === 1) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - } - - await db.updateWorkspaceRole(targetAccount, workspace, targetRole) -} - -/* =================================== */ -/* ===WORKSPACE SERVICE OPERATIONS==== */ -/* =================================== */ - -/** - * Retrieves one workspace for which there are things to process. - * - * Workspace is provided for 30seconds. This timeout is reset - * on every progress update. - * If no progress is reported for the workspace during this time, - * it will become available again to be processed by another executor. - */ -export async function getPendingWorkspace ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { - region: string - version: Data - operation: WorkspaceOperation - } -): Promise { - const { region, version, operation } = params - const { extra } = decodeTokenVerbose(ctx, token) - if (extra?.service !== 'workspace') { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - const wsLivenessDays = getMetadata(accountPlugin.metadata.WsLivenessDays) - const wsLivenessMs = wsLivenessDays !== undefined ? wsLivenessDays * 24 * 60 * 60 * 1000 : undefined - - const result = await db.getPendingWorkspace(region, version, operation, processingTimeoutMs, wsLivenessMs) - - if (result != null) { - ctx.info('getPendingWorkspace', { - workspaceId: result.uuid, - workspaceName: result.name, - mode: result.status.mode, - operation, - region, - major: result.status.versionMajor, - minor: result.status.versionMinor, - patch: result.status.versionPatch, - requestedVersion: version - }) - } - - return result -} - -export async function updateWorkspaceInfo ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { - workspaceUuid: WorkspaceUuid - event: WorkspaceEvent - version: Data // A worker version - progress: number - message?: string - } -): Promise { - const { workspaceUuid, event, version, message } = params - let progress = params.progress - - const { extra } = decodeTokenVerbose(ctx, token) - if (!['workspace', 'tool'].includes(extra?.service)) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - const workspace = await getWorkspaceInfoWithStatusById(db, workspaceUuid) - if (workspace === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid })) - } - progress = Math.round(progress) - - const update: Partial = {} - const wsUpdate: Partial = {} - switch (event) { - case 'create-started': - update.mode = 'creating' - if (workspace.status.mode !== 'creating') { - update.processingAttempts = 0 - } - update.processingProgress = progress - break - case 'upgrade-started': - if (workspace.status.mode !== 'upgrading') { - update.processingAttempts = 0 - } - update.mode = 'upgrading' - update.processingProgress = progress - break - case 'create-done': - ctx.info('Updating workspace info on create-done', { workspaceUuid, event, version, progress }) - update.mode = 'active' - update.isDisabled = false - update.versionMajor = version.major - update.versionMinor = version.minor - update.versionPatch = version.patch - update.processingProgress = progress - break - case 'upgrade-done': - ctx.info('Updating workspace info on upgrade-done', { workspaceUuid, event, version, progress }) - update.mode = 'active' - update.versionMajor = version.major - update.versionMinor = version.minor - update.versionPatch = version.patch - update.processingProgress = progress - break - case 'progress': - update.processingProgress = progress - break - case 'migrate-backup-started': - update.mode = 'migration-backup' - update.processingProgress = progress - break - case 'migrate-backup-done': - update.mode = 'migration-pending-clean' - update.processingProgress = progress - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - case 'migrate-clean-started': - update.mode = 'migration-clean' - update.processingAttempts = 0 - update.processingProgress = progress - break - case 'migrate-clean-done': - wsUpdate.region = workspace.status.targetRegion ?? '' - update.mode = 'pending-restore' - update.processingProgress = progress - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - case 'restore-started': - update.mode = 'restoring' - update.processingAttempts = 0 - update.processingProgress = progress - break - case 'restore-done': - update.mode = 'active' - update.processingProgress = 100 - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - case 'archiving-backup-started': - update.mode = 'archiving-backup' - update.processingAttempts = 0 - update.processingProgress = progress - break - case 'archiving-backup-done': - update.mode = 'archiving-pending-clean' - update.processingProgress = progress - update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step - break - case 'archiving-clean-started': - update.mode = 'archiving-clean' - update.processingAttempts = 0 - update.processingProgress = progress - break - case 'archiving-clean-done': - update.mode = 'archived' - update.processingProgress = 100 - break - case 'ping': - default: - break - } - - if (message != null) { - update.processingMessage = message - } - - await db.workspaceStatus.updateOne( - { workspaceUuid: workspace.uuid }, - { - lastProcessingTime: Date.now(), // Some operations override it. - ...update - } - ) - - if (Object.keys(wsUpdate).length !== 0) { - await db.workspace.updateOne({ uuid: workspace.uuid }, wsUpdate) - } -} - -export async function workerHandshake ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { - region: string - version: Data - operation: WorkspaceOperation - } -): Promise { - const { region, version, operation } = params - const { extra } = decodeTokenVerbose(ctx, token) - if (extra?.service !== 'workspace') { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - ctx.info('Worker handshake happened', { region, version, operation }) - // Nothing else to do now but keeping to have track of workers in logs -} - -export async function updateBackupInfo ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { backupInfo: BackupStatus } -): Promise { - const { backupInfo } = params - const { extra, workspace } = decodeTokenVerbose(ctx, token) - if (extra?.service !== 'backup') { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - const workspaceInfo = await getWorkspaceById(db, workspace) - if (workspaceInfo === null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace })) - } - - await db.workspaceStatus.updateOne( - { workspaceUuid: workspace }, - { - backupInfo, - lastProcessingTime: Date.now() - } - ) -} - -export async function assignWorkspace ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { - email: string - workspaceUuid: WorkspaceUuid - role: AccountRole - } -): Promise { - const { email, workspaceUuid, role } = params - const { extra } = decodeTokenVerbose(ctx, token) - if (!['aibot', 'tool', 'workspace'].includes(extra?.service)) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) - } - - const normalizedEmail = cleanEmail(email) - const emailSocialId = await getEmailSocialId(db, normalizedEmail) - - if (emailSocialId == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) - } - - const account = await getAccount(db, emailSocialId.personUuid) - - if (account == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) - } - - const workspace = await getWorkspaceById(db, workspaceUuid) - - if (workspace == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid })) - } - - const currentRole = await db.getWorkspaceRole(account.uuid, workspaceUuid) - - if (currentRole == null) { - await db.assignWorkspace(account.uuid, workspaceUuid, role) - } else if (getRolePower(currentRole) < getRolePower(role)) { - await db.updateWorkspaceRole(account.uuid, workspaceUuid, role) - } -} - export async function ensurePerson ( ctx: MeasureContext, db: AccountDB, @@ -2021,7 +1533,14 @@ export async function ensurePerson ( return { uuid: personUuid, socialId: newSocialId } } -async function getMailboxOptions (): Promise { +async function getMailboxOptions ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string +): Promise { + decodeTokenVerbose(ctx, token) + return { availableDomains: process.env.MAILBOX_DOMAINS?.split(',') ?? [], minNameLength: parseInt(process.env.MAILBOX_MIN_NAME_LENGTH ?? '6'), @@ -2045,7 +1564,7 @@ async function createMailbox ( const normalizedName = cleanEmail(name) const normalizedDomain = cleanEmail(domain) const mailbox = normalizedName + '@' + normalizedDomain - const opts = await getMailboxOptions() + const opts = await getMailboxOptions(ctx, db, branding, token) if (normalizedName.length === 0 || normalizedDomain.length === 0 || !isEmail(mailbox)) { throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'invalid-name' })) @@ -2107,22 +1626,8 @@ async function deleteMailbox ( ctx.info('Mailbox deleted', { mailbox, account }) } -export async function addSocialIdToPerson ( - ctx: MeasureContext, - db: AccountDB, - branding: Branding | null, - token: string, - params: { person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean } -): Promise { - const { person, type, value, confirmed } = params - const { extra } = decodeTokenVerbose(ctx, token) - - verifyAllowedServices(['github'], extra) - - return await addSocialId(db, person, type, value, confirmed) -} - export type AccountMethods = + | AccountServiceMethods | 'login' | 'loginOtp' | 'signUp' @@ -2150,14 +1655,8 @@ export type AccountMethods = | 'getUserWorkspaces' | 'getWorkspaceInfo' | 'getWorkspacesInfo' - | 'listWorkspaces' | 'getLoginInfoByToken' | 'getSocialIds' - | 'getPendingWorkspace' - | 'updateWorkspaceInfo' - | 'workerHandshake' - | 'updateBackupInfo' - | 'assignWorkspace' | 'getPerson' | 'getPersonInfo' | 'getWorkspaceMembers' @@ -2165,14 +1664,11 @@ export type AccountMethods = | 'findPersonBySocialKey' | 'findPersonBySocialId' | 'findSocialIdBySocialKey' - | 'performWorkspaceOperation' - | 'updateWorkspaceRoleBySocialKey' | 'ensurePerson' | 'getMailboxOptions' | 'createMailbox' | 'getMailboxes' | 'deleteMailbox' - | 'addSocialIdToPerson' /** * @public @@ -2207,6 +1703,7 @@ export function getMethods (hasSignUp: boolean = true): Partial { + const { region, mode } = params + const { extra } = decodeTokenVerbose(ctx, token) + + if (!['tool', 'backup', 'admin'].includes(extra?.service) && extra?.admin !== 'true') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + return await getWorkspaces(db, false, region, mode) +} + +export async function performWorkspaceOperation ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + parameters: { + workspaceId: WorkspaceUuid | WorkspaceUuid[] + event: 'archive' | 'migrate-to' | 'unarchive' | 'delete' | 'reset-attempts' + params: any[] + } +): Promise { + const { workspaceId, event, params } = parameters + const { extra, workspace } = decodeTokenVerbose(ctx, token) + + if (extra?.admin !== 'true') { + if (event !== 'unarchive' || workspaceId !== workspace) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } + + const workspaceUuids = Array.isArray(workspaceId) ? workspaceId : [workspaceId] + + const workspaces = await getWorkspacesInfoWithStatusByIds(db, workspaceUuids) + if (workspaces.length === 0) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, {})) + } + + let ops = 0 + for (const workspace of workspaces) { + const update: Partial = {} + switch (event) { + case 'reset-attempts': + update.processingAttempts = 0 + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + case 'delete': + if (workspace.status.mode !== 'active') { + throw new PlatformError(unknownError('Delete allowed only for active workspaces')) + } + + update.mode = 'pending-deletion' + update.processingAttempts = 0 + update.processingProgress = 0 + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + case 'archive': + if (!isActiveMode(workspace.status.mode)) { + throw new PlatformError(unknownError('Archiving allowed only for active workspaces')) + } + + update.mode = 'archiving-pending-backup' + update.processingAttempts = 0 + update.processingProgress = 0 + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + case 'unarchive': + if (event === 'unarchive') { + if (workspace.status.mode !== 'archived') { + throw new PlatformError(unknownError('Unarchive allowed only for archived workspaces')) + } + } + + update.mode = 'pending-restore' + update.processingAttempts = 0 + update.processingProgress = 0 + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + case 'migrate-to': { + if (!isActiveMode(workspace.status.mode)) { + return false + } + if (params.length !== 1 && params[0] == null) { + throw new PlatformError(unknownError('Invalid region passed to migrate operation')) + } + const regions = getRegions() + if (regions.find((it) => it.region === params[0]) === undefined) { + throw new PlatformError(unknownError('Invalid region passed to migrate operation')) + } + if ((workspace.region ?? '') === params[0]) { + throw new PlatformError(unknownError('Invalid region passed to migrate operation')) + } + + update.mode = 'migration-pending-backup' + // NOTE: will only work for Mongo accounts + update.targetRegion = params[0] + update.processingAttempts = 0 + update.processingProgress = 0 + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + } + default: + break + } + + if (Object.keys(update).length !== 0) { + await db.workspaceStatus.updateOne({ workspaceUuid: workspace.uuid }, update) + ops++ + } + } + return ops > 0 +} + +export async function updateWorkspaceRoleBySocialKey ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + socialKey: string + targetRole: AccountRole + } +): Promise { + const { socialKey, targetRole } = params + const { extra } = decodeTokenVerbose(ctx, token) + + if (!['workspace', 'tool'].includes(extra?.service)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const socialId = await getSocialIdByKey(db, socialKey.toLowerCase() as PersonId) + if (socialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + + await updateWorkspaceRole(ctx, db, branding, token, { targetAccount: socialId.personUuid, targetRole }) +} + +/** + * Retrieves one workspace for which there are things to process. + * + * Workspace is provided for 30seconds. This timeout is reset + * on every progress update. + * If no progress is reported for the workspace during this time, + * it will become available again to be processed by another executor. + */ +export async function getPendingWorkspace ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + region: string + version: Data + operation: WorkspaceOperation + } +): Promise { + const { region, version, operation } = params + const { extra } = decodeTokenVerbose(ctx, token) + if (extra?.service !== 'workspace') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const wsLivenessDays = getMetadata(accountPlugin.metadata.WsLivenessDays) + const wsLivenessMs = wsLivenessDays !== undefined ? wsLivenessDays * 24 * 60 * 60 * 1000 : undefined + + const result = await db.getPendingWorkspace(region, version, operation, processingTimeoutMs, wsLivenessMs) + + if (result != null) { + ctx.info('getPendingWorkspace', { + workspaceId: result.uuid, + workspaceName: result.name, + mode: result.status.mode, + operation, + region, + major: result.status.versionMajor, + minor: result.status.versionMinor, + patch: result.status.versionPatch, + requestedVersion: version + }) + } + + return result +} + +export async function updateWorkspaceInfo ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + workspaceUuid: WorkspaceUuid + event: WorkspaceEvent + version: Data // A worker version + progress: number + message?: string + } +): Promise { + const { workspaceUuid, event, version, message } = params + let progress = params.progress + + const { extra } = decodeTokenVerbose(ctx, token) + if (!['workspace', 'tool'].includes(extra?.service)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const workspace = await getWorkspaceInfoWithStatusById(db, workspaceUuid) + if (workspace === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid })) + } + progress = Math.round(progress) + + const update: Partial = {} + const wsUpdate: Partial = {} + switch (event) { + case 'create-started': + update.mode = 'creating' + if (workspace.status.mode !== 'creating') { + update.processingAttempts = 0 + } + update.processingProgress = progress + break + case 'upgrade-started': + if (workspace.status.mode !== 'upgrading') { + update.processingAttempts = 0 + } + update.mode = 'upgrading' + update.processingProgress = progress + break + case 'create-done': + ctx.info('Updating workspace info on create-done', { workspaceUuid, event, version, progress }) + update.mode = 'active' + update.isDisabled = false + update.versionMajor = version.major + update.versionMinor = version.minor + update.versionPatch = version.patch + update.processingProgress = progress + break + case 'upgrade-done': + ctx.info('Updating workspace info on upgrade-done', { workspaceUuid, event, version, progress }) + update.mode = 'active' + update.versionMajor = version.major + update.versionMinor = version.minor + update.versionPatch = version.patch + update.processingProgress = progress + break + case 'progress': + update.processingProgress = progress + break + case 'migrate-backup-started': + update.mode = 'migration-backup' + update.processingProgress = progress + break + case 'migrate-backup-done': + update.mode = 'migration-pending-clean' + update.processingProgress = progress + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + case 'migrate-clean-started': + update.mode = 'migration-clean' + update.processingAttempts = 0 + update.processingProgress = progress + break + case 'migrate-clean-done': + wsUpdate.region = workspace.status.targetRegion ?? '' + update.mode = 'pending-restore' + update.processingProgress = progress + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + case 'restore-started': + update.mode = 'restoring' + update.processingAttempts = 0 + update.processingProgress = progress + break + case 'restore-done': + update.mode = 'active' + update.processingProgress = 100 + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + case 'archiving-backup-started': + update.mode = 'archiving-backup' + update.processingAttempts = 0 + update.processingProgress = progress + break + case 'archiving-backup-done': + update.mode = 'archiving-pending-clean' + update.processingProgress = progress + update.lastProcessingTime = Date.now() - processingTimeoutMs // To not wait for next step + break + case 'archiving-clean-started': + update.mode = 'archiving-clean' + update.processingAttempts = 0 + update.processingProgress = progress + break + case 'archiving-clean-done': + update.mode = 'archived' + update.processingProgress = 100 + break + case 'ping': + default: + break + } + + if (message != null) { + update.processingMessage = message + } + + await db.workspaceStatus.updateOne( + { workspaceUuid: workspace.uuid }, + { + lastProcessingTime: Date.now(), // Some operations override it. + ...update + } + ) + + if (Object.keys(wsUpdate).length !== 0) { + await db.workspace.updateOne({ uuid: workspace.uuid }, wsUpdate) + } +} + +export async function workerHandshake ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + region: string + version: Data + operation: WorkspaceOperation + } +): Promise { + const { region, version, operation } = params + const { extra } = decodeTokenVerbose(ctx, token) + if (extra?.service !== 'workspace') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + ctx.info('Worker handshake happened', { region, version, operation }) + // Nothing else to do now but keeping to have track of workers in logs +} + +export async function updateBackupInfo ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { backupInfo: BackupStatus } +): Promise { + const { backupInfo } = params + const { extra, workspace } = decodeTokenVerbose(ctx, token) + if (extra?.service !== 'backup') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const workspaceInfo = await getWorkspaceById(db, workspace) + if (workspaceInfo === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace })) + } + + await db.workspaceStatus.updateOne( + { workspaceUuid: workspace }, + { + backupInfo, + lastProcessingTime: Date.now() + } + ) +} + +export async function assignWorkspace ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + email: string + workspaceUuid: WorkspaceUuid + role: AccountRole + } +): Promise { + const { email, workspaceUuid, role } = params + const { extra } = decodeTokenVerbose(ctx, token) + if (!['aibot', 'tool', 'workspace'].includes(extra?.service)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const normalizedEmail = cleanEmail(email) + const emailSocialId = await getEmailSocialId(db, normalizedEmail) + + if (emailSocialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + + const account = await getAccount(db, emailSocialId.personUuid) + + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + + const workspace = await getWorkspaceById(db, workspaceUuid) + + if (workspace == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid })) + } + + const currentRole = await db.getWorkspaceRole(account.uuid, workspaceUuid) + + if (currentRole == null) { + await db.assignWorkspace(account.uuid, workspaceUuid, role) + } else if (getRolePower(currentRole) < getRolePower(role)) { + await db.updateWorkspaceRole(account.uuid, workspaceUuid, role) + } +} + +export async function addSocialIdToPerson ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean } +): Promise { + const { person, type, value, confirmed } = params + const { extra } = decodeTokenVerbose(ctx, token) + + verifyAllowedServices(['github'], extra) + + return await addSocialId(db, person, type, value, confirmed) +} + +// Move to config? +const integrationServices = ['github', 'telegram-bot', 'telegram', 'mailbox'] + +export async function createIntegration ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: Integration +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(integrationServices, extra) + const { socialId, kind, workspaceUuid, data } = params + + if (kind == null || socialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const existingSocialId = await db.socialId.findOne({ _id: socialId }) + if (existingSocialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.SocialIdNotFound, { _id: socialId })) + } + + if (workspaceUuid != null) { + const workspace = await getWorkspaceById(db, workspaceUuid) + if (workspace == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid })) + } + } + + const existing = await db.integration.findOne({ socialId, kind, workspaceUuid }) + if (existing != null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationAlreadyExists, {})) + } + + await db.integration.insertOne({ socialId, kind, workspaceUuid, data }) +} + +export async function updateIntegration ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: Integration +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(integrationServices, extra) + const { socialId, kind, workspaceUuid, data } = params + + const existing = await db.integration.findOne({ socialId, kind, workspaceUuid }) + if (existing == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) + } + + await db.integration.updateOne({ socialId, kind, workspaceUuid }, { data }) +} + +export async function deleteIntegration ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: IntegrationKey +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(integrationServices, extra) + const { socialId, kind, workspaceUuid } = params + + const existing = await db.integration.findOne({ socialId, kind, workspaceUuid }) + if (existing == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) + } + + await db.integration.deleteMany({ socialId, kind, workspaceUuid }) +} + +export async function listIntegrations ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + socialId?: PersonId + kind?: string + workspaceUuid?: WorkspaceUuid | null + } +): Promise { + const { account, extra } = decodeTokenVerbose(ctx, token) + const isAllowedService = verifyAllowedServices(integrationServices, extra, false) + const { socialId, kind, workspaceUuid } = params + let socialIds: PersonId[] | undefined + + if (isAllowedService) { + socialIds = socialId != null ? [socialId] : undefined + } else { + const socialIdObjs = await db.socialId.find({ personUuid: account, verifiedOn: { $gt: 0 } }) + + if (socialIdObjs.length === 0) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const allowedSocialIds = socialIdObjs.map((it) => it._id) + + if (socialId !== undefined) { + if (!allowedSocialIds.includes(socialId)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + socialIds = [socialId] + } else { + socialIds = allowedSocialIds + } + } + + return await db.integration.find({ + ...(socialIds != null ? { socialId: { $in: socialIds } } : {}), + kind, + workspaceUuid + }) +} + +export async function getIntegration ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: IntegrationKey +): Promise { + const { account, extra } = decodeTokenVerbose(ctx, token) + const isAllowedService = verifyAllowedServices(integrationServices, extra, false) + const { socialId, kind, workspaceUuid } = params + + if (!isAllowedService) { + const existingSocialId = await db.socialId.findOne({ _id: socialId, personUuid: account, verifiedOn: { $gt: 0 } }) + + if (existingSocialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } + + return await db.integration.findOne({ socialId, kind, workspaceUuid }) +} + +export async function addIntegrationSecret ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: IntegrationSecret +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(integrationServices, extra) + const { socialId, kind, workspaceUuid, key, secret } = params + + if (kind == null || socialId == null || key == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + const integrationKey: IntegrationKey = { socialId, kind, workspaceUuid } + const secretKey: IntegrationSecretKey = { ...integrationKey, key } + + const existingIntegration = await db.integration.findOne(integrationKey) + if (existingIntegration == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationNotFound, {})) + } + + const existingSecret = await db.integrationSecret.findOne(secretKey) + if (existingSecret != null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretAlreadyExists, {})) + } + + await db.integrationSecret.insertOne({ ...secretKey, secret }) +} + +export async function updateIntegrationSecret ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: IntegrationSecret +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(integrationServices, extra) + const { socialId, kind, workspaceUuid, key, secret } = params + const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key } + + const existingSecret = await db.integrationSecret.findOne(secretKey) + if (existingSecret == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) + } + + await db.integrationSecret.updateOne(secretKey, { secret }) +} + +export async function deleteIntegrationSecret ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: IntegrationSecretKey +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(integrationServices, extra) + const { socialId, kind, workspaceUuid, key } = params + const secretKey: IntegrationSecretKey = { socialId, kind, workspaceUuid, key } + + const existingSecret = await db.integrationSecret.findOne(secretKey) + if (existingSecret == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) + } + + await db.integrationSecret.deleteMany(secretKey) +} + +export async function getIntegrationSecret ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: IntegrationSecretKey +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(integrationServices, extra) + const { socialId, kind, workspaceUuid, key } = params + + const existing = await db.integrationSecret.findOne({ socialId, kind, workspaceUuid, key }) + if (existing == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.IntegrationSecretNotFound, {})) + } + + return existing +} + +export async function listIntegrationsSecrets ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + socialId?: PersonId + kind?: string + workspaceUuid?: WorkspaceUuid | null + key?: string + } +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + verifyAllowedServices(integrationServices, extra) + const { socialId, kind, workspaceUuid, key } = params + + return await db.integrationSecret.find({ socialId, kind, workspaceUuid, key }) +} + +export type AccountServiceMethods = + | 'getPendingWorkspace' + | 'updateWorkspaceInfo' + | 'workerHandshake' + | 'updateBackupInfo' + | 'assignWorkspace' + | 'listWorkspaces' + | 'performWorkspaceOperation' + | 'updateWorkspaceRoleBySocialKey' + | 'addSocialIdToPerson' + | 'createIntegration' + | 'updateIntegration' + | 'deleteIntegration' + | 'listIntegrations' + | 'getIntegration' + | 'addIntegrationSecret' + | 'updateIntegrationSecret' + | 'deleteIntegrationSecret' + | 'getIntegrationSecret' + | 'listIntegrationsSecrets' + +/** + * @public + */ +export function getServiceMethods (): Partial> { + return { + getPendingWorkspace: wrap(getPendingWorkspace), + updateWorkspaceInfo: wrap(updateWorkspaceInfo), + workerHandshake: wrap(workerHandshake), + updateBackupInfo: wrap(updateBackupInfo), + assignWorkspace: wrap(assignWorkspace), + listWorkspaces: wrap(listWorkspaces), + performWorkspaceOperation: wrap(performWorkspaceOperation), + updateWorkspaceRoleBySocialKey: wrap(updateWorkspaceRoleBySocialKey), + addSocialIdToPerson: wrap(addSocialIdToPerson), + createIntegration: wrap(createIntegration), + updateIntegration: wrap(updateIntegration), + deleteIntegration: wrap(deleteIntegration), + listIntegrations: wrap(listIntegrations), + getIntegration: wrap(getIntegration), + addIntegrationSecret: wrap(addIntegrationSecret), + updateIntegrationSecret: wrap(updateIntegrationSecret), + deleteIntegrationSecret: wrap(deleteIntegrationSecret), + getIntegrationSecret: wrap(getIntegrationSecret), + listIntegrationsSecrets: wrap(listIntegrationsSecrets) + } +} diff --git a/server/account/src/types.ts b/server/account/src/types.ts index 7a28bbe42f..cef330776e 100644 --- a/server/account/src/types.ts +++ b/server/account/src/types.ts @@ -139,6 +139,25 @@ export interface MailboxInfo { mailbox: string } +export interface Integration { + socialId: PersonId + kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + workspaceUuid?: WorkspaceUuid + data?: Record +} + +export type IntegrationKey = Omit + +export interface IntegrationSecret { + socialId: PersonId + kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + workspaceUuid?: WorkspaceUuid + key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc. + secret: string +} + +export type IntegrationSecretKey = Omit + /* ========= S U P P L E M E N T A R Y ========= */ export interface WorkspaceInfoWithStatus extends Workspace { @@ -166,6 +185,8 @@ export interface AccountDB { invite: DbCollection mailbox: DbCollection mailboxSecret: DbCollection + integration: DbCollection + integrationSecret: DbCollection init: () => Promise createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 31e4664b7d..bdb7d107d0 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -610,6 +610,53 @@ export async function selectWorkspace ( } } +export async function updateWorkspaceRole ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + targetAccount: PersonUuid + targetRole: AccountRole + } +): Promise { + const { targetAccount, targetRole } = params + + const { account, workspace } = decodeTokenVerbose(ctx, token) + + if (workspace === null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace })) + } + + const accRole = account === systemAccountUuid ? AccountRole.Owner : await db.getWorkspaceRole(account, workspace) + + if ( + accRole == null || + getRolePower(accRole) < getRolePower(AccountRole.Maintainer) || + getRolePower(accRole) < getRolePower(targetRole) + ) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const currentRole = await db.getWorkspaceRole(targetAccount, workspace) + + if (currentRole == null || getRolePower(accRole) < getRolePower(currentRole)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + if (currentRole === targetRole) return + + if (currentRole === AccountRole.Owner) { + // Check if there are other owners + const owners = (await db.getWorkspaceMembers(workspace)).filter((m) => m.role === AccountRole.Owner) + if (owners.length === 1) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + } + + await db.updateWorkspaceRole(targetAccount, workspace, targetRole) +} + /** * Convert workspace name to a URL-friendly string following these rules: * @@ -832,7 +879,7 @@ export async function confirmEmail ( ctx.error('Email social id not found', { account, normalizedEmail }) throw new PlatformError( new Status(Severity.ERROR, platform.status.SocialIdNotFound, { - socialId: normalizedEmail, + value: normalizedEmail, type: SocialIdType.EMAIL }) )