Add tools to delete user or workspace

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-05-19 14:20:02 +07:00
parent fa2930d54e
commit 44ebb22c17
3 changed files with 1049 additions and 0 deletions
+446
View File
@@ -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'])
})
})
+495
View File
@@ -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 }
}
+108
View File
@@ -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,