uberf-9726: manage integrations in accounts (#8475)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions

This commit is contained in:
Alexey Zinoviev 2025-04-05 17:43:22 +04:00 committed by GitHub
parent 099fb90c59
commit 9bf5234730
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 2156 additions and 674 deletions

View File

@ -38,7 +38,11 @@ import type {
WorkspaceLoginInfo, WorkspaceLoginInfo,
RegionInfo, RegionInfo,
WorkspaceOperation, WorkspaceOperation,
MailboxInfo MailboxInfo,
Integration,
IntegrationKey,
IntegrationSecret,
IntegrationSecretKey
} from './types' } from './types'
/** @public */ /** @public */
@ -136,6 +140,25 @@ export interface AccountClient {
lastName: string lastName: string
) => Promise<{ uuid: PersonUuid, socialId: PersonId }> ) => Promise<{ uuid: PersonUuid, socialId: PersonId }>
addSocialIdToPerson: (person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean) => Promise<PersonId> addSocialIdToPerson: (person: PersonUuid, type: SocialIdType, value: string, confirmed: boolean) => Promise<PersonId>
createIntegration: (integration: Integration) => Promise<void>
updateIntegration: (integration: Integration) => Promise<void>
deleteIntegration: (integrationKey: IntegrationKey) => Promise<void>
getIntegration: (integrationKey: IntegrationKey) => Promise<Integration | null>
listIntegrations: (filter: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
}) => Promise<Integration[]>
addIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise<void>
updateIntegrationSecret: (integrationSecret: IntegrationSecret) => Promise<void>
deleteIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<void>
getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<IntegrationSecret | null>
listIntegrationsSecrets: (filter: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
key?: string
}) => Promise<IntegrationSecret[]>
setCookie: () => Promise<void> setCookie: () => Promise<void>
deleteCookie: () => Promise<void> deleteCookie: () => Promise<void>
@ -721,6 +744,105 @@ class AccountClientImpl implements AccountClient {
await this.rpc(request) await this.rpc(request)
} }
async createIntegration (integration: Integration): Promise<void> {
const request = {
method: 'createIntegration' as const,
params: integration
}
await this.rpc(request)
}
async updateIntegration (integration: Integration): Promise<void> {
const request = {
method: 'updateIntegration' as const,
params: integration
}
await this.rpc(request)
}
async deleteIntegration (integrationKey: IntegrationKey): Promise<void> {
const request = {
method: 'deleteIntegration' as const,
params: integrationKey
}
await this.rpc(request)
}
async getIntegration (integrationKey: IntegrationKey): Promise<Integration | null> {
const request = {
method: 'getIntegration' as const,
params: integrationKey
}
return await this.rpc(request)
}
async listIntegrations (filter: {
socialId?: PersonId
kind?: string
workspaceUuid?: WorkspaceUuid | null
}): Promise<Integration[]> {
const request = {
method: 'listIntegrations' as const,
params: filter
}
return await this.rpc(request)
}
async addIntegrationSecret (integrationSecret: IntegrationSecret): Promise<void> {
const request = {
method: 'addIntegrationSecret' as const,
params: integrationSecret
}
await this.rpc(request)
}
async updateIntegrationSecret (integrationSecret: IntegrationSecret): Promise<void> {
const request = {
method: 'updateIntegrationSecret' as const,
params: integrationSecret
}
await this.rpc(request)
}
async deleteIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise<void> {
const request = {
method: 'deleteIntegrationSecret' as const,
params: integrationSecretKey
}
await this.rpc(request)
}
async getIntegrationSecret (integrationSecretKey: IntegrationSecretKey): Promise<IntegrationSecret | null> {
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<IntegrationSecret[]> {
const request = {
method: 'listIntegrationsSecrets' as const,
params: filter
}
return await this.rpc(request)
}
async setCookie (): Promise<void> { async setCookie (): Promise<void> {
const url = concatLink(this.url, '/cookie') const url = concatLink(this.url, '/cookie')
const response = await fetch(url, { ...this.request, method: 'PUT' }) const response = await fetch(url, { ...this.request, method: 'PUT' })

View File

@ -55,3 +55,22 @@ export interface MailboxInfo {
aliases: string[] aliases: string[]
appPasswords: string[] appPasswords: string[]
} }
export interface Integration {
socialId: PersonId
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
workspaceUuid?: WorkspaceUuid
data?: Record<string, any>
}
export type IntegrationKey = Omit<Integration, 'data'>
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<IntegrationSecret, 'secret'>

View File

@ -13,7 +13,6 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
*/ */
import { Metadata, PluginLoader, PluginModule, Resources } from '.' import { Metadata, PluginLoader, PluginModule, Resources } from '.'
/** /**
@ -152,9 +151,13 @@ export default plugin(platformId, {
WorkspaceNotFound: '' as StatusCode<{ workspaceUuid?: string, workspaceName?: string, workspaceUrl?: string }>, WorkspaceNotFound: '' as StatusCode<{ workspaceUuid?: string, workspaceName?: string, workspaceUrl?: string }>,
WorkspaceArchived: '' as StatusCode<{ workspaceUuid: string }>, WorkspaceArchived: '' as StatusCode<{ workspaceUuid: string }>,
WorkspaceMigration: '' 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 }>, SocialIdNotConfirmed: '' as StatusCode<{ socialId: string, type: string }>,
SocialIdAlreadyConfirmed: '' 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 }>, PersonNotFound: '' as StatusCode<{ person: string }>,
InvalidPassword: '' as StatusCode<{ account: string }>, InvalidPassword: '' as StatusCode<{ account: string }>,
AccountAlreadyExists: '' as StatusCode, AccountAlreadyExists: '' as StatusCode,

View File

@ -13,13 +13,12 @@
// limitations under the License. // 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 platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform'
import { decodeTokenVerbose } from '@hcengineering/server-token' import { decodeTokenVerbose } from '@hcengineering/server-token'
import * as utils from '../utils'
import { AccountDB } from '../types' import { AccountDB } from '../types'
import { createInvite, createInviteLink, sendInvite, resendInvite, addSocialIdToPerson } from '../operations' import { createInvite, createInviteLink, sendInvite, resendInvite } from '../operations'
import { accountPlugin } from '../plugin' import { accountPlugin } from '../plugin'
// Mock platform // Mock platform
@ -453,106 +452,4 @@ describe('invite operations', () => {
expect(global.fetch).toHaveBeenCalled() 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()
})
})
}) })

View File

@ -31,6 +31,8 @@ interface TestWorkspace {
lastProcessingTime?: number lastProcessingTime?: number
} }
const ns = 'global_account'
describe('PostgresDbCollection', () => { describe('PostgresDbCollection', () => {
let mockClient: any let mockClient: any
let collection: PostgresDbCollection<TestWorkspace, 'uuid'> let collection: PostgresDbCollection<TestWorkspace, 'uuid'>
@ -40,7 +42,7 @@ describe('PostgresDbCollection', () => {
unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result
} }
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid') collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid', ns)
}) })
describe('getTableName', () => { describe('getTableName', () => {
@ -208,7 +210,7 @@ describe('AccountPostgresDbCollection', () => {
unsafe: jest.fn().mockResolvedValue([]) unsafe: jest.fn().mockResolvedValue([])
} }
collection = new AccountPostgresDbCollection(mockClient as Sql) collection = new AccountPostgresDbCollection(mockClient as Sql, ns)
}) })
describe('getTableName', () => { describe('getTableName', () => {

File diff suppressed because it is too large Load Diff

View File

@ -932,7 +932,7 @@ describe('account utils', () => {
await expect(confirmEmail(mockCtx, mockDb, account, email)).rejects.toThrow( await expect(confirmEmail(mockCtx, mockDb, account, email)).rejects.toThrow(
new PlatformError( new PlatformError(
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
socialId: email, value: email,
type: SocialIdType.EMAIL type: SocialIdType.EMAIL
}) })
) )

View File

@ -51,7 +51,9 @@ import type {
WorkspaceStatusData, WorkspaceStatusData,
Sort, Sort,
Mailbox, Mailbox,
MailboxSecret MailboxSecret,
Integration,
IntegrationSecret
} from '../types' } from '../types'
import { isShallowEqual } from '../utils' import { isShallowEqual } from '../utils'
@ -373,6 +375,8 @@ export class MongoAccountDB implements AccountDB {
invite: MongoDbCollection<WorkspaceInvite, 'id'> invite: MongoDbCollection<WorkspaceInvite, 'id'>
mailbox: MongoDbCollection<Mailbox, 'mailbox'> mailbox: MongoDbCollection<Mailbox, 'mailbox'>
mailboxSecret: MongoDbCollection<MailboxSecret> mailboxSecret: MongoDbCollection<MailboxSecret>
integration: MongoDbCollection<Integration>
integrationSecret: MongoDbCollection<IntegrationSecret>
workspaceMembers: MongoDbCollection<WorkspaceMember> workspaceMembers: MongoDbCollection<WorkspaceMember>
@ -388,6 +392,8 @@ export class MongoAccountDB implements AccountDB {
this.invite = new MongoDbCollection<WorkspaceInvite, 'id'>('invite', db, 'id') this.invite = new MongoDbCollection<WorkspaceInvite, 'id'>('invite', db, 'id')
this.mailbox = new MongoDbCollection<Mailbox, 'mailbox'>('mailbox', db) this.mailbox = new MongoDbCollection<Mailbox, 'mailbox'>('mailbox', db)
this.mailboxSecret = new MongoDbCollection<MailboxSecret>('mailboxSecrets', db) this.mailboxSecret = new MongoDbCollection<MailboxSecret>('mailboxSecrets', db)
this.integration = new MongoDbCollection<Integration>('integration', db)
this.integrationSecret = new MongoDbCollection<IntegrationSecret>('integrationSecret', db)
this.workspaceMembers = new MongoDbCollection<WorkspaceMember>('workspaceMembers', db) this.workspaceMembers = new MongoDbCollection<WorkspaceMember>('workspaceMembers', db)
} }

View File

@ -41,7 +41,9 @@ import type {
WorkspaceInfoWithStatus, WorkspaceInfoWithStatus,
Sort, Sort,
Mailbox, Mailbox,
MailboxSecret MailboxSecret,
Integration,
IntegrationSecret
} from '../types' } from '../types'
function toSnakeCase (str: string): string { function toSnakeCase (str: string): string {
@ -86,13 +88,18 @@ function convertKeysToSnakeCase (obj: any): any {
return obj return obj
} }
function formatVar (idx: number, type?: string): string {
return type != null ? `$${idx}::${type}` : `$${idx}`
}
export class PostgresDbCollection<T extends Record<string, any>, K extends keyof T | undefined = undefined> export class PostgresDbCollection<T extends Record<string, any>, K extends keyof T | undefined = undefined>
implements DbCollection<T> { implements DbCollection<T> {
constructor ( constructor (
readonly name: string, readonly name: string,
readonly client: Sql, readonly client: Sql,
readonly idKey?: K, readonly idKey?: K,
readonly ns: string = 'global_account' readonly ns?: string,
readonly fieldTypes: Record<string, string> = {}
) {} ) {}
getTableName (): string { getTableName (): string {
@ -108,7 +115,14 @@ implements DbCollection<T> {
} }
protected buildWhereClause (query: Query<T>, lastRefIdx: number = 0): [string, any[]] { protected buildWhereClause (query: Query<T>, lastRefIdx: number = 0): [string, any[]] {
if (Object.keys(query).length === 0) { const filteredQuery = Object.entries(query).reduce<Query<T>>((acc, [key, value]) => {
if (value !== undefined) {
acc[key as keyof Query<T>] = value
}
return acc
}, {})
if (Object.keys(filteredQuery).length === 0) {
return ['', []] return ['', []]
} }
@ -116,9 +130,12 @@ implements DbCollection<T> {
const values: any[] = [] const values: any[] = []
let currIdx: number = lastRefIdx let currIdx: number = lastRefIdx
for (const key of Object.keys(query)) { for (const key of Object.keys(filteredQuery)) {
const qKey = query[key] const qKey = filteredQuery[key]
if (qKey === undefined) continue
const operator = qKey != null && typeof qKey === 'object' ? Object.keys(qKey)[0] : '' const operator = qKey != null && typeof qKey === 'object' ? Object.keys(qKey)[0] : ''
const castType = this.fieldTypes[key]
const snakeKey = toSnakeCase(key) const snakeKey = toSnakeCase(key)
switch (operator) { switch (operator) {
case '$in': { case '$in': {
@ -126,7 +143,7 @@ implements DbCollection<T> {
const inVars: string[] = [] const inVars: string[] = []
for (const val of inVals) { for (const val of inVals) {
currIdx++ currIdx++
inVars.push(`$${currIdx}`) inVars.push(formatVar(currIdx, castType))
values.push(val) values.push(val)
} }
whereChunks.push(`"${snakeKey}" IN (${inVars.join(', ')})`) whereChunks.push(`"${snakeKey}" IN (${inVars.join(', ')})`)
@ -134,38 +151,42 @@ implements DbCollection<T> {
} }
case '$lt': { case '$lt': {
currIdx++ currIdx++
whereChunks.push(`"${snakeKey}" < $${currIdx}`) whereChunks.push(`"${snakeKey}" < ${formatVar(currIdx, castType)}`)
values.push(Object.values(qKey as object)[0]) values.push(Object.values(qKey as object)[0])
break break
} }
case '$lte': { case '$lte': {
currIdx++ currIdx++
whereChunks.push(`"${snakeKey}" <= $${currIdx}`) whereChunks.push(`"${snakeKey}" <= ${formatVar(currIdx, castType)}`)
values.push(Object.values(qKey as object)[0]) values.push(Object.values(qKey as object)[0])
break break
} }
case '$gt': { case '$gt': {
currIdx++ currIdx++
whereChunks.push(`"${snakeKey}" > $${currIdx}`) whereChunks.push(`"${snakeKey}" > ${formatVar(currIdx, castType)}`)
values.push(Object.values(qKey as object)[0]) values.push(Object.values(qKey as object)[0])
break break
} }
case '$gte': { case '$gte': {
currIdx++ currIdx++
whereChunks.push(`"${snakeKey}" >= $${currIdx}`) whereChunks.push(`"${snakeKey}" >= ${formatVar(currIdx, castType)}`)
values.push(Object.values(qKey as object)[0]) values.push(Object.values(qKey as object)[0])
break break
} }
case '$ne': { case '$ne': {
currIdx++ currIdx++
whereChunks.push(`"${key}" != $${currIdx}`) whereChunks.push(`"${snakeKey}" != ${formatVar(currIdx, castType)}`)
values.push(Object.values(qKey as object)[0]) values.push(Object.values(qKey as object)[0])
break break
} }
default: { default: {
currIdx++ currIdx++
whereChunks.push(`"${snakeKey}" = $${currIdx}`) if (qKey !== null) {
values.push(qKey) whereChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`)
values.push(qKey)
} else {
whereChunks.push(`"${snakeKey}" IS NULL`)
}
} }
} }
} }
@ -253,8 +274,9 @@ implements DbCollection<T> {
} }
default: { default: {
const snakeKey = toSnakeCase(key) const snakeKey = toSnakeCase(key)
const castType = this.fieldTypes[key]
currIdx++ currIdx++
updateChunks.push(`"${snakeKey}" = $${currIdx}`) updateChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`)
values.push(ops[key]) values.push(ops[key])
} }
} }
@ -297,8 +319,11 @@ export class AccountPostgresDbCollection
implements DbCollection<Account> { implements DbCollection<Account> {
private readonly passwordKeys = ['hash', 'salt'] private readonly passwordKeys = ['hash', 'salt']
constructor (readonly client: Sql) { constructor (
super('account', client, 'uuid') readonly client: Sql,
readonly ns?: string
) {
super('account', client, 'uuid', ns)
} }
getPasswordsTableName (): string { getPasswordsTableName (): string {
@ -381,21 +406,25 @@ export class PostgresAccountDB implements AccountDB {
invite: PostgresDbCollection<WorkspaceInvite, 'id'> invite: PostgresDbCollection<WorkspaceInvite, 'id'>
mailbox: PostgresDbCollection<Mailbox, 'mailbox'> mailbox: PostgresDbCollection<Mailbox, 'mailbox'>
mailboxSecret: PostgresDbCollection<MailboxSecret> mailboxSecret: PostgresDbCollection<MailboxSecret>
integration: PostgresDbCollection<Integration>
integrationSecret: PostgresDbCollection<IntegrationSecret>
constructor ( constructor (
readonly client: Sql, readonly client: Sql,
readonly ns: string = 'global_account' readonly ns: string = 'global_account'
) { ) {
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid') this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid', ns)
this.account = new AccountPostgresDbCollection(client) this.account = new AccountPostgresDbCollection(client, ns)
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id') this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id', ns)
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client) this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client, undefined, ns)
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid') this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid', ns)
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client) this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client, undefined, ns)
this.otp = new PostgresDbCollection<OTP>('otp', client) this.otp = new PostgresDbCollection<OTP>('otp', client, undefined, ns)
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id') this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id', ns)
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client) this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client, undefined, ns)
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client) this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client, undefined, ns)
this.integration = new PostgresDbCollection<Integration>('integrations', client, undefined, ns)
this.integrationSecret = new PostgresDbCollection<IntegrationSecret>('integration_secrets', client, undefined, ns)
} }
getWsMembersTableName (): string { getWsMembersTableName (): string {
@ -646,7 +675,8 @@ export class PostgresAccountDB implements AccountDB {
this.getV3Migration(), this.getV3Migration(),
this.getV4Migration(), this.getV4Migration(),
this.getV4Migration1(), 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
);
`
]
}
} }

View File

@ -17,31 +17,20 @@ import {
AccountRole, AccountRole,
buildSocialIdString, buildSocialIdString,
concatLink, concatLink,
Data,
isActiveMode, isActiveMode,
isWorkspaceCreating, isWorkspaceCreating,
MeasureContext, MeasureContext,
SocialIdType, SocialIdType,
systemAccountUuid, systemAccountUuid,
Version,
type BackupStatus,
type Branding, type Branding,
type Person, type Person,
type PersonId, type PersonId,
type PersonInfo, type PersonInfo,
type PersonUuid, type PersonUuid,
type WorkspaceMemberInfo, type WorkspaceMemberInfo,
type WorkspaceMode,
type WorkspaceUuid type WorkspaceUuid
} from '@hcengineering/core' } from '@hcengineering/core'
import platform, { import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
getMetadata,
PlatformError,
Severity,
Status,
translate,
unknownError
} from '@hcengineering/platform'
import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token' import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
import { isAdminEmail } from './admin' import { isAdminEmail } from './admin'
@ -55,13 +44,9 @@ import type {
OtpInfo, OtpInfo,
RegionInfo, RegionInfo,
SocialId, SocialId,
Workspace,
WorkspaceEvent,
WorkspaceInfoWithStatus, WorkspaceInfoWithStatus,
WorkspaceInviteInfo, WorkspaceInviteInfo,
WorkspaceLoginInfo, WorkspaceLoginInfo
WorkspaceOperation,
WorkspaceStatus
} from './types' } from './types'
import { import {
checkInvite, checkInvite,
@ -80,12 +65,9 @@ import {
getRegions, getRegions,
getRolePower, getRolePower,
getMailUrl, getMailUrl,
getSocialIdByKey,
getWorkspaceById, getWorkspaceById,
getWorkspaceInfoWithStatusById, getWorkspaceInfoWithStatusById,
getWorkspaceInvite, getWorkspaceInvite,
getWorkspaces,
getWorkspacesInfoWithStatusByIds,
GUEST_ACCOUNT, GUEST_ACCOUNT,
isOtpValid, isOtpValid,
selectWorkspace, selectWorkspace,
@ -103,11 +85,14 @@ import {
isEmail, isEmail,
generatePassword, generatePassword,
addSocialId, addSocialId,
releaseSocialId releaseSocialId,
updateWorkspaceRole
} from './utils' } from './utils'
import { type AccountServiceMethods, getServiceMethods } from './serviceOperations'
// Move to config? // Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params
const processingTimeoutMs = 30 * 1000 // to the database layer when searching/inserting as they may contain SQL injection
// !!! NEVER PASS "params" DIRECTLY in any DB functions !!!
const workspaceLimitPerUser = const workspaceLimitPerUser =
process.env.WORKSPACE_LIMIT_PER_USER != null ? parseInt(process.env.WORKSPACE_LIMIT_PER_USER) : 10 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) { if (emailSocialId == null) {
ctx.error('Email social id not found', { email, normalizedEmail }) ctx.error('Email social id not found', { email, normalizedEmail })
throw new PlatformError( 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) { if (emailSocialId == null) {
ctx.error('Email social id not found', { email }) ctx.error('Email social id not found', { email })
throw new PlatformError( 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 return workspace
} }
export async function listWorkspaces (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
region?: string | null
mode?: WorkspaceMode | null
}
): Promise<WorkspaceInfoWithStatus[]> {
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<boolean> {
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<WorkspaceStatus> = {}
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. * Validates the token and returns the decoded account information.
*/ */
@ -1630,357 +1493,6 @@ export async function getWorkspaceMembers (
return await db.getWorkspaceMembers(workspace) return await db.getWorkspaceMembers(workspace)
} }
export async function updateWorkspaceRoleBySocialKey (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
socialKey: string
targetRole: AccountRole
}
): Promise<void> {
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<void> {
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<Version>
operation: WorkspaceOperation
}
): Promise<WorkspaceInfoWithStatus | undefined> {
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<Version> // A worker version
progress: number
message?: string
}
): Promise<void> {
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<WorkspaceStatus> = {}
const wsUpdate: Partial<Workspace> = {}
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<Version>
operation: WorkspaceOperation
}
): Promise<void> {
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<void> {
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<void> {
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 ( export async function ensurePerson (
ctx: MeasureContext, ctx: MeasureContext,
db: AccountDB, db: AccountDB,
@ -2021,7 +1533,14 @@ export async function ensurePerson (
return { uuid: personUuid, socialId: newSocialId } return { uuid: personUuid, socialId: newSocialId }
} }
async function getMailboxOptions (): Promise<MailboxOptions> { async function getMailboxOptions (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<MailboxOptions> {
decodeTokenVerbose(ctx, token)
return { return {
availableDomains: process.env.MAILBOX_DOMAINS?.split(',') ?? [], availableDomains: process.env.MAILBOX_DOMAINS?.split(',') ?? [],
minNameLength: parseInt(process.env.MAILBOX_MIN_NAME_LENGTH ?? '6'), minNameLength: parseInt(process.env.MAILBOX_MIN_NAME_LENGTH ?? '6'),
@ -2045,7 +1564,7 @@ async function createMailbox (
const normalizedName = cleanEmail(name) const normalizedName = cleanEmail(name)
const normalizedDomain = cleanEmail(domain) const normalizedDomain = cleanEmail(domain)
const mailbox = normalizedName + '@' + normalizedDomain 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)) { if (normalizedName.length === 0 || normalizedDomain.length === 0 || !isEmail(mailbox)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.MailboxError, { reason: 'invalid-name' })) 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 }) 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<PersonId> {
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 = export type AccountMethods =
| AccountServiceMethods
| 'login' | 'login'
| 'loginOtp' | 'loginOtp'
| 'signUp' | 'signUp'
@ -2150,14 +1655,8 @@ export type AccountMethods =
| 'getUserWorkspaces' | 'getUserWorkspaces'
| 'getWorkspaceInfo' | 'getWorkspaceInfo'
| 'getWorkspacesInfo' | 'getWorkspacesInfo'
| 'listWorkspaces'
| 'getLoginInfoByToken' | 'getLoginInfoByToken'
| 'getSocialIds' | 'getSocialIds'
| 'getPendingWorkspace'
| 'updateWorkspaceInfo'
| 'workerHandshake'
| 'updateBackupInfo'
| 'assignWorkspace'
| 'getPerson' | 'getPerson'
| 'getPersonInfo' | 'getPersonInfo'
| 'getWorkspaceMembers' | 'getWorkspaceMembers'
@ -2165,14 +1664,11 @@ export type AccountMethods =
| 'findPersonBySocialKey' | 'findPersonBySocialKey'
| 'findPersonBySocialId' | 'findPersonBySocialId'
| 'findSocialIdBySocialKey' | 'findSocialIdBySocialKey'
| 'performWorkspaceOperation'
| 'updateWorkspaceRoleBySocialKey'
| 'ensurePerson' | 'ensurePerson'
| 'getMailboxOptions' | 'getMailboxOptions'
| 'createMailbox' | 'createMailbox'
| 'getMailboxes' | 'getMailboxes'
| 'deleteMailbox' | 'deleteMailbox'
| 'addSocialIdToPerson'
/** /**
* @public * @public
@ -2207,6 +1703,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
createMailbox: wrap(createMailbox), createMailbox: wrap(createMailbox),
getMailboxes: wrap(getMailboxes), getMailboxes: wrap(getMailboxes),
deleteMailbox: wrap(deleteMailbox), deleteMailbox: wrap(deleteMailbox),
ensurePerson: wrap(ensurePerson),
/* READ OPERATIONS */ /* READ OPERATIONS */
getRegionInfo: wrap(getRegionInfo), getRegionInfo: wrap(getRegionInfo),
@ -2224,18 +1721,10 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
getMailboxOptions: wrap(getMailboxOptions), getMailboxOptions: wrap(getMailboxOptions),
/* SERVICE METHODS */ /* SERVICE METHODS */
getPendingWorkspace: wrap(getPendingWorkspace), ...getServiceMethods()
updateWorkspaceInfo: wrap(updateWorkspaceInfo),
workerHandshake: wrap(workerHandshake),
updateBackupInfo: wrap(updateBackupInfo),
assignWorkspace: wrap(assignWorkspace),
listWorkspaces: wrap(listWorkspaces),
performWorkspaceOperation: wrap(performWorkspaceOperation),
updateWorkspaceRoleBySocialKey: wrap(updateWorkspaceRoleBySocialKey),
ensurePerson: wrap(ensurePerson),
addSocialIdToPerson: wrap(addSocialIdToPerson)
} }
} }
export * from './plugin' export * from './plugin'
export * from './serviceOperations'
export default accountPlugin export default accountPlugin

View File

@ -0,0 +1,805 @@
//
// Copyright © 2022-2024 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 {
AccountRole,
Data,
isActiveMode,
MeasureContext,
SocialIdType,
Version,
WorkspaceMode,
type BackupStatus,
type Branding,
type PersonId,
type PersonUuid,
type WorkspaceUuid
} from '@hcengineering/core'
import platform, { getMetadata, PlatformError, Severity, Status, unknownError } from '@hcengineering/platform'
import { decodeTokenVerbose } from '@hcengineering/server-token'
import { accountPlugin } from './plugin'
import type {
AccountDB,
AccountMethodHandler,
Integration,
IntegrationKey,
IntegrationSecret,
IntegrationSecretKey,
Workspace,
WorkspaceEvent,
WorkspaceInfoWithStatus,
WorkspaceOperation,
WorkspaceStatus
} from './types'
import {
cleanEmail,
getAccount,
getEmailSocialId,
getRegions,
getRolePower,
getSocialIdByKey,
getWorkspaceById,
getWorkspaceInfoWithStatusById,
getWorkspacesInfoWithStatusByIds,
verifyAllowedServices,
wrap,
addSocialId,
getWorkspaces,
updateWorkspaceRole
} from './utils'
// 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 !!!
// Move to config?
const processingTimeoutMs = 30 * 1000
export async function listWorkspaces (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
region?: string | null
mode?: WorkspaceMode | null
}
): Promise<WorkspaceInfoWithStatus[]> {
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<boolean> {
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<WorkspaceStatus> = {}
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<void> {
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<Version>
operation: WorkspaceOperation
}
): Promise<WorkspaceInfoWithStatus | undefined> {
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<Version> // A worker version
progress: number
message?: string
}
): Promise<void> {
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<WorkspaceStatus> = {}
const wsUpdate: Partial<Workspace> = {}
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<Version>
operation: WorkspaceOperation
}
): Promise<void> {
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<void> {
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<void> {
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<PersonId> {
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<void> {
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<void> {
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<void> {
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<Integration[]> {
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<Integration | null> {
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<void> {
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<void> {
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<void> {
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<IntegrationSecret | null> {
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<IntegrationSecret[]> {
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<Record<AccountServiceMethods, AccountMethodHandler>> {
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)
}
}

View File

@ -139,6 +139,25 @@ export interface MailboxInfo {
mailbox: string mailbox: string
} }
export interface Integration {
socialId: PersonId
kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc.
workspaceUuid?: WorkspaceUuid
data?: Record<string, any>
}
export type IntegrationKey = Omit<Integration, 'data'>
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<IntegrationSecret, 'secret'>
/* ========= S U P P L E M E N T A R Y ========= */ /* ========= S U P P L E M E N T A R Y ========= */
export interface WorkspaceInfoWithStatus extends Workspace { export interface WorkspaceInfoWithStatus extends Workspace {
@ -166,6 +185,8 @@ export interface AccountDB {
invite: DbCollection<WorkspaceInvite> invite: DbCollection<WorkspaceInvite>
mailbox: DbCollection<Mailbox> mailbox: DbCollection<Mailbox>
mailboxSecret: DbCollection<MailboxSecret> mailboxSecret: DbCollection<MailboxSecret>
integration: DbCollection<Integration>
integrationSecret: DbCollection<IntegrationSecret>
init: () => Promise<void> init: () => Promise<void>
createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise<WorkspaceUuid> createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise<WorkspaceUuid>

View File

@ -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<void> {
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: * 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 }) ctx.error('Email social id not found', { account, normalizedEmail })
throw new PlatformError( throw new PlatformError(
new Status(Severity.ERROR, platform.status.SocialIdNotFound, { new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
socialId: normalizedEmail, value: normalizedEmail,
type: SocialIdType.EMAIL type: SocialIdType.EMAIL
}) })
) )