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,
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<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>
deleteCookie: () => Promise<void>
@ -721,6 +744,105 @@ class AccountClientImpl implements AccountClient {
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> {
const url = concatLink(this.url, '/cookie')
const response = await fetch(url, { ...this.request, method: 'PUT' })

View File

@ -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<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
// 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,

View File

@ -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()
})
})
})

View File

@ -31,6 +31,8 @@ interface TestWorkspace {
lastProcessingTime?: number
}
const ns = 'global_account'
describe('PostgresDbCollection', () => {
let mockClient: any
let collection: PostgresDbCollection<TestWorkspace, 'uuid'>
@ -40,7 +42,7 @@ describe('PostgresDbCollection', () => {
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', () => {
@ -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', () => {

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(
new PlatformError(
new Status(Severity.ERROR, platform.status.SocialIdNotFound, {
socialId: email,
value: email,
type: SocialIdType.EMAIL
})
)

View File

@ -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<WorkspaceInvite, 'id'>
mailbox: MongoDbCollection<Mailbox, 'mailbox'>
mailboxSecret: MongoDbCollection<MailboxSecret>
integration: MongoDbCollection<Integration>
integrationSecret: MongoDbCollection<IntegrationSecret>
workspaceMembers: MongoDbCollection<WorkspaceMember>
@ -388,6 +392,8 @@ export class MongoAccountDB implements AccountDB {
this.invite = new MongoDbCollection<WorkspaceInvite, 'id'>('invite', db, 'id')
this.mailbox = new MongoDbCollection<Mailbox, 'mailbox'>('mailbox', 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)
}

View File

@ -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<T extends Record<string, any>, K extends keyof T | undefined = undefined>
implements DbCollection<T> {
constructor (
readonly name: string,
readonly client: Sql,
readonly idKey?: K,
readonly ns: string = 'global_account'
readonly ns?: string,
readonly fieldTypes: Record<string, string> = {}
) {}
getTableName (): string {
@ -108,7 +115,14 @@ implements DbCollection<T> {
}
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 ['', []]
}
@ -116,9 +130,12 @@ implements DbCollection<T> {
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<T> {
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<T> {
}
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<T> {
}
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<Account> {
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<WorkspaceInvite, 'id'>
mailbox: PostgresDbCollection<Mailbox, 'mailbox'>
mailboxSecret: PostgresDbCollection<MailboxSecret>
integration: PostgresDbCollection<Integration>
integrationSecret: PostgresDbCollection<IntegrationSecret>
constructor (
readonly client: Sql,
readonly ns: string = 'global_account'
) {
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid')
this.account = new AccountPostgresDbCollection(client)
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id')
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client)
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid')
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client)
this.otp = new PostgresDbCollection<OTP>('otp', client)
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id')
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client)
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client)
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid', ns)
this.account = new AccountPostgresDbCollection(client, ns)
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id', ns)
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client, undefined, ns)
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid', ns)
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client, undefined, ns)
this.otp = new PostgresDbCollection<OTP>('otp', client, undefined, ns)
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id', ns)
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client, undefined, ns)
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 {
@ -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
);
`
]
}
}

View File

@ -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<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.
*/
@ -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<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 (
ctx: MeasureContext,
db: AccountDB,
@ -2021,7 +1533,14 @@ export async function ensurePerson (
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 {
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<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 =
| 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<Record<AccountMe
createMailbox: wrap(createMailbox),
getMailboxes: wrap(getMailboxes),
deleteMailbox: wrap(deleteMailbox),
ensurePerson: wrap(ensurePerson),
/* READ OPERATIONS */
getRegionInfo: wrap(getRegionInfo),
@ -2224,18 +1721,10 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
getMailboxOptions: wrap(getMailboxOptions),
/* SERVICE METHODS */
getPendingWorkspace: wrap(getPendingWorkspace),
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)
...getServiceMethods()
}
}
export * from './plugin'
export * from './serviceOperations'
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
}
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 ========= */
export interface WorkspaceInfoWithStatus extends Workspace {
@ -166,6 +185,8 @@ export interface AccountDB {
invite: DbCollection<WorkspaceInvite>
mailbox: DbCollection<Mailbox>
mailboxSecret: DbCollection<MailboxSecret>
integration: DbCollection<Integration>
integrationSecret: DbCollection<IntegrationSecret>
init: () => Promise<void>
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:
*
@ -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
})
)