mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-12 04:37:44 +02:00
Add tools to delete user or workspace
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
//
|
||||
// Copyright © 2026 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,
|
||||
SocialIdType,
|
||||
type AccountUuid,
|
||||
type MeasureContext,
|
||||
type WorkspaceDataId,
|
||||
type WorkspaceUuid
|
||||
} from '@hcengineering/core'
|
||||
import {
|
||||
confirmRetype,
|
||||
deleteUser,
|
||||
deleteWorkspace,
|
||||
deleteWorkspaceStorage,
|
||||
type AccountAdmin,
|
||||
type AccountDbView,
|
||||
type Prompter,
|
||||
type StorageOps,
|
||||
type WorkspaceResolver
|
||||
} from './deletion'
|
||||
|
||||
const wsUuid = (s: string): WorkspaceUuid => s as WorkspaceUuid
|
||||
const acctUuid = (s: string): AccountUuid => s as AccountUuid
|
||||
|
||||
type CtxStub = MeasureContext & { info: jest.Mock }
|
||||
|
||||
function createCtx (): CtxStub {
|
||||
return { info: jest.fn() } as unknown as CtxStub
|
||||
}
|
||||
|
||||
interface AuditCall {
|
||||
action: string
|
||||
attrs: Record<string, unknown>
|
||||
}
|
||||
|
||||
function auditCalls (ctx: CtxStub): AuditCall[] {
|
||||
return ctx.info.mock.calls
|
||||
.filter((c: unknown[]) => typeof c[0] === 'string' && c[0].startsWith('audit.'))
|
||||
.map((c: unknown[]) => ({
|
||||
action: (c[0] as string).slice('audit.'.length),
|
||||
attrs: (c[1] ?? {}) as Record<string, unknown>
|
||||
}))
|
||||
}
|
||||
|
||||
function createPrompter (answers: string[]): Prompter {
|
||||
let i = 0
|
||||
return {
|
||||
prompt: jest.fn(async () => answers[i++] ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
function createAccountDb (config: {
|
||||
byEmail?: Record<string, AccountUuid>
|
||||
workspaces?: Record<string, Array<{ uuid: WorkspaceUuid, url: string, dataId?: WorkspaceDataId }>>
|
||||
members?: Record<string, Array<{ person: AccountUuid, role: AccountRole }>>
|
||||
}): AccountDbView {
|
||||
return {
|
||||
socialId: {
|
||||
findOne: jest.fn(async (q) => {
|
||||
if (q.type !== SocialIdType.EMAIL) return null
|
||||
const uuid = config.byEmail?.[q.value]
|
||||
return uuid != null ? { personUuid: uuid } : null
|
||||
})
|
||||
},
|
||||
getAccountWorkspaces: jest.fn(async (uuid) => config.workspaces?.[uuid] ?? []),
|
||||
getWorkspaceMembers: jest.fn(async (uuid) => config.members?.[uuid] ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
function createAccountAdmin (): AccountAdmin & {
|
||||
deleteAccount: jest.Mock
|
||||
performWorkspaceOperation: jest.Mock
|
||||
} {
|
||||
return {
|
||||
deleteAccount: jest.fn(async () => {}),
|
||||
performWorkspaceOperation: jest.fn(async () => true)
|
||||
}
|
||||
}
|
||||
|
||||
function createResolver (
|
||||
workspaces: Array<{ uuid: WorkspaceUuid, url: string, dataId?: WorkspaceDataId }>
|
||||
): WorkspaceResolver {
|
||||
return {
|
||||
getWorkspace: jest.fn(async (idOrUrl) => {
|
||||
return workspaces.find((w) => w.uuid === idOrUrl || w.url === idOrUrl) ?? null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('confirmRetype', () => {
|
||||
it('accepts --yes preflight when it matches expected', async () => {
|
||||
const prompter = createPrompter([])
|
||||
const ok = await confirmRetype(prompter, 'acme-prod', { yes: 'acme-prod' })
|
||||
expect(ok).toBe(true)
|
||||
expect(prompter.prompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects --yes preflight when it does not match', async () => {
|
||||
const prompter = createPrompter(['acme-prod'])
|
||||
const ok = await confirmRetype(prompter, 'acme-prod', { yes: 'acme-staging' })
|
||||
expect(ok).toBe(false)
|
||||
expect(prompter.prompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to interactive prompt and accepts exact match', async () => {
|
||||
const prompter = createPrompter(['acme-prod'])
|
||||
const ok = await confirmRetype(prompter, 'acme-prod', {})
|
||||
expect(ok).toBe(true)
|
||||
expect(prompter.prompt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects interactive mismatch (case-sensitive, trimmed)', async () => {
|
||||
const prompter = createPrompter(['Acme-Prod'])
|
||||
const ok = await confirmRetype(prompter, 'acme-prod', {})
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('refuses unknown email and audits nothing', async () => {
|
||||
const ctx = createCtx()
|
||||
const db = createAccountDb({ byEmail: {} })
|
||||
const admin = createAccountAdmin()
|
||||
const prompter = createPrompter([])
|
||||
|
||||
const result = await deleteUser({
|
||||
ctx,
|
||||
db,
|
||||
admin,
|
||||
prompter,
|
||||
params: { email: 'ghost@nope.io', confirm: 'ghost@nope.io', operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('not-found')
|
||||
expect(admin.deleteAccount).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses when user is sole Owner of a workspace and force is false', async () => {
|
||||
const ctx = createCtx()
|
||||
const uuid = acctUuid('user-1')
|
||||
const ws = wsUuid('ws-1')
|
||||
const db = createAccountDb({
|
||||
byEmail: { 'u@x.io': uuid },
|
||||
workspaces: { [uuid]: [{ uuid: ws, url: 'acme' }] },
|
||||
members: { [ws]: [{ person: uuid, role: AccountRole.Owner }] }
|
||||
})
|
||||
const admin = createAccountAdmin()
|
||||
const prompter = createPrompter([])
|
||||
|
||||
const result = await deleteUser({
|
||||
ctx,
|
||||
db,
|
||||
admin,
|
||||
prompter,
|
||||
params: { email: 'u@x.io', confirm: 'u@x.io', operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('refused-sole-owner')
|
||||
expect(admin.deleteAccount).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx).map((c) => c.action)).toEqual(['user.delete.refused'])
|
||||
})
|
||||
|
||||
it('proceeds with --force even if sole Owner, and writes start+done audit', async () => {
|
||||
const ctx = createCtx()
|
||||
const uuid = acctUuid('user-2')
|
||||
const ws = wsUuid('ws-2')
|
||||
const db = createAccountDb({
|
||||
byEmail: { 'u@x.io': uuid },
|
||||
workspaces: { [uuid]: [{ uuid: ws, url: 'acme' }] },
|
||||
members: { [ws]: [{ person: uuid, role: AccountRole.Owner }] }
|
||||
})
|
||||
const admin = createAccountAdmin()
|
||||
const prompter = createPrompter([])
|
||||
|
||||
const result = await deleteUser({
|
||||
ctx,
|
||||
db,
|
||||
admin,
|
||||
prompter,
|
||||
params: {
|
||||
email: 'u@x.io',
|
||||
confirm: 'u@x.io',
|
||||
force: true,
|
||||
reason: 'GDPR-123',
|
||||
operator: 'op@huly.io'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe('deleted')
|
||||
expect(admin.deleteAccount).toHaveBeenCalledWith(uuid)
|
||||
const audits = auditCalls(ctx)
|
||||
expect(audits.map((c) => c.action)).toEqual(['user.delete.start', 'user.delete.done'])
|
||||
expect(audits[0].attrs['audit.operator']).toBe('op@huly.io')
|
||||
expect(audits[0].attrs['audit.reason']).toBe('GDPR-123')
|
||||
expect(JSON.parse(audits[0].attrs['audit.target'] as string)).toEqual({
|
||||
kind: 'user',
|
||||
email: 'u@x.io',
|
||||
uuid
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses on bad confirmation and does not call deleteAccount', async () => {
|
||||
const ctx = createCtx()
|
||||
const uuid = acctUuid('user-3')
|
||||
const db = createAccountDb({ byEmail: { 'u@x.io': uuid } })
|
||||
const admin = createAccountAdmin()
|
||||
const prompter = createPrompter(['wrong'])
|
||||
|
||||
const result = await deleteUser({
|
||||
ctx,
|
||||
db,
|
||||
admin,
|
||||
prompter,
|
||||
params: { email: 'u@x.io', confirm: 'wrong', operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('refused-confirmation')
|
||||
expect(admin.deleteAccount).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx).map((c) => c.action)).toEqual(['user.delete.refused'])
|
||||
})
|
||||
|
||||
it('dryRun reports plan but does not call deleteAccount and emits no audit', async () => {
|
||||
const ctx = createCtx()
|
||||
const uuid = acctUuid('user-4')
|
||||
const ws = wsUuid('ws-4')
|
||||
const db = createAccountDb({
|
||||
byEmail: { 'u@x.io': uuid },
|
||||
workspaces: { [uuid]: [{ uuid: ws, url: 'acme' }] },
|
||||
members: { [ws]: [{ person: uuid, role: AccountRole.User }] }
|
||||
})
|
||||
const admin = createAccountAdmin()
|
||||
const prompter = createPrompter([])
|
||||
|
||||
const result = await deleteUser({
|
||||
ctx,
|
||||
db,
|
||||
admin,
|
||||
prompter,
|
||||
params: { email: 'u@x.io', dryRun: true, operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
if (result.status !== 'dry-run') throw new Error(`expected dry-run, got ${result.status}`)
|
||||
expect(result.accountUuid).toBe(uuid)
|
||||
expect(result.workspaces.map((w) => w.uuid)).toEqual([ws])
|
||||
expect(admin.deleteAccount).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteWorkspace', () => {
|
||||
it('marks workspace pending-deletion and audits start+marked', async () => {
|
||||
const ctx = createCtx()
|
||||
const ws = wsUuid('ws-100')
|
||||
const resolver = createResolver([{ uuid: ws, url: 'acme-prod' }])
|
||||
const admin = createAccountAdmin()
|
||||
const prompter = createPrompter([])
|
||||
|
||||
const result = await deleteWorkspace({
|
||||
ctx,
|
||||
resolver,
|
||||
admin,
|
||||
prompter,
|
||||
params: { workspace: 'acme-prod', confirm: 'acme-prod', operator: 'op@huly.io', reason: 'ticket-7' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('marked')
|
||||
expect(admin.performWorkspaceOperation).toHaveBeenCalledWith(ws, 'delete')
|
||||
const audits = auditCalls(ctx)
|
||||
expect(audits.map((c) => c.action)).toEqual(['workspace.delete.start', 'workspace.delete.marked'])
|
||||
expect(JSON.parse(audits[0].attrs['audit.target'] as string)).toEqual({
|
||||
kind: 'workspace',
|
||||
uuid: ws,
|
||||
url: 'acme-prod'
|
||||
})
|
||||
expect(audits[0].attrs['audit.reason']).toBe('ticket-7')
|
||||
})
|
||||
|
||||
it('refuses on bad confirmation and emits a refused audit entry', async () => {
|
||||
const ctx = createCtx()
|
||||
const ws = wsUuid('ws-101')
|
||||
const resolver = createResolver([{ uuid: ws, url: 'acme-prod' }])
|
||||
const admin = createAccountAdmin()
|
||||
const prompter = createPrompter(['totally-wrong'])
|
||||
|
||||
const result = await deleteWorkspace({
|
||||
ctx,
|
||||
resolver,
|
||||
admin,
|
||||
prompter,
|
||||
params: { workspace: 'acme-prod', confirm: 'totally-wrong', operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('refused-confirmation')
|
||||
expect(admin.performWorkspaceOperation).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx).map((c) => c.action)).toEqual(['workspace.delete.refused'])
|
||||
})
|
||||
|
||||
it('returns not-found when workspace is missing', async () => {
|
||||
const ctx = createCtx()
|
||||
const resolver = createResolver([])
|
||||
const admin = createAccountAdmin()
|
||||
|
||||
const result = await deleteWorkspace({
|
||||
ctx,
|
||||
resolver,
|
||||
admin,
|
||||
prompter: createPrompter([]),
|
||||
params: { workspace: 'ghost', confirm: 'ghost', operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('not-found')
|
||||
expect(admin.performWorkspaceOperation).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('dryRun does not call performWorkspaceOperation and emits no audit', async () => {
|
||||
const ctx = createCtx()
|
||||
const ws = wsUuid('ws-102')
|
||||
const resolver = createResolver([{ uuid: ws, url: 'acme-prod' }])
|
||||
const admin = createAccountAdmin()
|
||||
|
||||
const result = await deleteWorkspace({
|
||||
ctx,
|
||||
resolver,
|
||||
admin,
|
||||
prompter: createPrompter([]),
|
||||
params: { workspace: 'acme-prod', dryRun: true, operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('dry-run')
|
||||
expect(admin.performWorkspaceOperation).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteWorkspaceStorage', () => {
|
||||
function createStorage (batches: Array<Array<{ _id: string, size?: number }>>): StorageOps & {
|
||||
remove: jest.Mock
|
||||
listStream: jest.Mock
|
||||
} {
|
||||
let cursor = 0
|
||||
return {
|
||||
listStream: jest.fn(async () => ({
|
||||
next: async () => batches[cursor++] ?? [],
|
||||
close: async () => {}
|
||||
})),
|
||||
remove: jest.fn(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
it('dry-run is the default: reports counts but does not call remove or audit', async () => {
|
||||
const ctx = createCtx()
|
||||
const ws = wsUuid('ws-200')
|
||||
const resolver = createResolver([{ uuid: ws, url: 'acme-prod' }])
|
||||
const storage = createStorage([
|
||||
[
|
||||
{ _id: 'a', size: 10 },
|
||||
{ _id: 'b', size: 20 }
|
||||
],
|
||||
[{ _id: 'c', size: 30 }]
|
||||
])
|
||||
|
||||
const result = await deleteWorkspaceStorage({
|
||||
ctx,
|
||||
resolver,
|
||||
storage,
|
||||
prompter: createPrompter([]),
|
||||
params: { workspace: 'acme-prod', operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
if (result.status !== 'dry-run') throw new Error(`expected dry-run, got ${result.status}`)
|
||||
expect(result.objectCount).toBe(3)
|
||||
expect(result.totalBytes).toBe(60)
|
||||
expect(storage.remove).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('with apply: removes objects in batches and audits start+done', async () => {
|
||||
const ctx = createCtx()
|
||||
const ws = wsUuid('ws-201')
|
||||
const resolver = createResolver([{ uuid: ws, url: 'acme-prod', dataId: 'd-201' as WorkspaceDataId }])
|
||||
const storage = createStorage([[{ _id: 'a' }, { _id: 'b' }, { _id: 'c' }], [{ _id: 'd' }]])
|
||||
|
||||
const result = await deleteWorkspaceStorage({
|
||||
ctx,
|
||||
resolver,
|
||||
storage,
|
||||
prompter: createPrompter([]),
|
||||
params: {
|
||||
workspace: 'acme-prod',
|
||||
apply: true,
|
||||
confirm: 'acme-prod',
|
||||
batchSize: 2,
|
||||
operator: 'op@huly.io',
|
||||
reason: 'GDPR-99'
|
||||
}
|
||||
})
|
||||
|
||||
if (result.status !== 'deleted') throw new Error(`expected deleted, got ${result.status}`)
|
||||
expect(result.objectCount).toBe(4)
|
||||
// remove() is bounded by batchSize across listStream batches: 4 ids @ size 2 -> 2 calls of 2.
|
||||
expect(storage.remove).toHaveBeenCalledTimes(2)
|
||||
const callArgs = (storage.remove as jest.Mock).mock.calls.map((c) => c[2])
|
||||
expect(callArgs).toEqual([
|
||||
['a', 'b'],
|
||||
['c', 'd']
|
||||
])
|
||||
const audits = auditCalls(ctx)
|
||||
expect(audits.map((c) => c.action)).toEqual(['workspace.storage.delete.start', 'workspace.storage.delete.done'])
|
||||
expect(audits[0].attrs['audit.reason']).toBe('GDPR-99')
|
||||
})
|
||||
|
||||
it('with apply but bad confirmation: does not remove anything', async () => {
|
||||
const ctx = createCtx()
|
||||
const ws = wsUuid('ws-202')
|
||||
const resolver = createResolver([{ uuid: ws, url: 'acme-prod' }])
|
||||
const storage = createStorage([[{ _id: 'a' }]])
|
||||
|
||||
const result = await deleteWorkspaceStorage({
|
||||
ctx,
|
||||
resolver,
|
||||
storage,
|
||||
prompter: createPrompter(['wrong']),
|
||||
params: { workspace: 'acme-prod', apply: true, confirm: 'wrong', operator: 'op@huly.io' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('refused-confirmation')
|
||||
expect(storage.remove).not.toHaveBeenCalled()
|
||||
expect(auditCalls(ctx).map((c) => c.action)).toEqual(['workspace.storage.delete.refused'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,495 @@
|
||||
//
|
||||
// Copyright © 2026 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,
|
||||
SocialIdType,
|
||||
type AccountUuid,
|
||||
type MeasureContext,
|
||||
type PersonUuid,
|
||||
type WorkspaceDataId,
|
||||
type WorkspaceUuid
|
||||
} from '@hcengineering/core'
|
||||
import { createInterface } from 'readline'
|
||||
|
||||
/**
|
||||
* Operator-facing prompt used to confirm destructive operations by re-typing the target name.
|
||||
*/
|
||||
export interface Prompter {
|
||||
prompt: (question: string) => Promise<string>
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Audit event types
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stable identifiers emitted via {@link MeasureContext.info} as `audit.<action>`. Searchable in
|
||||
* Uptrace / stdout logs by exact match. Add new actions here — do not rename existing ones.
|
||||
*/
|
||||
export type AuditAction =
|
||||
| 'user.delete.start'
|
||||
| 'user.delete.done'
|
||||
| 'user.delete.refused'
|
||||
| 'workspace.delete.start'
|
||||
| 'workspace.delete.marked'
|
||||
| 'workspace.delete.refused'
|
||||
| 'workspace.storage.delete.start'
|
||||
| 'workspace.storage.delete.done'
|
||||
| 'workspace.storage.delete.refused'
|
||||
|
||||
export type AuditTarget =
|
||||
| { kind: 'user', email: string, uuid?: AccountUuid }
|
||||
| { kind: 'workspace', uuid: WorkspaceUuid, url: string }
|
||||
|
||||
export interface AuditEntry {
|
||||
action: AuditAction
|
||||
target: AuditTarget
|
||||
operator: string
|
||||
reason?: string
|
||||
time?: number
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function emitAudit (ctx: MeasureContext, entry: AuditEntry): void {
|
||||
ctx.info(`audit.${entry.action}`, {
|
||||
action: entry.action,
|
||||
operator: entry.operator,
|
||||
reason: entry.reason,
|
||||
target: JSON.stringify(entry.target),
|
||||
details: entry.details != null ? JSON.stringify(entry.details) : undefined,
|
||||
time: entry.time ?? Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal view of {@link @hcengineering/account#AccountDB} required for deletion flows.
|
||||
* Keeping it narrow keeps the module unit-testable without dragging in the full DB type.
|
||||
*/
|
||||
export interface AccountDbView {
|
||||
socialId: {
|
||||
// Mirrors AccountDB.socialId.findOne (returns SocialId, narrowed to the personUuid we need).
|
||||
findOne: (q: { type: SocialIdType, value: string }) => Promise<{ personUuid: PersonUuid } | null>
|
||||
}
|
||||
getAccountWorkspaces: (
|
||||
accountId: AccountUuid
|
||||
) => Promise<Array<{ uuid: WorkspaceUuid, url: string, dataId?: WorkspaceDataId }>>
|
||||
getWorkspaceMembers: (workspaceId: WorkspaceUuid) => Promise<Array<{ person: PersonUuid, role: AccountRole }>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal AccountClient slice used by these flows.
|
||||
*/
|
||||
export interface AccountAdmin {
|
||||
deleteAccount: (uuid: AccountUuid) => Promise<void>
|
||||
performWorkspaceOperation: (workspaceId: WorkspaceUuid, op: 'delete') => Promise<boolean>
|
||||
}
|
||||
|
||||
export interface WorkspaceResolver {
|
||||
getWorkspace: (idOrUrl: string) => Promise<{ uuid: WorkspaceUuid, url: string, dataId?: WorkspaceDataId } | null>
|
||||
}
|
||||
|
||||
export interface BlobBatchIterator {
|
||||
next: () => Promise<Array<{ _id: string, size?: number }>>
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal storage slice used by storage cleanup. Mirrors {@link StorageAdapter} but narrows
|
||||
* the shape we depend on so tests can stub it directly.
|
||||
*/
|
||||
export interface StorageOps {
|
||||
listStream: (
|
||||
ctx: MeasureContext,
|
||||
wsIds: { uuid: WorkspaceUuid, url: string, dataId?: WorkspaceDataId }
|
||||
) => Promise<BlobBatchIterator>
|
||||
remove: (
|
||||
ctx: MeasureContext,
|
||||
wsIds: { uuid: WorkspaceUuid, url: string, dataId?: WorkspaceDataId },
|
||||
objectNames: string[]
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// confirmRetype
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
export interface ConfirmOptions {
|
||||
/** Non-interactive: if equal to `expected`, treated as confirmed without prompting. */
|
||||
yes?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Require the operator to re-type the target name (or pass `--yes <name>`).
|
||||
*
|
||||
* Trimmed, case-sensitive equality. A bad `--yes` value never falls through to the prompt — that
|
||||
* would defeat the point of the non-interactive escape hatch.
|
||||
*/
|
||||
export async function confirmRetype (prompter: Prompter, expected: string, opts: ConfirmOptions): Promise<boolean> {
|
||||
if (opts.yes !== undefined) {
|
||||
return opts.yes === expected
|
||||
}
|
||||
const answer = (await prompter.prompt(`Re-type "${expected}" to confirm: `)).trim()
|
||||
return answer === expected
|
||||
}
|
||||
|
||||
/**
|
||||
* A readline-backed prompter for CLI use. Tests inject their own.
|
||||
*/
|
||||
export function createReadlinePrompter (): Prompter {
|
||||
return {
|
||||
prompt: async (question) =>
|
||||
await new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
||||
rl.question(question, (answer) => {
|
||||
rl.close()
|
||||
resolve(answer)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// deleteUser
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
export interface DeleteUserParams {
|
||||
email: string
|
||||
/** When set, value the operator typed via `--yes <value>` for non-interactive confirmation. */
|
||||
confirm?: string
|
||||
/** Skip the sole-owner safety check. Required if the user owns workspaces alone. */
|
||||
force?: boolean
|
||||
/** When true, report what would happen without touching the account. */
|
||||
dryRun?: boolean
|
||||
/** Human reason (ticket id) recorded in the audit entry. */
|
||||
reason?: string
|
||||
/** Identifier of the operator running this command. */
|
||||
operator: string
|
||||
}
|
||||
|
||||
export interface DeleteUserDeps {
|
||||
ctx: MeasureContext
|
||||
db: AccountDbView
|
||||
admin: AccountAdmin
|
||||
prompter: Prompter
|
||||
params: DeleteUserParams
|
||||
}
|
||||
|
||||
export type DeleteUserResult =
|
||||
| { status: 'not-found' }
|
||||
| {
|
||||
status: 'dry-run'
|
||||
accountUuid: AccountUuid
|
||||
workspaces: Array<{ uuid: WorkspaceUuid, url: string }>
|
||||
soleOwnerOf: WorkspaceUuid[]
|
||||
}
|
||||
| { status: 'refused-sole-owner', accountUuid: AccountUuid, soleOwnerOf: WorkspaceUuid[] }
|
||||
| { status: 'refused-confirmation', accountUuid: AccountUuid }
|
||||
| { status: 'deleted', accountUuid: AccountUuid }
|
||||
|
||||
export async function deleteUser (deps: DeleteUserDeps): Promise<DeleteUserResult> {
|
||||
const { ctx, db, admin, prompter, params } = deps
|
||||
const social = await db.socialId.findOne({ type: SocialIdType.EMAIL, value: params.email })
|
||||
if (social == null) {
|
||||
return { status: 'not-found' }
|
||||
}
|
||||
// At this point the social id resolves to an existing person; if no account row exists,
|
||||
// accountClient.deleteAccount will be a no-op. The PersonUuid -> AccountUuid cast matches
|
||||
// what the rest of the account service does (see operations.ts:deleteAccount).
|
||||
const accountUuid = social.personUuid as AccountUuid
|
||||
const workspaces = await db.getAccountWorkspaces(accountUuid)
|
||||
const soleOwnerOf: WorkspaceUuid[] = []
|
||||
for (const ws of workspaces) {
|
||||
const members = await db.getWorkspaceMembers(ws.uuid)
|
||||
const owners = members.filter((m) => m.role === AccountRole.Owner)
|
||||
if (owners.length === 1 && owners[0].person === accountUuid) {
|
||||
soleOwnerOf.push(ws.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
const target: AuditTarget = { kind: 'user', email: params.email, uuid: accountUuid }
|
||||
|
||||
if (params.dryRun === true) {
|
||||
return {
|
||||
status: 'dry-run',
|
||||
accountUuid,
|
||||
workspaces: workspaces.map((w) => ({ uuid: w.uuid, url: w.url })),
|
||||
soleOwnerOf
|
||||
}
|
||||
}
|
||||
|
||||
if (soleOwnerOf.length > 0 && params.force !== true) {
|
||||
emitAudit(ctx, {
|
||||
action: 'user.delete.refused',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason,
|
||||
details: { reason: 'sole-owner', soleOwnerOf }
|
||||
})
|
||||
return { status: 'refused-sole-owner', accountUuid, soleOwnerOf }
|
||||
}
|
||||
|
||||
const ok = await confirmRetype(prompter, params.email, { yes: params.confirm })
|
||||
if (!ok) {
|
||||
emitAudit(ctx, {
|
||||
action: 'user.delete.refused',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason,
|
||||
details: { reason: 'bad-confirmation' }
|
||||
})
|
||||
return { status: 'refused-confirmation', accountUuid }
|
||||
}
|
||||
|
||||
emitAudit(ctx, {
|
||||
action: 'user.delete.start',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason,
|
||||
details: { workspaces: workspaces.map((w) => w.uuid), soleOwnerOf, force: params.force === true }
|
||||
})
|
||||
|
||||
ctx.info('deleting user', { email: params.email, accountUuid })
|
||||
await admin.deleteAccount(accountUuid)
|
||||
|
||||
emitAudit(ctx, {
|
||||
action: 'user.delete.done',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason
|
||||
})
|
||||
|
||||
return { status: 'deleted', accountUuid }
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// deleteWorkspace
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
export interface DeleteWorkspaceParams {
|
||||
workspace: string
|
||||
confirm?: string
|
||||
dryRun?: boolean
|
||||
reason?: string
|
||||
operator: string
|
||||
}
|
||||
|
||||
export interface DeleteWorkspaceDeps {
|
||||
ctx: MeasureContext
|
||||
resolver: WorkspaceResolver
|
||||
admin: AccountAdmin
|
||||
prompter: Prompter
|
||||
params: DeleteWorkspaceParams
|
||||
}
|
||||
|
||||
export type DeleteWorkspaceResult =
|
||||
| { status: 'not-found' }
|
||||
| { status: 'dry-run', workspace: { uuid: WorkspaceUuid, url: string } }
|
||||
| { status: 'refused-confirmation', workspace: { uuid: WorkspaceUuid, url: string } }
|
||||
| { status: 'marked', workspace: { uuid: WorkspaceUuid, url: string } }
|
||||
|
||||
export async function deleteWorkspace (deps: DeleteWorkspaceDeps): Promise<DeleteWorkspaceResult> {
|
||||
const { ctx, resolver, admin, prompter, params } = deps
|
||||
const ws = await resolver.getWorkspace(params.workspace)
|
||||
if (ws == null) {
|
||||
return { status: 'not-found' }
|
||||
}
|
||||
const target: AuditTarget = { kind: 'workspace', uuid: ws.uuid, url: ws.url }
|
||||
const wsView = { uuid: ws.uuid, url: ws.url }
|
||||
|
||||
if (params.dryRun === true) {
|
||||
return { status: 'dry-run', workspace: wsView }
|
||||
}
|
||||
|
||||
const ok = await confirmRetype(prompter, ws.url, { yes: params.confirm })
|
||||
if (!ok) {
|
||||
emitAudit(ctx, {
|
||||
action: 'workspace.delete.refused',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason,
|
||||
details: { reason: 'bad-confirmation' }
|
||||
})
|
||||
return { status: 'refused-confirmation', workspace: wsView }
|
||||
}
|
||||
|
||||
emitAudit(ctx, {
|
||||
action: 'workspace.delete.start',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason
|
||||
})
|
||||
|
||||
ctx.info('marking workspace pending-deletion', { uuid: ws.uuid, url: ws.url })
|
||||
await admin.performWorkspaceOperation(ws.uuid, 'delete')
|
||||
|
||||
emitAudit(ctx, {
|
||||
action: 'workspace.delete.marked',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason
|
||||
})
|
||||
|
||||
return { status: 'marked', workspace: wsView }
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// deleteWorkspaceStorage (dry-run by default)
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
export interface DeleteStorageParams {
|
||||
workspace: string
|
||||
/** When true, actually remove the blobs. Default behaviour is a non-destructive dry-run. */
|
||||
apply?: boolean
|
||||
confirm?: string
|
||||
/** Objects per remove() call. Defaults to 500. */
|
||||
batchSize?: number
|
||||
reason?: string
|
||||
operator: string
|
||||
}
|
||||
|
||||
export interface DeleteStorageDeps {
|
||||
ctx: MeasureContext
|
||||
resolver: WorkspaceResolver
|
||||
storage: StorageOps
|
||||
prompter: Prompter
|
||||
params: DeleteStorageParams
|
||||
}
|
||||
|
||||
export type DeleteStorageResult =
|
||||
| { status: 'not-found' }
|
||||
| { status: 'dry-run', workspace: { uuid: WorkspaceUuid, url: string }, objectCount: number, totalBytes: number }
|
||||
| {
|
||||
status: 'refused-confirmation'
|
||||
workspace: { uuid: WorkspaceUuid, url: string }
|
||||
objectCount: number
|
||||
totalBytes: number
|
||||
}
|
||||
| {
|
||||
status: 'deleted'
|
||||
workspace: { uuid: WorkspaceUuid, url: string }
|
||||
objectCount: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
objectCount: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
async function scanStorage (
|
||||
ctx: MeasureContext,
|
||||
storage: StorageOps,
|
||||
wsIds: { uuid: WorkspaceUuid, url: string, dataId?: WorkspaceDataId }
|
||||
): Promise<ScanResult> {
|
||||
const it = await storage.listStream(ctx, wsIds)
|
||||
let objectCount = 0
|
||||
let totalBytes = 0
|
||||
try {
|
||||
while (true) {
|
||||
const batch = await it.next()
|
||||
if (batch.length === 0) break
|
||||
for (const b of batch) {
|
||||
objectCount++
|
||||
totalBytes += b.size ?? 0
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await it.close()
|
||||
}
|
||||
return { objectCount, totalBytes }
|
||||
}
|
||||
|
||||
async function removeAll (
|
||||
ctx: MeasureContext,
|
||||
storage: StorageOps,
|
||||
wsIds: { uuid: WorkspaceUuid, url: string, dataId?: WorkspaceDataId },
|
||||
batchSize: number
|
||||
): Promise<ScanResult> {
|
||||
const it = await storage.listStream(ctx, wsIds)
|
||||
let objectCount = 0
|
||||
let totalBytes = 0
|
||||
let buffer: string[] = []
|
||||
try {
|
||||
while (true) {
|
||||
const batch = await it.next()
|
||||
if (batch.length === 0) break
|
||||
for (const b of batch) {
|
||||
objectCount++
|
||||
totalBytes += b.size ?? 0
|
||||
buffer.push(b._id)
|
||||
if (buffer.length >= batchSize) {
|
||||
await storage.remove(ctx, wsIds, buffer)
|
||||
buffer = []
|
||||
}
|
||||
}
|
||||
}
|
||||
if (buffer.length > 0) {
|
||||
await storage.remove(ctx, wsIds, buffer)
|
||||
}
|
||||
} finally {
|
||||
await it.close()
|
||||
}
|
||||
return { objectCount, totalBytes }
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceStorage (deps: DeleteStorageDeps): Promise<DeleteStorageResult> {
|
||||
const { ctx, resolver, storage, prompter, params } = deps
|
||||
const ws = await resolver.getWorkspace(params.workspace)
|
||||
if (ws == null) {
|
||||
return { status: 'not-found' }
|
||||
}
|
||||
const target: AuditTarget = { kind: 'workspace', uuid: ws.uuid, url: ws.url }
|
||||
const wsView = { uuid: ws.uuid, url: ws.url }
|
||||
|
||||
// Default behaviour is a non-destructive dry-run scan.
|
||||
if (params.apply !== true) {
|
||||
const scan = await scanStorage(ctx, storage, ws)
|
||||
return { status: 'dry-run', workspace: wsView, ...scan }
|
||||
}
|
||||
|
||||
const ok = await confirmRetype(prompter, ws.url, { yes: params.confirm })
|
||||
if (!ok) {
|
||||
// We still report counts so the operator knows what would have been touched.
|
||||
const scan = await scanStorage(ctx, storage, ws)
|
||||
emitAudit(ctx, {
|
||||
action: 'workspace.storage.delete.refused',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason,
|
||||
details: { reason: 'bad-confirmation', ...scan }
|
||||
})
|
||||
return { status: 'refused-confirmation', workspace: wsView, ...scan }
|
||||
}
|
||||
|
||||
emitAudit(ctx, {
|
||||
action: 'workspace.storage.delete.start',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason
|
||||
})
|
||||
|
||||
const result = await removeAll(ctx, storage, ws, params.batchSize ?? 500)
|
||||
|
||||
emitAudit(ctx, {
|
||||
action: 'workspace.storage.delete.done',
|
||||
target,
|
||||
operator: params.operator,
|
||||
reason: params.reason,
|
||||
details: { objectCount: result.objectCount, totalBytes: result.totalBytes }
|
||||
})
|
||||
|
||||
return { status: 'deleted', workspace: wsView, ...result }
|
||||
}
|
||||
@@ -130,6 +130,14 @@ import { ensureMissingSocialIdentities } from './contact'
|
||||
import { performGithubAccountMigrations } from './github'
|
||||
import { performGmailAccountMigrations } from './gmail'
|
||||
import { getToolToken, getWorkspace, getWorkspaceTransactorEndpoint } from './utils'
|
||||
import {
|
||||
createReadlinePrompter,
|
||||
deleteUser,
|
||||
deleteWorkspace,
|
||||
deleteWorkspaceStorage,
|
||||
type AccountAdmin,
|
||||
type WorkspaceResolver
|
||||
} from './deletion'
|
||||
|
||||
import { createRestClient } from '@hcengineering/api-client'
|
||||
import { type CardID } from '@hcengineering/communication-types'
|
||||
@@ -466,6 +474,106 @@ export function devTool (
|
||||
// })
|
||||
// })
|
||||
|
||||
function resolveOperator (): string {
|
||||
return process.env.SUDO_USER ?? process.env.USER ?? process.env.USERNAME ?? 'unknown'
|
||||
}
|
||||
|
||||
function makeAdmin (): AccountAdmin {
|
||||
const client = getAccountClient(getToolToken())
|
||||
return {
|
||||
deleteAccount: async (uuid) => {
|
||||
await client.deleteAccount(uuid)
|
||||
},
|
||||
performWorkspaceOperation: async (workspaceId, op) => await client.performWorkspaceOperation(workspaceId, op)
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('delete-user <email>')
|
||||
.description('Delete an account on user request (GDPR). Confirms by retyping the email and writes an audit entry.')
|
||||
.option('--yes <email>', 'Non-interactive confirmation; must equal <email>')
|
||||
.option('--force', 'Proceed even if the user is the sole Owner of a workspace', false)
|
||||
.option('--dry-run', 'Report what would be deleted without touching anything', false)
|
||||
.option('--reason <reason>', 'Ticket id or human reason, recorded in the audit entry')
|
||||
.action(async (email: string, cmd: { yes?: string, force: boolean, dryRun: boolean, reason?: string }) => {
|
||||
await withAccountDatabase(async (db) => {
|
||||
const result = await deleteUser({
|
||||
ctx: toolCtx,
|
||||
db,
|
||||
admin: makeAdmin(),
|
||||
prompter: createReadlinePrompter(),
|
||||
params: {
|
||||
email,
|
||||
confirm: cmd.yes,
|
||||
force: cmd.force,
|
||||
dryRun: cmd.dryRun,
|
||||
reason: cmd.reason,
|
||||
operator: resolveOperator()
|
||||
}
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
program
|
||||
.command('delete-workspace <workspace>')
|
||||
.description('Mark a workspace pending-deletion; workspace-service performs the DB drop asynchronously.')
|
||||
.option('--yes <url>', 'Non-interactive confirmation; must equal the workspace url')
|
||||
.option('--dry-run', 'Report what would happen without marking the workspace', false)
|
||||
.option('--reason <reason>', 'Ticket id or human reason, recorded in the audit entry')
|
||||
.action(async (workspace: string, cmd: { yes?: string, dryRun: boolean, reason?: string }) => {
|
||||
await withAccountDatabase(async (db) => {
|
||||
const resolver: WorkspaceResolver = { getWorkspace: async (idOrUrl) => await getWorkspace(db, idOrUrl) }
|
||||
const result = await deleteWorkspace({
|
||||
ctx: toolCtx,
|
||||
resolver,
|
||||
admin: makeAdmin(),
|
||||
prompter: createReadlinePrompter(),
|
||||
params: {
|
||||
workspace,
|
||||
confirm: cmd.yes,
|
||||
dryRun: cmd.dryRun,
|
||||
reason: cmd.reason,
|
||||
operator: resolveOperator()
|
||||
}
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
program
|
||||
.command('delete-workspace-storage <workspace>')
|
||||
.description(
|
||||
'Scan (default) or remove (--apply) all blobs/objects for a workspace. ' +
|
||||
'Run after workspace-service has dropped the DB so live transactor never reads missing blobs.'
|
||||
)
|
||||
.option('--apply', 'Actually delete; without this flag the command only reports counts.', false)
|
||||
.option('--yes <url>', 'Non-interactive confirmation; must equal the workspace url. Required with --apply.')
|
||||
.option('--batch-size <n>', 'Objects per remove() call', '500')
|
||||
.option('--reason <reason>', 'Ticket id or human reason, recorded in the audit entry')
|
||||
.action(async (workspace: string, cmd: { apply: boolean, yes?: string, batchSize: string, reason?: string }) => {
|
||||
await withAccountDatabase(async (db) => {
|
||||
await withStorage(async (storage) => {
|
||||
const resolver: WorkspaceResolver = { getWorkspace: async (idOrUrl) => await getWorkspace(db, idOrUrl) }
|
||||
const result = await deleteWorkspaceStorage({
|
||||
ctx: toolCtx,
|
||||
resolver,
|
||||
storage,
|
||||
prompter: createReadlinePrompter(),
|
||||
params: {
|
||||
workspace,
|
||||
apply: cmd.apply,
|
||||
confirm: cmd.yes,
|
||||
batchSize: Number.parseInt(cmd.batchSize, 10),
|
||||
reason: cmd.reason,
|
||||
operator: resolveOperator()
|
||||
}
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function doUpgrade (
|
||||
toolCtx: MeasureMetricsContext,
|
||||
workspace: WorkspaceUuid,
|
||||
|
||||
Reference in New Issue
Block a user