diff --git a/backend/src/privacy/pilot-account.service.spec.ts b/backend/src/privacy/pilot-account.service.spec.ts new file mode 100644 index 0000000..a6c9cfc --- /dev/null +++ b/backend/src/privacy/pilot-account.service.spec.ts @@ -0,0 +1,336 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { PrismaClient, StoreStatus } from '@prisma/client'; + +import { + PilotAccountService, + privacyFingerprint, +} from './pilot-account.service'; + +const tenantTarget = { + tenantId: 'ten_a', + nameSha256: privacyFingerprint('Pilot shop'), +}; +const identityTarget = { + tenantId: 'ten_a', + userId: 'usr_a', + emailSha256: privacyFingerprint('owner@example.test'), +}; +const now = new Date(); + +function fixture(shared = false) { + const update = jest.fn(async () => ({ count: 1 })); + const create = jest.fn(async () => ({ id: 'aud_fixture' })); + const tx = { + tenant: { + findUniqueOrThrow: jest.fn(async () => ({ + name: 'Pilot shop', + deletedAt: null as Date | null, + })), + updateMany: update, + }, + store: { + findMany: jest.fn(async () => [ + { + id: 'sto_a', + baseUrl: 'https://erased.invalid/sto_a', + status: StoreStatus.DISCONNECTED, + deletedAt: now, + }, + ]), + }, + membership: { + findMany: jest.fn(async () => [{ userId: 'usr_a' }]), + findFirst: jest.fn(async () => ({ + id: 'mem_a', + deletedAt: null as Date | null, + user: { email: 'owner@example.test' }, + })), + count: jest.fn(async () => (shared ? 1 : 0)), + updateMany: update, + }, + user: { updateMany: jest.fn(async () => ({ count: 1 })) }, + telegramAccount: { + findUnique: jest.fn(async () => ({ + id: 'tga_a', + deletedAt: null as Date | null, + })), + updateMany: jest.fn(async () => ({ count: 1 })), + }, + telegramChatAuthorization: { + updateMany: update, + findMany: jest.fn(async () => [{ id: 'chat_a' }]), + update: jest.fn(async () => ({ id: 'chat_a' })), + }, + telegramLinkToken: { deleteMany: jest.fn(async () => ({ count: 1 })) }, + storeNotificationRecipient: { deleteMany: update }, + telegramSettingsReference: { updateMany: update }, + telegramCallbackReference: { updateMany: update }, + telegramInventoryReference: { updateMany: update }, + telegramSearchReference: { updateMany: update }, + telegramOrderNoteAction: { updateMany: update }, + telegramOrderStatusWrite: { updateMany: update }, + telegramOrderNotificationDelivery: { updateMany: update }, + telegramInventoryNotificationDelivery: { updateMany: update }, + auditLog: { create, findFirst: jest.fn(async () => null) }, + }; + const database = { + tenant: { + findMany: jest.fn(async () => [ + { + id: 'ten_a', + name: 'Pilot shop', + deletedAt: null as Date | null, + }, + ]), + findUniqueOrThrow: jest.fn(async () => ({ + id: 'ten_a', + name: 'Pilot shop', + })), + }, + membership: { + findMany: jest.fn(async () => [ + { + id: 'mem_a', + deletedAt: null as Date | null, + tenant: { deletedAt: null }, + user: { email: 'owner@example.test' }, + }, + ]), + count: jest.fn(async () => (shared ? 1 : 0)), + findFirstOrThrow: jest.fn(async () => ({ + id: 'mem_a', + tenantId: 'ten_a', + user: { id: 'usr_a', email: 'owner@example.test' }, + })), + }, + store: { findMany: tx.store.findMany }, + auditLog: { findMany: jest.fn(async () => []) }, + $transaction: jest.fn( + async (callback: (client: typeof tx) => Promise) => callback(tx) + ), + }; + return { + service: new PilotAccountService(database as unknown as PrismaClient), + database, + tx, + }; +} + +describe('operator Tenant/User identity lifecycle', () => { + it('fails closed on an inexact Tenant or User fingerprint', async () => { + const test = fixture(); + await expect( + test.service.inspectTenant({ + ...tenantTarget, + nameSha256: '0'.repeat(64), + }) + ).rejects.toThrow('identity'); + await expect( + test.service.inspectIdentity({ + ...identityTarget, + emailSha256: '0'.repeat(64), + }) + ).rejects.toThrow('identity'); + expect(test.database.$transaction).not.toHaveBeenCalled(); + }); + + it('refuses Tenant erasure until every linked Store has been erased', async () => { + const test = fixture(); + test.tx.store.findMany.mockResolvedValueOnce([ + { + id: 'sto_a', + baseUrl: 'https://shop.example.test', + status: StoreStatus.ACTIVE, + deletedAt: null, + }, + ] as never); + await expect(test.service.eraseTenant(tenantTarget)).rejects.toThrow( + 'linked Store' + ); + expect(test.tx.tenant.updateMany).not.toHaveBeenCalled(); + }); + + it('retains a shared User/Telegram identity while removing only target-Tenant access', async () => { + const test = fixture(true); + await expect(test.service.eraseIdentity(identityTarget)).resolves.toEqual({ + userId: 'usr_a', + shared: true, + }); + expect(test.tx.membership.count).toHaveBeenCalledWith({ + where: { userId: 'usr_a', tenantId: { not: 'ten_a' } }, + }); + expect(test.tx.membership.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + tenantId: 'ten_a', + userId: 'usr_a', + }), + }) + ); + expect( + test.tx.telegramOrderNotificationDelivery.updateMany + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + tenantId: 'ten_a', + telegramAccountId: 'tga_a', + }), + }) + ); + expect(test.tx.user.updateMany).not.toHaveBeenCalled(); + expect(test.tx.telegramAccount.updateMany).not.toHaveBeenCalled(); + expect(test.tx.telegramLinkToken.deleteMany).not.toHaveBeenCalled(); + }); + + it('anonymizes an exclusive User and Telegram identity without breaking FKs', async () => { + const test = fixture(); + await test.service.eraseIdentity(identityTarget); + expect(test.tx.user.updateMany).toHaveBeenCalledWith({ + where: { id: 'usr_a', email: 'owner@example.test' }, + data: { + email: 'erased-usr_a@invalid.invalid', + displayName: null, + passwordHash: null, + }, + }); + expect(test.tx.telegramAccount.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'tga_a', userId: 'usr_a' }, + data: expect.objectContaining({ deletedAt: expect.any(Date) }), + }) + ); + expect(test.tx.telegramChatAuthorization.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'chat_a' }, + data: expect.objectContaining({ + activeTenantId: null, + activeStoreId: null, + }), + }) + ); + expect(test.tx.telegramLinkToken.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'usr_a' }, + }); + }); + + it('anonymizes an already unlinked Telegram identity for an exclusive User', async () => { + const test = fixture(); + test.tx.telegramAccount.findUnique.mockResolvedValueOnce({ + id: 'tga_a', + deletedAt: now, + }); + await test.service.eraseIdentity(identityTarget); + expect(test.tx.telegramAccount.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'tga_a', userId: 'usr_a' }, + data: expect.objectContaining({ + telegramUserId: expect.anything(), + deletedAt: expect.any(Date), + }), + }) + ); + }); + + it('erases a prepared Tenant after linked Store erasure and records bounded exceptions', async () => { + const test = fixture(); + await expect(test.service.eraseTenant(tenantTarget)).resolves.toEqual({ + users: 1, + sharedUsersRetained: 0, + }); + expect(test.tx.tenant.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'ten_a', name: 'Pilot shop', deletedAt: null }, + data: expect.objectContaining({ name: 'Erased tenant ten_a' }), + }) + ); + expect(test.tx.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + tenantId: 'ten_a', + action: 'privacy.tenant_erased', + }), + }) + ); + }); + + it('erases an exact soft-deleted membership without reactivating it', async () => { + const test = fixture(); + test.database.membership.findMany.mockResolvedValueOnce([ + { + id: 'mem_a', + deletedAt: now, + tenant: { deletedAt: now }, + user: { email: 'owner@example.test' }, + }, + ] as never); + test.tx.membership.findFirst.mockResolvedValueOnce({ + id: 'mem_a', + deletedAt: now, + user: { email: 'owner@example.test' }, + }); + await expect(test.service.eraseIdentity(identityTarget)).resolves.toEqual({ + userId: 'usr_a', + shared: false, + }); + expect(test.tx.membership.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'mem_a', + tenantId: 'ten_a', + userId: 'usr_a', + deletedAt: now, + }, + data: { deletedAt: expect.any(Date) }, + }) + ); + expect(test.tx.user.updateMany).toHaveBeenCalled(); + }); + + it('cascades through removed members of an exact soft-deleted Tenant', async () => { + const test = fixture(); + test.database.tenant.findMany.mockResolvedValueOnce([ + { id: 'ten_a', name: 'Pilot shop', deletedAt: now }, + ]); + test.tx.tenant.findUniqueOrThrow.mockResolvedValueOnce({ + name: 'Pilot shop', + deletedAt: now, + }); + // Completion inspection and erasure both see the removed membership. + test.tx.membership.findFirst.mockResolvedValue({ + id: 'mem_a', + deletedAt: now, + user: { email: 'owner@example.test' }, + }); + await expect(test.service.eraseTenant(tenantTarget)).resolves.toEqual({ + users: 1, + sharedUsersRetained: 0, + }); + expect(test.tx.membership.findMany).toHaveBeenCalledWith({ + where: { tenantId: 'ten_a' }, + select: { userId: true }, + }); + expect(test.tx.user.updateMany).toHaveBeenCalled(); + expect(test.tx.tenant.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'ten_a', name: 'Pilot shop', deletedAt: now }, + }) + ); + }); + + it('exports only the targeted Tenant or User audit scope', async () => { + const test = fixture(); + await test.service.exportTenant(tenantTarget); + expect(test.database.auditLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { tenantId: 'ten_a' } }) + ); + await test.service.exportIdentity(identityTarget); + expect(test.database.auditLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + tenantId: 'ten_a', + userId: 'usr_a', + }, + }) + ); + }); +}); diff --git a/backend/src/privacy/pilot-account.service.ts b/backend/src/privacy/pilot-account.service.ts new file mode 100644 index 0000000..0066d48 --- /dev/null +++ b/backend/src/privacy/pilot-account.service.ts @@ -0,0 +1,810 @@ +import { + Prisma, + PrismaClient, + StoreStatus, + TelegramInventoryNotificationState, + TelegramOrderNotificationState, + TenantEntitlementStatus, +} from '@prisma/client'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +export interface TenantPrivacyTarget { + tenantId: string; + nameSha256: string; +} + +export interface IdentityPrivacyTarget { + tenantId: string; + userId: string; + emailSha256: string; +} + +export function privacyFingerprint(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function anonymousTelegramId(): bigint { + return -(randomBytes(8).readBigUInt64BE() & ((1n << 62n) - 1n)) - 1n; +} + +export class PilotAccountService { + constructor(private readonly database: PrismaClient) {} + + async resolveTenant(target: TenantPrivacyTarget) { + const tenants = await this.database.tenant.findMany({ + where: { id: target.tenantId }, + select: { id: true, name: true, deletedAt: true }, + take: 2, + }); + if ( + tenants.length !== 1 || + privacyFingerprint(tenants[0]!.name) !== target.nameSha256 + ) { + throw new Error( + 'privacy Tenant identity is missing, erased or ambiguous' + ); + } + return tenants[0]!; + } + + async resolveIdentity(target: IdentityPrivacyTarget) { + const memberships = await this.database.membership.findMany({ + where: { tenantId: target.tenantId, userId: target.userId }, + select: { + id: true, + deletedAt: true, + user: { select: { email: true } }, + }, + take: 2, + }); + if ( + memberships.length !== 1 || + privacyFingerprint(memberships[0]!.user.email) !== target.emailSha256 + ) { + throw new Error('privacy User identity is missing, erased or ambiguous'); + } + return memberships[0]!; + } + + async inspectTenant(target: TenantPrivacyTarget) { + await this.resolveTenant(target); + const [stores, memberships] = await Promise.all([ + this.database.store.findMany({ + where: { tenantId: target.tenantId }, + select: { id: true, status: true, deletedAt: true, baseUrl: true }, + orderBy: { id: 'asc' }, + }), + this.database.membership.findMany({ + where: { tenantId: target.tenantId }, + select: { userId: true, deletedAt: true }, + orderBy: { id: 'asc' }, + }), + ]); + return { + tenantId: target.tenantId, + stores: stores.map((store) => ({ + id: store.id, + erased: isErasedStore(store), + })), + users: await Promise.all( + memberships.map(async (membership) => ({ + id: membership.userId, + membershipErased: await this.identityErasureComplete({ + tenantId: target.tenantId, + userId: membership.userId, + }), + shared: + (await this.otherMemberships( + this.database, + target.tenantId, + membership.userId + )) > 0, + })) + ), + retained: [ + 'referential identifiers', + 'security audit until retention', + 'historical backups', + ], + }; + } + + async exportTenant(target: TenantPrivacyTarget) { + await this.resolveTenant(target); + const [tenant, stores, memberships, audit] = await Promise.all([ + this.database.tenant.findUniqueOrThrow({ + where: { id: target.tenantId }, + select: { + id: true, + name: true, + plan: true, + timezone: true, + language: true, + createdAt: true, + }, + }), + this.database.store.findMany({ + where: { tenantId: target.tenantId }, + select: { id: true, name: true, baseUrl: true, status: true }, + orderBy: { id: 'asc' }, + }), + this.database.membership.findMany({ + where: { tenantId: target.tenantId }, + select: { + id: true, + role: true, + deletedAt: true, + user: { + select: { + id: true, + email: true, + displayName: true, + telegramAccount: { + select: { id: true, telegramUserId: true, deletedAt: true }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }), + this.database.auditLog.findMany({ + where: { tenantId: target.tenantId }, + select: { + id: true, + userId: true, + action: true, + entityType: true, + entityId: true, + metadata: true, + createdAt: true, + }, + orderBy: { id: 'asc' }, + }), + ]); + return { + exportedAt: new Date().toISOString(), + tenant, + stores, + memberships, + audit, + }; + } + + async inspectIdentity(target: IdentityPrivacyTarget) { + await this.resolveIdentity(target); + return { + tenantId: target.tenantId, + userId: target.userId, + shared: + (await this.otherMemberships( + this.database, + target.tenantId, + target.userId + )) > 0, + retained: [ + 'referential identifiers', + 'security audit until retention', + 'historical backups', + ], + }; + } + + async exportIdentity(target: IdentityPrivacyTarget) { + await this.resolveIdentity(target); + const [membership, audit] = await Promise.all([ + this.database.membership.findFirstOrThrow({ + where: { tenantId: target.tenantId, userId: target.userId }, + select: { + id: true, + tenantId: true, + role: true, + createdAt: true, + user: { + select: { + id: true, + email: true, + displayName: true, + telegramAccount: { + select: { + id: true, + telegramUserId: true, + deletedAt: true, + chatAuthorizations: { + where: { activeTenantId: target.tenantId }, + select: { telegramChatId: true, activeStoreId: true }, + }, + }, + }, + }, + }, + }, + }), + this.database.auditLog.findMany({ + where: { tenantId: target.tenantId, userId: target.userId }, + select: { + id: true, + action: true, + entityType: true, + entityId: true, + metadata: true, + createdAt: true, + }, + orderBy: { id: 'asc' }, + }), + ]); + return { exportedAt: new Date().toISOString(), membership, audit }; + } + + async eraseIdentity(target: IdentityPrivacyTarget) { + await this.resolveIdentity(target); + return this.database.$transaction( + async (tx) => + this.eraseIdentityInTransaction( + tx, + target.tenantId, + target.userId, + new Date(), + target.emailSha256 + ), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); + } + + async eraseTenant(target: TenantPrivacyTarget) { + await this.resolveTenant(target); + return this.database.$transaction( + async (tx) => { + const tenant = await tx.tenant.findUniqueOrThrow({ + where: { id: target.tenantId }, + select: { name: true, deletedAt: true }, + }); + if (privacyFingerprint(tenant.name) !== target.nameSha256) + throw new Error('privacy Tenant identity changed before erasure'); + const stores = await tx.store.findMany({ + where: { tenantId: target.tenantId }, + select: { id: true, baseUrl: true, status: true, deletedAt: true }, + }); + if (stores.some((store) => !isErasedStore(store))) { + throw new Error( + 'erase each linked Store and archive its ledger entry first' + ); + } + const members = await tx.membership.findMany({ + where: { tenantId: target.tenantId }, + select: { userId: true }, + }); + const now = new Date(); + const identities = []; + for (const member of members) { + if ( + await this.identityErasureComplete( + { tenantId: target.tenantId, userId: member.userId }, + tx + ) + ) + continue; + identities.push( + await this.eraseIdentityInTransaction( + tx, + target.tenantId, + member.userId, + now + ) + ); + } + await tx.telegramChatAuthorization.updateMany({ + where: { activeTenantId: target.tenantId }, + data: { activeTenantId: null, activeStoreId: null, revokedAt: now }, + }); + const changed = await tx.tenant.updateMany({ + where: { + id: target.tenantId, + name: tenant.name, + deletedAt: tenant.deletedAt, + }, + data: { + name: `Erased tenant ${target.tenantId}`, + deletedAt: now, + entitlementStatus: TenantEntitlementStatus.SUSPENDED, + }, + }); + if (changed.count !== 1) + throw new Error('privacy Tenant changed during erasure'); + await tx.auditLog.create({ + data: { + id: `aud_${randomUUID()}`, + tenantId: target.tenantId, + userId: null, + action: 'privacy.tenant_erased', + entityType: 'Tenant', + entityId: target.tenantId, + metadata: { + erasedAt: now.toISOString(), + nameSha256: target.nameSha256, + retained: 'security-audit-and-referential-ids', + }, + }, + }); + return { + users: identities.length, + sharedUsersRetained: identities.filter((entry) => entry.shared) + .length, + }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); + } + + // Soft deletion revokes access; only a matching privacy transaction proves erasure. + // If retention has removed the audit proof, replay must verify the original + // fingerprint and erase again, or fail closed on an anonymized mismatch. + async identityErasureComplete( + target: { tenantId: string; userId: string; emailSha256?: string }, + tx: Prisma.TransactionClient | PrismaClient = this.database + ): Promise { + const membership = await tx.membership.findFirst({ + where: { tenantId: target.tenantId, userId: target.userId }, + select: { + id: true, + deletedAt: true, + user: { + select: { + email: true, + displayName: true, + passwordHash: true, + telegramAccount: { + select: { + id: true, + telegramUserId: true, + deletedAt: true, + lastRedeemUpdateId: true, + lastUnlinkUpdateId: true, + chatAuthorizations: { + select: { + telegramChatId: true, + activeTenantId: true, + activeStoreId: true, + revokedAt: true, + }, + }, + }, + }, + }, + }, + }, + }); + if (!membership?.deletedAt) return false; + const shared = + (await this.otherMemberships(tx, target.tenantId, target.userId)) > 0; + const user = membership.user; + const account = user.telegramAccount; + if (shared) { + if ( + target.emailSha256 && + privacyFingerprint(user.email) !== target.emailSha256 + ) + return false; + if ( + account?.chatAuthorizations.some( + (chat) => chat.activeTenantId === target.tenantId + ) + ) + return false; + } else { + if ( + user.email !== `erased-${target.userId}@invalid.invalid` || + user.displayName !== null || + user.passwordHash !== null + ) + return false; + if ( + account && + (!account.deletedAt || + account.telegramUserId >= 0n || + account.lastRedeemUpdateId !== null || + account.lastUnlinkUpdateId !== null || + account.chatAuthorizations.some( + (chat) => + chat.telegramChatId >= 0n || + chat.activeTenantId !== null || + chat.activeStoreId !== null || + !chat.revokedAt + )) + ) + return false; + if ( + await tx.telegramLinkToken.count({ where: { userId: target.userId } }) + ) + return false; + } + const audit = await tx.auditLog.findFirst({ + where: { + tenantId: target.tenantId, + userId: target.userId, + action: 'privacy.identity_erased', + entityType: 'User', + entityId: target.userId, + AND: [ + { + metadata: { + path: ['erasedAt'], + equals: membership.deletedAt.toISOString(), + }, + }, + { metadata: { path: ['sharedIdentityRetained'], equals: shared } }, + ...(target.emailSha256 + ? [ + { + metadata: { + path: ['emailSha256'], + equals: target.emailSha256, + }, + }, + ] + : []), + ], + }, + select: { id: true }, + }); + if (!audit) return false; + const remaining = [ + await tx.storeNotificationRecipient.count({ + where: { tenantId: target.tenantId, membershipId: membership.id }, + }), + await tx.telegramSettingsReference.count({ + where: { + tenantId: target.tenantId, + targetMembershipId: membership.id, + expiresAt: { gt: membership.deletedAt }, + }, + }), + ]; + if (account) { + const scoped = { + tenantId: target.tenantId, + telegramAccountId: account.id, + }; + const referencePii = [ + { telegramChatId: { gte: 0n } }, + { expiresAt: { gt: membership.deletedAt } }, + ]; + remaining.push( + ...(await Promise.all([ + tx.telegramSettingsReference.count({ + where: { ...scoped, OR: referencePii }, + }), + tx.telegramCallbackReference.count({ + where: { + ...scoped, + OR: [ + ...referencePii, + { noteBodyEncrypted: { not: null } }, + { noteContentFingerprint: { not: null } }, + ], + }, + }), + tx.telegramInventoryReference.count({ + where: { ...scoped, OR: referencePii }, + }), + tx.telegramSearchReference.count({ + where: { + ...scoped, + OR: [...referencePii, { queryEncrypted: { not: null } }], + }, + }), + tx.telegramOrderNoteAction.count({ + where: { + ...scoped, + OR: [ + { contentFingerprint: { not: '0'.repeat(64) } }, + { result: { not: Prisma.AnyNull } }, + ], + }, + }), + tx.telegramOrderStatusWrite.count({ + where: { ...scoped, result: { not: Prisma.AnyNull } }, + }), + tx.telegramOrderNotificationDelivery.count({ + where: { + ...scoped, + OR: [ + { telegramMessageId: { not: null } }, + { + state: { + in: [ + TelegramOrderNotificationState.PENDING, + TelegramOrderNotificationState.IN_FLIGHT, + TelegramOrderNotificationState.RETRYABLE_FAILURE, + ], + }, + }, + ], + }, + }), + tx.telegramInventoryNotificationDelivery.count({ + where: { + ...scoped, + OR: [ + { telegramMessageId: { not: null } }, + { + state: { + in: [ + TelegramInventoryNotificationState.PENDING, + TelegramInventoryNotificationState.IN_FLIGHT, + TelegramInventoryNotificationState.RETRYABLE_FAILURE, + ], + }, + }, + ], + }, + }), + ])) + ); + } + return remaining.every((count) => count === 0); + } + + async tenantErasureComplete(target: TenantPrivacyTarget): Promise { + const tenant = await this.database.tenant.findUnique({ + where: { id: target.tenantId }, + select: { name: true, deletedAt: true, entitlementStatus: true }, + }); + if ( + !tenant?.deletedAt || + tenant.name !== `Erased tenant ${target.tenantId}` || + tenant.entitlementStatus !== TenantEntitlementStatus.SUSPENDED + ) + return false; + const audit = await this.database.auditLog.findFirst({ + where: { + tenantId: target.tenantId, + userId: null, + action: 'privacy.tenant_erased', + entityType: 'Tenant', + entityId: target.tenantId, + AND: [ + { + metadata: { + path: ['erasedAt'], + equals: tenant.deletedAt.toISOString(), + }, + }, + { metadata: { path: ['nameSha256'], equals: target.nameSha256 } }, + ], + }, + select: { id: true }, + }); + if (!audit) return false; + const stores = await this.database.store.findMany({ + where: { tenantId: target.tenantId }, + select: { id: true, baseUrl: true, status: true, deletedAt: true }, + }); + if (stores.some((store) => !isErasedStore(store))) return false; + const members = await this.database.membership.findMany({ + where: { tenantId: target.tenantId }, + select: { userId: true }, + }); + for (const member of members) { + if ( + !(await this.identityErasureComplete({ + tenantId: target.tenantId, + userId: member.userId, + })) + ) + return false; + } + return ( + (await this.database.telegramChatAuthorization.count({ + where: { activeTenantId: target.tenantId }, + })) === 0 + ); + } + + private async otherMemberships( + tx: Prisma.TransactionClient | PrismaClient, + tenantId: string, + userId: string + ) { + return tx.membership.count({ + where: { + userId, + tenantId: { not: tenantId }, + }, + }); + } + + private async eraseIdentityInTransaction( + tx: Prisma.TransactionClient, + tenantId: string, + userId: string, + now: Date, + emailSha256?: string + ) { + const membership = await tx.membership.findFirst({ + where: { tenantId, userId }, + select: { id: true, deletedAt: true, user: { select: { email: true } } }, + }); + if ( + !membership || + (emailSha256 && privacyFingerprint(membership.user.email) !== emailSha256) + ) + throw new Error('privacy User identity changed before erasure'); + const shared = (await this.otherMemberships(tx, tenantId, userId)) > 0; + const account = await tx.telegramAccount.findUnique({ + where: { userId }, + select: { id: true, deletedAt: true }, + }); + await tx.storeNotificationRecipient.deleteMany({ + where: { tenantId, membershipId: membership.id }, + }); + await tx.telegramSettingsReference.updateMany({ + where: { tenantId, targetMembershipId: membership.id }, + data: { expiresAt: now }, + }); + if (account) { + const scoped = { tenantId, telegramAccountId: account.id }; + await tx.telegramSettingsReference.updateMany({ + where: scoped, + data: { expiresAt: now, telegramChatId: anonymousTelegramId() }, + }); + await tx.telegramCallbackReference.updateMany({ + where: scoped, + data: { + expiresAt: now, + telegramChatId: anonymousTelegramId(), + noteBodyEncrypted: null, + noteContentFingerprint: null, + }, + }); + await tx.telegramInventoryReference.updateMany({ + where: scoped, + data: { expiresAt: now, telegramChatId: anonymousTelegramId() }, + }); + await tx.telegramSearchReference.updateMany({ + where: scoped, + data: { + expiresAt: now, + telegramChatId: anonymousTelegramId(), + queryEncrypted: null, + }, + }); + await tx.telegramOrderNoteAction.updateMany({ + where: scoped, + data: { contentFingerprint: '0'.repeat(64), result: Prisma.DbNull }, + }); + await tx.telegramOrderStatusWrite.updateMany({ + where: scoped, + data: { result: Prisma.DbNull }, + }); + // Both delivery tables preserve FK and state evidence while removing message IDs. + await tx.telegramOrderNotificationDelivery.updateMany({ + where: scoped, + data: { telegramMessageId: null }, + }); + await tx.telegramInventoryNotificationDelivery.updateMany({ + where: scoped, + data: { telegramMessageId: null }, + }); + await tx.telegramOrderNotificationDelivery.updateMany({ + where: { + ...scoped, + state: { + in: [ + TelegramOrderNotificationState.PENDING, + TelegramOrderNotificationState.IN_FLIGHT, + TelegramOrderNotificationState.RETRYABLE_FAILURE, + ], + }, + }, + data: { + state: TelegramOrderNotificationState.TERMINAL_FAILURE, + failureCategory: 'privacy', + failureCode: 'identity-erased', + }, + }); + await tx.telegramInventoryNotificationDelivery.updateMany({ + where: { + ...scoped, + state: { + in: [ + TelegramInventoryNotificationState.PENDING, + TelegramInventoryNotificationState.IN_FLIGHT, + TelegramInventoryNotificationState.RETRYABLE_FAILURE, + ], + }, + }, + data: { + state: TelegramInventoryNotificationState.TERMINAL_FAILURE, + failureCategory: 'privacy', + failureCode: 'identity-erased', + }, + }); + await tx.telegramChatAuthorization.updateMany({ + where: { telegramAccountId: account.id, activeTenantId: tenantId }, + data: { activeTenantId: null, activeStoreId: null, revokedAt: now }, + }); + } + const changed = await tx.membership.updateMany({ + where: { + id: membership.id, + tenantId, + userId, + deletedAt: membership.deletedAt, + }, + data: { deletedAt: now }, + }); + if (changed.count !== 1) + throw new Error('privacy Membership changed during erasure'); + if (!shared) { + await tx.telegramLinkToken.deleteMany({ where: { userId } }); + const userChanged = await tx.user.updateMany({ + where: { id: userId, email: membership.user.email }, + data: { + email: `erased-${userId}@invalid.invalid`, + displayName: null, + passwordHash: null, + }, + }); + if (userChanged.count !== 1) + throw new Error('privacy User changed during erasure'); + if (account) { + const chats = await tx.telegramChatAuthorization.findMany({ + where: { telegramAccountId: account.id }, + select: { id: true }, + }); + for (const chat of chats) { + await tx.telegramChatAuthorization.update({ + where: { id: chat.id }, + data: { + telegramChatId: anonymousTelegramId(), + activeTenantId: null, + activeStoreId: null, + revokedAt: now, + }, + }); + } + const accountChanged = await tx.telegramAccount.updateMany({ + where: { id: account.id, userId }, + data: { + telegramUserId: anonymousTelegramId(), + lastRedeemUpdateId: null, + lastUnlinkUpdateId: null, + deletedAt: now, + }, + }); + if (accountChanged.count !== 1) + throw new Error('privacy Telegram identity changed during erasure'); + } + } + await tx.auditLog.create({ + data: { + id: `aud_${randomUUID()}`, + tenantId, + userId, + action: 'privacy.identity_erased', + entityType: 'User', + entityId: userId, + metadata: { + erasedAt: now.toISOString(), + emailSha256: privacyFingerprint(membership.user.email), + sharedIdentityRetained: shared, + retained: 'security-audit-and-referential-ids', + }, + }, + }); + return { userId, shared }; + } +} + +function isErasedStore(store: { + id: string; + baseUrl: string; + status: StoreStatus; + deletedAt: Date | null; +}): boolean { + return ( + store.status === StoreStatus.DISCONNECTED && + store.deletedAt !== null && + store.baseUrl === `https://erased.invalid/${store.id}` + ); +} diff --git a/backend/src/privacy/pilot-data.cli.spec.ts b/backend/src/privacy/pilot-data.cli.spec.ts index 65b2457..16f6cd5 100644 --- a/backend/src/privacy/pilot-data.cli.spec.ts +++ b/backend/src/privacy/pilot-data.cli.spec.ts @@ -6,11 +6,18 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { + accountLedgerContains, + appendAccountLedger, appendLedger, replayLedger, + requireStoreLedgerEntries, verifyExportOutput, } from './pilot-data.cli'; import type { PilotDataService } from './pilot-data.service'; +import { + PilotAccountService, + privacyFingerprint, +} from './pilot-account.service'; const target = { tenantId: 'ten_a', @@ -91,6 +98,250 @@ describe('external erasure ledger replay', () => { ); expect(service.disconnect).not.toHaveBeenCalled(); }); + + it('replays exact User and Tenant entries after restore without storing names or email', async () => { + const ledger = await fixture(); + const user = { + tenantId: 'ten_a', + userId: 'usr_a', + emailSha256: privacyFingerprint('owner@example.test'), + }; + const tenant = { + tenantId: 'ten_a', + nameSha256: privacyFingerprint('Pilot shop'), + }; + await appendAccountLedger(ledger, user); + await appendAccountLedger(ledger, tenant); + expect(await accountLedgerContains(ledger, user)).toBe(true); + expect(await accountLedgerContains(ledger, tenant)).toBe(true); + const content = await readFile(ledger, 'utf8'); + expect(content).not.toContain('owner@example.test'); + expect(content).not.toContain('Pilot shop'); + const database = { + membership: { + findFirst: jest.fn(async () => ({ + deletedAt: null, + user: { email: 'owner@example.test' }, + })), + }, + tenant: { + findFirst: jest.fn(async () => ({ + name: 'Pilot shop', + deletedAt: null, + })), + }, + } as unknown as PrismaClient; + const accounts = { + identityErasureComplete: jest.fn(async () => false), + tenantErasureComplete: jest.fn(async () => false), + eraseIdentity: jest.fn(async () => ({ userId: 'usr_a', shared: false })), + eraseTenant: jest.fn(async () => ({ users: 1, sharedUsersRetained: 0 })), + } as unknown as PilotAccountService; + const stores = {} as PilotDataService; + await expect( + replayLedger(ledger, stores, database, false, accounts) + ).resolves.toMatchObject({ pending: 2, executed: false }); + expect(accounts.eraseIdentity).not.toHaveBeenCalled(); + await expect( + replayLedger(ledger, stores, database, true, accounts) + ).resolves.toMatchObject({ pending: 2, executed: true }); + expect(accounts.eraseIdentity).toHaveBeenCalledWith(user); + expect(accounts.eraseTenant).toHaveBeenCalledWith(tenant); + }); + + it('replays soft-deleted originals and skips only verified privacy completion', async () => { + const ledger = await fixture(); + const user = { + tenantId: 'ten_a', + userId: 'usr_a', + emailSha256: privacyFingerprint('owner@example.test'), + }; + const tenant = { + tenantId: 'ten_a', + nameSha256: privacyFingerprint('Pilot shop'), + }; + await appendAccountLedger(ledger, user); + await appendAccountLedger(ledger, tenant); + const database = { + membership: { + findFirst: jest.fn(async () => ({ + deletedAt: new Date(), + user: { email: 'owner@example.test' }, + })), + }, + tenant: { + findFirst: jest.fn(async () => ({ + deletedAt: new Date(), + name: 'Pilot shop', + })), + }, + } as unknown as PrismaClient; + const accounts = { + identityErasureComplete: jest.fn(async () => false), + tenantErasureComplete: jest.fn(async () => false), + eraseIdentity: jest.fn(async () => undefined), + eraseTenant: jest.fn(async () => undefined), + }; + await expect( + replayLedger( + ledger, + {} as PilotDataService, + database, + true, + accounts as unknown as PilotAccountService + ) + ).resolves.toMatchObject({ pending: 2 }); + expect(accounts.eraseIdentity).toHaveBeenCalledWith(user); + expect(accounts.eraseTenant).toHaveBeenCalledWith(tenant); + accounts.identityErasureComplete.mockResolvedValue(true); + accounts.tenantErasureComplete.mockResolvedValue(true); + await expect( + replayLedger( + ledger, + {} as PilotDataService, + database, + true, + accounts as unknown as PilotAccountService + ) + ).resolves.toMatchObject({ pending: 0 }); + expect(accounts.eraseIdentity).toHaveBeenCalledTimes(1); + expect(accounts.eraseTenant).toHaveBeenCalledTimes(1); + }); + + it('fails closed on anonymized state without completion proof', async () => { + const ledger = await fixture(); + await appendAccountLedger(ledger, { + tenantId: 'ten_a', + userId: 'usr_a', + emailSha256: privacyFingerprint('owner@example.test'), + }); + const database = { + membership: { + findFirst: jest.fn(async () => ({ + deletedAt: new Date(), + user: { email: 'erased-usr_a@invalid.invalid' }, + })), + }, + } as unknown as PrismaClient; + const accounts = { + identityErasureComplete: jest.fn(async () => false), + eraseIdentity: jest.fn(), + } as unknown as PilotAccountService; + await expect( + replayLedger(ledger, {} as PilotDataService, database, true, accounts) + ).rejects.toThrow('does not match'); + expect(accounts.eraseIdentity).not.toHaveBeenCalled(); + }); + + it('fails closed on a surviving User when the backup predates Membership', async () => { + const ledger = await fixture(); + await appendAccountLedger(ledger, { + tenantId: 'ten_a', + userId: 'usr_a', + emailSha256: privacyFingerprint('owner@example.test'), + }); + const database = { + membership: { findFirst: jest.fn(async () => null) }, + user: { findUnique: jest.fn(async () => ({ id: 'usr_a' })) }, + }; + const accounts = { + eraseIdentity: jest.fn(), + identityErasureComplete: jest.fn(), + } as unknown as PilotAccountService; + await expect( + replayLedger( + ledger, + {} as PilotDataService, + database as unknown as PrismaClient, + true, + accounts + ) + ).rejects.toThrow('without restored Membership'); + expect(database.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'usr_a' }, + select: { id: true }, + }); + expect(accounts.eraseIdentity).not.toHaveBeenCalled(); + expect(accounts.identityErasureComplete).not.toHaveBeenCalled(); + }); + + it('safely skips an absent Membership only when the exact User is also absent', async () => { + const ledger = await fixture(); + await appendAccountLedger(ledger, { + tenantId: 'ten_a', + userId: 'usr_a', + emailSha256: privacyFingerprint('owner@example.test'), + }); + const database = { + membership: { findFirst: jest.fn(async () => null) }, + user: { findUnique: jest.fn(async () => null) }, + } as unknown as PrismaClient; + const accounts = { + eraseIdentity: jest.fn(), + } as unknown as PilotAccountService; + await expect( + replayLedger(ledger, {} as PilotDataService, database, true, accounts) + ).resolves.toMatchObject({ pending: 0 }); + expect(accounts.eraseIdentity).not.toHaveBeenCalled(); + }); + + it('fails closed when a Tenant-only ledger cannot identify pre-Tenant restored accounts', async () => { + const ledger = await fixture(); + await appendAccountLedger(ledger, { + tenantId: 'ten_a', + nameSha256: privacyFingerprint('Pilot shop'), + }); + const database = { + tenant: { findFirst: jest.fn(async () => null) }, + } as unknown as PrismaClient; + const accounts = { + eraseTenant: jest.fn(), + } as unknown as PilotAccountService; + await expect( + replayLedger(ledger, {} as PilotDataService, database, true, accounts) + ).rejects.toThrow('completion cannot be verified'); + expect(accounts.eraseTenant).not.toHaveBeenCalled(); + }); + + it('requires a matching Store ledger before whole-Tenant erasure', async () => { + const ledger = await fixture(); + await expect( + requireStoreLedgerEntries(ledger, 'ten_a', ['sto_a']) + ).rejects.toThrow('linked Store'); + await appendLedger(ledger, target); + await expect( + requireStoreLedgerEntries(ledger, 'ten_a', ['sto_a']) + ).resolves.toBeUndefined(); + await expect( + requireStoreLedgerEntries(ledger, 'ten_b', ['sto_a']) + ).rejects.toThrow('linked Store'); + }); + + it('rejects a restored User identity mismatch before execution', async () => { + const ledger = await fixture(); + await appendAccountLedger(ledger, { + tenantId: 'ten_a', + userId: 'usr_a', + emailSha256: privacyFingerprint('owner@example.test'), + }); + const database = { + membership: { + findFirst: jest.fn(async () => ({ + deletedAt: null, + user: { email: 'different@example.test' }, + })), + }, + } as unknown as PrismaClient; + const accounts = { + identityErasureComplete: jest.fn(async () => false), + tenantErasureComplete: jest.fn(async () => false), + eraseIdentity: jest.fn(), + } as unknown as PilotAccountService; + await expect( + replayLedger(ledger, {} as PilotDataService, database, true, accounts) + ).rejects.toThrow('does not match'); + expect(accounts.eraseIdentity).not.toHaveBeenCalled(); + }); }); describe('protected privacy export path', () => { diff --git a/backend/src/privacy/pilot-data.cli.ts b/backend/src/privacy/pilot-data.cli.ts index 901e867..84350d4 100644 --- a/backend/src/privacy/pilot-data.cli.ts +++ b/backend/src/privacy/pilot-data.cli.ts @@ -7,6 +7,12 @@ import { basename, dirname, isAbsolute, resolve } from 'node:path'; import { EncryptionService } from '../common/encryption/encryption.service'; import type { ApplicationConfigService } from '../config/application-config.service'; import { PilotDataService, type PilotTarget } from './pilot-data.service'; +import { + PilotAccountService, + privacyFingerprint, + type IdentityPrivacyTarget, + type TenantPrivacyTarget, +} from './pilot-account.service'; import { removeOwnedWooWebhooks } from './woo-webhook-cleanup'; type Mode = @@ -16,14 +22,25 @@ type Mode = | 'prepare-erasure' | 'erase' | 'scrub' + | 'tenant-inspect' + | 'tenant-export' + | 'tenant-prepare-erasure' + | 'tenant-erase' + | 'identity-inspect' + | 'identity-export' + | 'identity-prepare-erasure' + | 'identity-erase' | 'replay'; interface Options { mode: Mode; target?: PilotTarget; + tenantTarget?: TenantPrivacyTarget; + identityTarget?: IdentityPrivacyTarget; execute: boolean; output?: string; ledger?: string; + connectorOrigin?: string; } function argument(name: string, args: string[]): string | undefined { @@ -41,6 +58,14 @@ function parse(args: string[]): Options { 'prepare-erasure', 'erase', 'scrub', + 'tenant-inspect', + 'tenant-export', + 'tenant-prepare-erasure', + 'tenant-erase', + 'identity-inspect', + 'identity-export', + 'identity-prepare-erasure', + 'identity-erase', 'replay', ].includes(mode) ) { @@ -52,7 +77,14 @@ function parse(args: string[]): Options { const baseUrl = argument('--base-url', args); const output = argument('--output', args); const ledger = argument('--ledger', args); + const connectorOrigin = argument('--connector-origin', args); + const accountMode = + mode.startsWith('tenant-') || mode.startsWith('identity-'); + const tenantNameHash = argument('--name-sha256', args); + const emailHash = argument('--email-sha256', args); + const userId = argument('--user-id', args); if ( + !accountMode && mode !== 'replay' && (!tenantId?.startsWith('ten_') || !storeId?.startsWith('sto_') || @@ -60,17 +92,49 @@ function parse(args: string[]): Options { ) { throw new Error('exact Tenant, Store and HTTPS base URL are required'); } - if (mode === 'export' && (!output || !isAbsolute(output))) { + if ( + mode.startsWith('tenant-') && + (!tenantId?.startsWith('ten_') || + !/^[0-9a-f]{64}$/.test(tenantNameHash ?? '')) + ) { + throw new Error('exact Tenant ID and name SHA-256 are required'); + } + if ( + mode.startsWith('identity-') && + (!tenantId?.startsWith('ten_') || + !userId?.startsWith('usr_') || + !/^[0-9a-f]{64}$/.test(emailHash ?? '')) + ) { + throw new Error('exact Tenant, User and email SHA-256 are required'); + } + if ( + ['export', 'tenant-export', 'identity-export'].includes(mode) && + (!output || !isAbsolute(output)) + ) { throw new Error('export requires an absolute protected output path'); } - if (mode === 'export' && (!ledger || !isAbsolute(ledger))) { + if ( + ['export', 'tenant-export', 'identity-export'].includes(mode) && + (!ledger || !isAbsolute(ledger)) + ) { throw new Error('export requires the protected privacy directory'); } - if (mode === 'export' && !args.includes('--retain-for-delivery')) { + if ( + ['export', 'tenant-export', 'identity-export'].includes(mode) && + !args.includes('--retain-for-delivery') + ) { throw new Error('export requires explicit delivery retention'); } if ( - ['prepare-erasure', 'erase', 'replay'].includes(mode) && + [ + 'prepare-erasure', + 'erase', + 'tenant-prepare-erasure', + 'tenant-erase', + 'identity-prepare-erasure', + 'identity-erase', + 'replay', + ].includes(mode) && (!ledger || !isAbsolute(ledger)) ) { throw new Error('erasure requires an absolute external ledger path'); @@ -78,13 +142,26 @@ function parse(args: string[]): Options { return { mode, execute, - ...(mode === 'replay' - ? {} - : { + ...(!accountMode && mode !== 'replay' + ? { target: { tenantId: tenantId!, storeId: storeId!, baseUrl: baseUrl! }, - }), + } + : {}), + ...(mode.startsWith('tenant-') + ? { tenantTarget: { tenantId: tenantId!, nameSha256: tenantNameHash! } } + : {}), + ...(mode.startsWith('identity-') + ? { + identityTarget: { + tenantId: tenantId!, + userId: userId!, + emailSha256: emailHash!, + }, + } + : {}), ...(output ? { output } : {}), ...(ledger ? { ledger } : {}), + ...(connectorOrigin ? { connectorOrigin } : {}), }; } @@ -95,7 +172,7 @@ function fingerprint(value: string): string { export async function verifyExportOutput( output: string, ledger: string, - target: PilotTarget + target: { tenantId: string; storeId: string } ): Promise { const scope = createHash('sha256') .update(`${target.tenantId}\0${target.storeId}`) @@ -148,6 +225,84 @@ export async function appendLedger( } } +export async function appendAccountLedger( + path: string, + target: TenantPrivacyTarget | IdentityPrivacyTarget +): Promise { + await verifyLedger(path); + const entry = + 'userId' in target + ? { + kind: 'identity', + tenantId: target.tenantId, + userId: target.userId, + emailSha256: target.emailSha256, + } + : { + kind: 'tenant', + tenantId: target.tenantId, + nameSha256: target.nameSha256, + }; + const handle = await open(path, 'a'); + try { + await handle.writeFile( + `${JSON.stringify({ + ...entry, + requestedAt: new Date().toISOString(), + })}\n` + ); + await handle.sync(); + } finally { + await handle.close(); + } +} + +export async function accountLedgerContains( + path: string, + target: TenantPrivacyTarget | IdentityPrivacyTarget +): Promise { + await verifyLedger(path); + return (await readFile(path, 'utf8')).split('\n').some((line) => { + if (!line) return false; + const entry = JSON.parse(line) as Record; + return 'userId' in target + ? entry['kind'] === 'identity' && + entry['tenantId'] === target.tenantId && + entry['userId'] === target.userId && + entry['emailSha256'] === target.emailSha256 + : entry['kind'] === 'tenant' && + entry['tenantId'] === target.tenantId && + entry['nameSha256'] === target.nameSha256; + }); +} + +export async function requireStoreLedgerEntries( + path: string, + tenantId: string, + storeIds: string[] +): Promise { + await verifyLedger(path); + const entries = (await readFile(path, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + for (const storeId of storeIds) { + if ( + !entries.some( + (entry) => + entry['kind'] === undefined && + entry['tenantId'] === tenantId && + entry['storeId'] === storeId && + typeof entry['baseUrlSha256'] === 'string' && + /^[0-9a-f]{64}$/.test(entry['baseUrlSha256']) + ) + ) + throw new Error( + 'linked Store is missing its protected erasure ledger entry' + ); + } +} + async function ledgerContains( path: string, target: PilotTarget @@ -168,13 +323,95 @@ export async function replayLedger( path: string, service: PilotDataService, database: PrismaClient, - execute: boolean + execute: boolean, + accounts?: PilotAccountService ) { await verifyLedger(path); const lines = (await readFile(path, 'utf8')).split('\n').filter(Boolean); let pending = 0; for (const line of lines) { const entry = JSON.parse(line) as Record; + if (entry['kind'] === 'tenant') { + if ( + !accounts || + typeof entry['tenantId'] !== 'string' || + typeof entry['nameSha256'] !== 'string' || + !/^[0-9a-f]{64}$/.test(entry['nameSha256']) + ) + throw new Error('erasure ledger contains an invalid Tenant entry'); + const tenant = await database.tenant.findFirst({ + where: { id: entry['tenantId'] }, + select: { name: true, deletedAt: true }, + }); + if (!tenant) + throw new Error( + 'erasure ledger Tenant completion cannot be verified in restored data' + ); + if ( + await accounts.tenantErasureComplete({ + tenantId: entry['tenantId'], + nameSha256: entry['nameSha256'], + }) + ) + continue; + if (privacyFingerprint(tenant.name) !== entry['nameSha256']) + throw new Error( + 'erasure ledger Tenant identity does not match restored data' + ); + pending++; + if (execute) + await accounts.eraseTenant({ + tenantId: entry['tenantId'], + nameSha256: entry['nameSha256'], + }); + continue; + } + if (entry['kind'] === 'identity') { + if ( + !accounts || + typeof entry['tenantId'] !== 'string' || + typeof entry['userId'] !== 'string' || + typeof entry['emailSha256'] !== 'string' || + !/^[0-9a-f]{64}$/.test(entry['emailSha256']) + ) + throw new Error('erasure ledger contains an invalid User entry'); + const membership = await database.membership.findFirst({ + where: { tenantId: entry['tenantId'], userId: entry['userId'] }, + select: { deletedAt: true, user: { select: { email: true } } }, + }); + if (!membership) { + const user = await database.user.findUnique({ + where: { id: entry['userId'] }, + select: { id: true }, + }); + if (!user) continue; + throw new Error( + 'erasure ledger User completion cannot be verified without restored Membership' + ); + } + if ( + await accounts.identityErasureComplete({ + tenantId: entry['tenantId'], + userId: entry['userId'], + emailSha256: entry['emailSha256'], + }) + ) + continue; + if (privacyFingerprint(membership.user.email) !== entry['emailSha256']) + throw new Error( + 'erasure ledger User identity does not match restored data' + ); + pending++; + if (execute) + await accounts.eraseIdentity({ + tenantId: entry['tenantId'], + userId: entry['userId'], + emailSha256: entry['emailSha256'], + }); + continue; + } + if (entry['kind'] !== undefined) + throw new Error('erasure ledger contains an unknown entry type'); if ( typeof entry['tenantId'] !== 'string' || typeof entry['storeId'] !== 'string' || @@ -239,13 +476,77 @@ async function main(): Promise { encryption: { key, previousKey: undefined }, } as ApplicationConfigService); const service = new PilotDataService(database, encryption); + const accounts = new PilotAccountService(database); try { if (options.mode === 'replay') { process.stdout.write( - `${JSON.stringify(await replayLedger(options.ledger!, service, database, options.execute))}\n` + `${safeJson(await replayLedger(options.ledger!, service, database, options.execute, accounts))}\n` ); return; } + if (options.tenantTarget || options.identityTarget) { + const tenant = options.tenantTarget; + const identity = options.identityTarget; + const report = tenant + ? await accounts.inspectTenant(tenant) + : await accounts.inspectIdentity(identity!); + if (options.mode.endsWith('-inspect')) { + process.stdout.write(`${safeJson(report)}\n`); + } else if (options.mode.endsWith('-export')) { + await verifyExportOutput(options.output!, options.ledger!, { + tenantId: (tenant ?? identity)!.tenantId, + storeId: tenant ? 'acc_tenant' : identity!.userId, + }); + const exported = tenant + ? await accounts.exportTenant(tenant) + : await accounts.exportIdentity(identity!); + await writeFile(options.output!, `${safeJson(exported)}\n`, { + flag: 'wx', + mode: 0o600, + }); + process.stdout.write('privacy export: PASS protected-file-created\n'); + } else if (options.mode.endsWith('-prepare-erasure')) { + if (tenant) { + const tenantReport = await accounts.inspectTenant(tenant); + if (tenantReport.stores.some((store) => !store.erased)) + throw new Error('erase each linked Store before Tenant erasure'); + await requireStoreLedgerEntries( + options.ledger!, + tenant.tenantId, + tenantReport.stores.map((store) => store.id) + ); + } + if (options.execute) + await appendAccountLedger(options.ledger!, tenant ?? identity!); + process.stdout.write( + `${safeJson({ prepared: options.execute, report })}\n` + ); + } else if (options.mode.endsWith('-erase')) { + if (options.execute) { + if (tenant) { + const tenantReport = await accounts.inspectTenant(tenant); + await requireStoreLedgerEntries( + options.ledger!, + tenant.tenantId, + tenantReport.stores.map((store) => store.id) + ); + } + if ( + !(await accountLedgerContains(options.ledger!, tenant ?? identity!)) + ) + throw new Error( + 'erasure ledger does not contain the verified account identity' + ); + const result = tenant + ? await accounts.eraseTenant(tenant) + : await accounts.eraseIdentity(identity!); + process.stdout.write(`${safeJson({ erased: true, result })}\n`); + } else { + process.stdout.write(`${safeJson({ erased: false, report })}\n`); + } + } + return; + } const target = options.target!; if (options.mode === 'inspect') { process.stdout.write( @@ -270,14 +571,19 @@ async function main(): Promise { throw new Error('privacy target changed before disconnect'); let remoteInput; try { - remoteInput = { - baseUrl: target.baseUrl, - endpointKey: credentials.webhookEndpointKey, - consumerKey: encryption.decrypt(credentials.consumerKeyEncrypted), - consumerSecret: encryption.decrypt( - credentials.consumerSecretEncrypted - ), - }; + remoteInput = options.connectorOrigin + ? { + baseUrl: target.baseUrl, + connectorOrigin: options.connectorOrigin, + endpointKey: credentials.webhookEndpointKey, + consumerKey: encryption.decrypt( + credentials.consumerKeyEncrypted + ), + consumerSecret: encryption.decrypt( + credentials.consumerSecretEncrypted + ), + } + : undefined; } catch { remoteInput = undefined; } @@ -329,6 +635,12 @@ async function main(): Promise { } } +function safeJson(value: unknown): string { + return JSON.stringify(value, (_key, candidate: unknown) => + typeof candidate === 'bigint' ? candidate.toString() : candidate + ); +} + if (require.main === module) { void main().catch(() => { process.stderr.write( diff --git a/backend/src/privacy/woo-webhook-cleanup.spec.ts b/backend/src/privacy/woo-webhook-cleanup.spec.ts index 28fb1ea..3cbbbce 100644 --- a/backend/src/privacy/woo-webhook-cleanup.spec.ts +++ b/backend/src/privacy/woo-webhook-cleanup.spec.ts @@ -4,6 +4,7 @@ import { removeOwnedWooWebhooks } from './woo-webhook-cleanup'; const input = { baseUrl: 'https://shop.example.test/', + connectorOrigin: 'https://wctm.walterbyte.com', endpointKey: 'whk_target', consumerKey: 'fixture-key', consumerSecret: 'fixture-secret', @@ -55,6 +56,127 @@ describe('WooCommerce connector webhook cleanup', () => { expect(String(request.mock.calls[1]?.[0])).not.toContain('12'); }); + it('enumerates both pages before deleting from a mutable 101-hook list', async () => { + const hooks = Array.from({ length: 101 }, (_, i) => ({ + id: i + 1, + topic: 'order.created', + name: + i === 0 || i === 100 ? 'WCTM Connector: order.created' : 'Accounting', + delivery_url: + 'https://wctm.walterbyte.com/api/webhooks/woocommerce/whk_target', + })); + const request = jest.fn(async (url, options) => { + const parsed = new URL(String(url)); + if (options?.method === 'DELETE') { + const id = Number(parsed.pathname.split('/').pop()); + const index = hooks.findIndex((hook) => hook.id === id); + expect(index).toBeGreaterThanOrEqual(0); + hooks.splice(index, 1); + return { ok: true } as Response; + } + const page = Number(parsed.searchParams.get('page')); + return { + ok: true, + json: async () => hooks.slice((page - 1) * 100, page * 100), + } as Response; + }); + await expect(removeOwnedWooWebhooks(input, request)).resolves.toEqual({ + complete: true, + removed: 2, + }); + expect(hooks).toHaveLength(99); + expect(hooks.every((hook) => hook.name === 'Accounting')).toBe(true); + expect( + request.mock.calls + .slice(0, 2) + .every((call) => call[1]?.method !== 'DELETE') + ).toBe(true); + }); + + it('requires manual reconciliation when the page cap prevents complete enumeration', async () => { + const request = jest.fn( + async () => + ({ + ok: true, + json: async () => + Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + topic: 'order.created', + name: 'WCTM Connector: order.created', + delivery_url: + 'https://wctm.walterbyte.com/api/webhooks/woocommerce/whk_target', + })), + }) as Response + ); + await expect(removeOwnedWooWebhooks(input, request)).resolves.toEqual({ + complete: false, + removed: 0, + }); + expect(request).toHaveBeenCalledTimes(10); + expect( + request.mock.calls.every((call) => call[1]?.method !== 'DELETE') + ).toBe(true); + }); + + it('reports partial removal on deletion failure', async () => { + const request = jest.fn(async (url, options) => + options?.method === 'DELETE' + ? ({ ok: String(url).includes('/10?') } as Response) + : ({ + ok: true, + json: async () => + [10, 11].map((id) => ({ + id, + topic: 'order.created', + name: 'WCTM Connector: order.created', + delivery_url: + 'https://wctm.walterbyte.com/api/webhooks/woocommerce/whk_target', + })), + } as Response) + ); + await expect(removeOwnedWooWebhooks(input, request)).resolves.toEqual({ + complete: false, + removed: 1, + }); + }); + + it('requires manual reconciliation for a matching route at another origin', async () => { + const request = jest.fn( + async () => + ({ + ok: true, + json: async () => [ + { + id: 13, + topic: 'order.created', + name: 'WCTM Connector: order.created', + delivery_url: + 'https://unrelated.example.test/api/webhooks/woocommerce/whk_target', + }, + ], + }) as Response + ); + await expect(removeOwnedWooWebhooks(input, request)).resolves.toEqual({ + complete: false, + removed: 0, + }); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('fails closed without an exact HTTPS connector origin', async () => { + const request = jest.fn(); + await expect( + removeOwnedWooWebhooks( + { + ...input, + connectorOrigin: 'https://wctm.walterbyte.com/other', + }, + request + ) + ).resolves.toEqual({ complete: false, removed: 0 }); + expect(request).not.toHaveBeenCalled(); + }); + it('reports manual cleanup when WooCommerce is unavailable', async () => { const request = jest.fn(async () => { throw new Error('fixture network error'); diff --git a/backend/src/privacy/woo-webhook-cleanup.ts b/backend/src/privacy/woo-webhook-cleanup.ts index bf8d1d1..af3a9fe 100644 --- a/backend/src/privacy/woo-webhook-cleanup.ts +++ b/backend/src/privacy/woo-webhook-cleanup.ts @@ -11,6 +11,7 @@ const TOPICS = new Set([ export interface RemoteWebhookCleanupInput { baseUrl: string; + connectorOrigin: string; endpointKey: string | null; consumerKey: string; consumerSecret: string; @@ -22,11 +23,29 @@ export async function removeOwnedWooWebhooks( request: typeof fetch = fetch ): Promise<{ complete: boolean; removed: number }> { if (!input.endpointKey) return { complete: true, removed: 0 }; + let expectedOrigin: string; + try { + const origin = new URL(input.connectorOrigin); + if ( + origin.protocol !== 'https:' || + origin.username || + origin.password || + origin.pathname !== '/' || + origin.search || + origin.hash + ) + return { complete: false, removed: 0 }; + expectedOrigin = origin.origin; + } catch { + return { complete: false, removed: 0 }; + } const authorization = `Basic ${Buffer.from(`${input.consumerKey}:${input.consumerSecret}`).toString('base64')}`; const base = input.baseUrl.endsWith('/') ? input.baseUrl : `${input.baseUrl}/`; let removed = 0; + const ownedIds = new Set(); + let enumerated = false; try { for (let page = 1; page <= 10; page++) { const listUrl = new URL( @@ -53,30 +72,49 @@ export async function removeOwnedWooWebhooks( Number(hook['id']) <= 0 ) continue; - let path: string; + let delivery: URL; try { - path = new URL(hook['delivery_url']).pathname; + delivery = new URL(hook['delivery_url']); } catch { continue; } - if (path !== `/api/webhooks/woocommerce/${input.endpointKey}`) continue; - const deleteUrl = new URL( - `wp-json/wc/v3/webhooks/${hook['id']}?force=true`, - base - ); - const deleted = await request(deleteUrl, { - method: 'DELETE', - headers: { Authorization: authorization }, - redirect: 'error', - signal: AbortSignal.timeout(5_000), - }); - if (!deleted.ok) return { complete: false, removed }; - removed++; + if ( + delivery.pathname !== `/api/webhooks/woocommerce/${input.endpointKey}` + ) + continue; + if ( + delivery.origin !== expectedOrigin || + delivery.search || + delivery.hash || + delivery.username || + delivery.password + ) + return { complete: false, removed }; + ownedIds.add(Number(hook['id'])); } - if (hooks.length < 100) return { complete: true, removed }; + if (hooks.length < 100) { + enumerated = true; + break; + } + } + if (!enumerated) return { complete: false, removed }; + // Deleting during offset pagination shifts later pages and can skip hooks. + for (const id of ownedIds) { + const deleteUrl = new URL( + `wp-json/wc/v3/webhooks/${id}?force=true`, + base + ); + const deleted = await request(deleteUrl, { + method: 'DELETE', + headers: { Authorization: authorization }, + redirect: 'error', + signal: AbortSignal.timeout(5_000), + }); + if (!deleted.ok) return { complete: false, removed }; + removed++; } } catch { return { complete: false, removed }; } - return { complete: false, removed }; + return { complete: true, removed }; } diff --git a/backend/test/fixtures/wordpress-plugin-reconciliation.php b/backend/test/fixtures/wordpress-plugin-reconciliation.php index 80ce028..5826388 100644 --- a/backend/test/fixtures/wordpress-plugin-reconciliation.php +++ b/backend/test/fixtures/wordpress-plugin-reconciliation.php @@ -324,6 +324,44 @@ function wc_get_webhook(int $id): ?WC_Webhook throw new RuntimeException('Uninstall did not remove only local connector material'); } +// Simulate lost installation salts: stored ciphertext no longer authenticates. +$nonce = str_repeat('n', 12); +$tag = ''; +$ciphertext = openssl_encrypt('fixture-only-secret', 'aes-256-gcm', + str_repeat('X', 32), OPENSSL_RAW_DATA, $nonce, $tag, 'WCTM:webhook_secret', 16); +$GLOBALS['wctm_options'] = array( + 'wc_telegram_connector_store_id' => 'sto_fixture', + 'wc_telegram_connector_webhook_endpoint_key' => 'whk_fixture', + 'wc_telegram_connector_webhook_secret' => + 'wctm1:' . base64_encode($nonce . $tag . $ciphertext), +); +$exact_url = $GLOBALS['wctm_runtime_base_url'] . '/api/webhooks/woocommerce/whk_fixture'; +$GLOBALS['wctm_webhooks'] = array( + 51 => array('name' => 'WCTM Connector: order.created', 'topic' => 'order.created', + 'status' => 'active', 'delivery_url' => $exact_url, 'secret' => 'old-secret', 'user_id' => 7), + 52 => array('name' => 'WCTM Connector: order.created', 'topic' => 'order.created', + 'status' => 'active', + 'delivery_url' => 'https://other.example.test/api/webhooks/woocommerce/whk_fixture', + 'secret' => 'old-secret', 'user_id' => 7), + 53 => array('name' => 'Another integration', 'topic' => 'order.created', + 'status' => 'active', 'delivery_url' => $exact_url, 'secret' => 'other-secret', 'user_id' => 7), +); +if (wc_telegram_connector_read_secret('webhook_secret') !== '' || + wc_telegram_connector_has_material()) { + throw new RuntimeException('Lost salts did not fail closed'); +} +$before_lost_salt_retry = $GLOBALS['wctm_webhooks']; +if (wc_telegram_connector_install_and_confirm_webhooks() || + $GLOBALS['wctm_webhooks'] !== $before_lost_salt_retry) { + throw new RuntimeException('Lost salts allowed webhook retry or mutation'); +} +wc_telegram_connector_uninstall(); +if ($GLOBALS['wctm_options'] !== array() || + array_keys($GLOBALS['wctm_webhooks']) !== array(52, 53) || + $GLOBALS['wctm_health_calls'] !== $before_privacy_calls) { + throw new RuntimeException('Salt-loss cleanup changed an unrelated webhook or made a request'); +} + $GLOBALS['wctm_options']['wc_telegram_connector_plugin_credential'] = 'fixture-only'; $GLOBALS['wctm_load_failure'] = true; wc_telegram_connector_uninstall(); diff --git a/backend/test/integration/pilot-account.integration.cjs b/backend/test/integration/pilot-account.integration.cjs new file mode 100644 index 0000000..5a03124 --- /dev/null +++ b/backend/test/integration/pilot-account.integration.cjs @@ -0,0 +1,472 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const { mkdtemp, writeFile, rm } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); + +const { PrismaPg } = require('@prisma/adapter-pg'); +const { + PrismaClient, + StoreStatus, + TelegramChatType, +} = require('@prisma/client'); +const { + PilotAccountService, + privacyFingerprint, +} = require('../../dist/privacy/pilot-account.service'); +const { PilotDataService } = require('../../dist/privacy/pilot-data.service'); +const { + appendAccountLedger, + appendLedger, + replayLedger, +} = require('../../dist/privacy/pilot-data.cli'); + +const url = process.env.PPR2_ACCOUNT_TEST_DATABASE_URL; +if ( + !url || + !/^wctm_ppr2_account_test_[a-z0-9_]+$/.test(new URL(url).pathname.slice(1)) +) + throw new Error('dedicated PPR-2 account test database is required'); +const container = process.env.PPR2_ACCOUNT_TEST_CONTAINER; +if (!container || !/^wctm-account-test-[0-9]+$/.test(container)) + throw new Error('dedicated disposable account test container is required'); +const database = new URL(url).pathname.slice(1); +const db = new PrismaClient({ + adapter: new PrismaPg({ connectionString: url }), +}); + +async function main() { + const directory = await mkdtemp(join(tmpdir(), 'wctm-account-ledger-')); + try { + const ledger = join(directory, 'erasure-ledger.jsonl'); + await writeFile(ledger, '', { mode: 0o600 }); + await db.tenant.createMany({ + data: [ + { id: 'ten_account_a', name: 'Account A', deletedAt: new Date() }, + { id: 'ten_account_b', name: 'Account B' }, + ], + }); + await db.user.createMany({ + data: [ + { + id: 'usr_exclusive', + email: 'exclusive@example.test', + displayName: 'Exclusive', + passwordHash: 'fixture-hash', + }, + { + id: 'usr_removed', + email: 'removed@example.test', + displayName: 'Removed', + passwordHash: 'fixture-hash', + }, + { + id: 'usr_direct', + email: 'direct@example.test', + displayName: 'Direct', + passwordHash: 'fixture-hash', + }, + { + id: 'usr_shared', + email: 'shared@example.test', + displayName: 'Shared', + passwordHash: 'fixture-hash', + }, + ], + }); + await db.membership.createMany({ + data: [ + { + id: 'mem_exclusive', + tenantId: 'ten_account_a', + userId: 'usr_exclusive', + deletedAt: new Date(), + }, + { + id: 'mem_removed', + tenantId: 'ten_account_a', + userId: 'usr_removed', + deletedAt: new Date(), + }, + { + id: 'mem_direct', + tenantId: 'ten_account_b', + userId: 'usr_direct', + deletedAt: new Date(), + }, + { + id: 'mem_shared_a', + tenantId: 'ten_account_a', + userId: 'usr_shared', + deletedAt: new Date(), + }, + { + id: 'mem_shared_b', + tenantId: 'ten_account_b', + userId: 'usr_shared', + deletedAt: new Date(), + }, + ], + }); + await db.store.create({ + data: { + id: 'sto_account_a', + tenantId: 'ten_account_a', + name: 'Erased store', + baseUrl: 'https://erased.invalid/sto_account_a', + status: StoreStatus.DISCONNECTED, + deletedAt: new Date(), + consumerKeyEncrypted: 'fixture-revoked', + consumerSecretEncrypted: 'fixture-revoked', + }, + }); + await db.telegramAccount.createMany({ + data: [ + { + id: 'tga_exclusive', + userId: 'usr_exclusive', + telegramUserId: 10001n, + deletedAt: new Date(), + }, + { id: 'tga_shared', userId: 'usr_shared', telegramUserId: 10002n }, + ], + }); + await db.telegramChatAuthorization.createMany({ + data: [ + { + id: 'chat_exclusive', + telegramAccountId: 'tga_exclusive', + telegramChatId: 10001n, + chatType: TelegramChatType.PRIVATE, + activeTenantId: 'ten_account_a', + activeStoreId: 'sto_account_a', + }, + { + id: 'chat_shared', + telegramAccountId: 'tga_shared', + telegramChatId: 10002n, + chatType: TelegramChatType.PRIVATE, + activeTenantId: 'ten_account_a', + activeStoreId: 'sto_account_a', + }, + { + id: 'chat_shared_b', + telegramAccountId: 'tga_shared', + telegramChatId: 10003n, + chatType: TelegramChatType.PRIVATE, + activeTenantId: 'ten_account_b', + }, + ], + }); + await db.auditLog.create({ + data: { + id: 'aud_account_fixture', + tenantId: 'ten_account_a', + userId: 'usr_exclusive', + action: 'fixture', + entityType: 'User', + entityId: 'usr_exclusive', + metadata: {}, + }, + }); + + const accounts = new PilotAccountService(db); + await assert.rejects( + accounts.eraseIdentity({ + tenantId: 'ten_account_b', + userId: 'usr_exclusive', + emailSha256: privacyFingerprint('exclusive@example.test'), + }), + /missing, erased or ambiguous/ + ); + const tenantTarget = { + tenantId: 'ten_account_a', + nameSha256: privacyFingerprint('Account A'), + }; + const userTarget = { + tenantId: 'ten_account_a', + userId: 'usr_exclusive', + emailSha256: privacyFingerprint('exclusive@example.test'), + }; + const tenantExport = await accounts.exportTenant(tenantTarget); + assert.deepEqual( + tenantExport.stores.map((store) => store.id), + ['sto_account_a'] + ); + assert.deepEqual( + tenantExport.memberships.map((membership) => membership.user.id).sort(), + ['usr_exclusive', 'usr_removed', 'usr_shared'] + ); + assert.deepEqual( + tenantExport.audit.map((entry) => entry.id), + ['aud_account_fixture'] + ); + const userExport = await accounts.exportIdentity(userTarget); + assert.equal(userExport.membership.user.email, 'exclusive@example.test'); + assert.equal(userExport.audit.length, 1); + await appendLedger(ledger, { + tenantId: 'ten_account_a', + storeId: 'sto_account_a', + baseUrl: 'https://shop.example.test', + }); + await appendAccountLedger(ledger, userTarget); + await appendAccountLedger(ledger, tenantTarget); + // The protected ledger survives a genuine restore of the earlier removed- + // membership backup. Reactivation and approved erasure occur after backup. + const backup = execFileSync('docker', [ + 'exec', + container, + 'pg_dump', + '-U', + 'postgres', + '-Fc', + database, + ]); + await db.membership.update({ + where: { id: 'mem_exclusive' }, + data: { deletedAt: null }, + }); + await accounts.eraseIdentity(userTarget); + assert.equal( + (await db.user.findUniqueOrThrow({ where: { id: 'usr_exclusive' } })) + .email, + 'erased-usr_exclusive@invalid.invalid' + ); + await db.$disconnect(); + execFileSync( + 'docker', + [ + 'exec', + '-i', + container, + 'pg_restore', + '-U', + 'postgres', + '--clean', + '--if-exists', + '-d', + database, + ], + { input: backup } + ); + await db.$connect(); + assert.ok( + ( + await db.membership.findUniqueOrThrow({ + where: { id: 'mem_exclusive' }, + }) + ).deletedAt + ); + assert.equal( + (await db.user.findUniqueOrThrow({ where: { id: 'usr_exclusive' } })) + .email, + 'exclusive@example.test' + ); + const stores = new PilotDataService(db, { encrypt: () => 'unused' }); + assert.equal( + (await replayLedger(ledger, stores, db, false, accounts)).pending, + 2 + ); + assert.equal( + (await replayLedger(ledger, stores, db, true, accounts)).pending, + 2 + ); + assert.equal( + (await replayLedger(ledger, stores, db, true, accounts)).pending, + 0 + ); + + const exclusive = await db.user.findUniqueOrThrow({ + where: { id: 'usr_exclusive' }, + }); + const shared = await db.user.findUniqueOrThrow({ + where: { id: 'usr_shared' }, + }); + assert.equal(exclusive.email, 'erased-usr_exclusive@invalid.invalid'); + assert.equal(exclusive.passwordHash, null); + assert.equal(shared.email, 'shared@example.test'); + const removed = await db.user.findUniqueOrThrow({ + where: { id: 'usr_removed' }, + }); + assert.equal(removed.email, 'erased-usr_removed@invalid.invalid'); + assert.equal(removed.displayName, null); + assert.equal(removed.passwordHash, null); + const exclusiveTelegram = await db.telegramAccount.findUniqueOrThrow({ + where: { userId: 'usr_exclusive' }, + }); + const sharedTelegram = await db.telegramAccount.findUniqueOrThrow({ + where: { userId: 'usr_shared' }, + }); + assert.ok(exclusiveTelegram.telegramUserId < 0n); + assert.ok(exclusiveTelegram.deletedAt); + assert.equal(sharedTelegram.telegramUserId, 10002n); + const chat = await db.telegramChatAuthorization.findUniqueOrThrow({ + where: { id: 'chat_exclusive' }, + }); + assert.ok(chat.telegramChatId < 0n); + assert.equal(chat.activeTenantId, null); + const otherTenantChat = + await db.telegramChatAuthorization.findUniqueOrThrow({ + where: { id: 'chat_shared_b' }, + }); + assert.equal(otherTenantChat.activeTenantId, 'ten_account_b'); + assert.equal(otherTenantChat.telegramChatId, 10003n); + const tenantA = await db.tenant.findUniqueOrThrow({ + where: { id: 'ten_account_a' }, + }); + const tenantB = await db.tenant.findUniqueOrThrow({ + where: { id: 'ten_account_b' }, + }); + assert.ok(tenantA.deletedAt); + assert.equal(tenantB.deletedAt, null); + const sharedMembership = await db.membership.findUniqueOrThrow({ + where: { id: 'mem_shared_b' }, + }); + assert.ok(sharedMembership.deletedAt); + const directTarget = { + tenantId: 'ten_account_b', + userId: 'usr_direct', + emailSha256: privacyFingerprint('direct@example.test'), + }; + await accounts.eraseIdentity(directTarget); + assert.equal( + (await db.user.findUniqueOrThrow({ where: { id: 'usr_direct' } })).email, + 'erased-usr_direct@invalid.invalid' + ); + assert.equal( + await db.auditLog.count({ where: { tenantId: 'ten_account_a' } }), + 5 + ); + // An audit marker cannot certify a mixed restore that still retains scoped PII. + await db.telegramSearchReference.create({ + data: { + id: 'ref_retained_pii', + tenantId: 'ten_account_a', + storeId: 'sto_account_a', + membershipId: 'mem_exclusive', + telegramAccountId: 'tga_exclusive', + telegramChatId: 10001n, + purpose: 'PAGE', + queryEncrypted: 'fixture-retained-query', + expiresAt: new Date(Date.now() + 60_000), + }, + }); + assert.equal(await accounts.identityErasureComplete(userTarget), false); + await assert.rejects( + replayLedger(ledger, stores, db, true, accounts), + /does not match/ + ); + await db.telegramSearchReference.delete({ + where: { id: 'ref_retained_pii' }, + }); + assert.equal(await accounts.identityErasureComplete(userTarget), true); + const preMembershipLedger = join(directory, 'pre-membership-ledger.jsonl'); + await writeFile(preMembershipLedger, '', { mode: 0o600 }); + const preMembershipTarget = { + tenantId: 'ten_account_b', + userId: 'usr_pre_membership', + emailSha256: privacyFingerprint('pre-membership@example.test'), + }; + await db.user.create({ + data: { + id: preMembershipTarget.userId, + email: 'pre-membership@example.test', + displayName: 'Before joining', + passwordHash: 'fixture-hash', + }, + }); + const preMembershipBackup = execFileSync('docker', [ + 'exec', + container, + 'pg_dump', + '-U', + 'postgres', + '-Fc', + database, + ]); + await db.membership.create({ + data: { + id: 'mem_pre_membership', + tenantId: preMembershipTarget.tenantId, + userId: preMembershipTarget.userId, + }, + }); + await appendAccountLedger(preMembershipLedger, preMembershipTarget); + await accounts.eraseIdentity(preMembershipTarget); + await db.$disconnect(); + execFileSync( + 'docker', + [ + 'exec', + '-i', + container, + 'pg_restore', + '-U', + 'postgres', + '--clean', + '--if-exists', + '-d', + database, + ], + { input: preMembershipBackup } + ); + await db.$connect(); + assert.equal( + await db.membership.findFirst({ + where: { + tenantId: preMembershipTarget.tenantId, + userId: preMembershipTarget.userId, + }, + }), + null + ); + await assert.rejects( + replayLedger(preMembershipLedger, stores, db, true, accounts), + /without restored Membership/ + ); + assert.equal( + ( + await db.user.findUniqueOrThrow({ + where: { id: preMembershipTarget.userId }, + }) + ).email, + 'pre-membership@example.test' + ); + await db.user.delete({ where: { id: preMembershipTarget.userId } }); + assert.equal( + (await replayLedger(preMembershipLedger, stores, db, true, accounts)) + .pending, + 0 + ); + const missingTenantLedger = join(directory, 'missing-tenant-ledger.jsonl'); + await writeFile(missingTenantLedger, '', { mode: 0o600 }); + await appendAccountLedger(missingTenantLedger, { + tenantId: 'ten_not_yet_created', + nameSha256: privacyFingerprint('Before tenant creation'), + }); + await assert.rejects( + replayLedger(missingTenantLedger, stores, db, true, accounts), + /Tenant completion cannot be verified/ + ); + // Missing/expired proof cannot turn anonymized data into an automatic skip. + await db.auditLog.deleteMany({ + where: { tenantId: 'ten_account_a', action: { startsWith: 'privacy.' } }, + }); + await assert.rejects( + replayLedger(ledger, stores, db, true, accounts), + /does not match/ + ); + console.log( + 'pilot account integration: PASS scoped-export linked-store ledger replay soft-deleted-target removed-member-cascade backup-restore-replay shared-removed-membership FK audit idempotency retained-scoped-pii-fail-closed audit-expiry-fail-closed pre-membership-restore-fail-closed absent-user-safe-skip absent-tenant-fail-closed' + ); + } finally { + await db.$disconnect(); + await rm(directory, { recursive: true, force: true }); + } +} + +main().catch((error) => { + const detail = String(error?.stack ?? error).replaceAll(url, '[test-db]'); + console.error(`pilot account integration: FAIL ${detail}`); + process.exitCode = 1; +}); diff --git a/backend/test/wordpress-plugin.test.cjs b/backend/test/wordpress-plugin.test.cjs index 1d7a383..b909641 100644 --- a/backend/test/wordpress-plugin.test.cjs +++ b/backend/test/wordpress-plugin.test.cjs @@ -173,6 +173,10 @@ test('connector keeps secrets out of navigation URLs and provides new-token reco /add_query_arg\([^\n]*(?:token|credential|secret)/i ); assert.match(plugin, /issue a new M7 token/i); + assert.match( + plugin, + /a new M7 token alone cannot recover unreadable webhook material/i + ); assert.match(plugin, /Reconnect with new token/); assert.match(plugin, /redirection' => 0/); }); diff --git a/docker-compose.yml b/docker-compose.yml index 36c3eb8..cbb18f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,14 @@ services: redis: image: redis:7.4.11-alpine3.21@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf restart: unless-stopped - command: ['redis-server', '--appendonly', 'yes'] + command: + - redis-server + - --appendonly + - 'yes' + - --auto-aof-rewrite-percentage + - '100' + - --auto-aof-rewrite-min-size + - 64mb volumes: - redis_data:/data healthcheck: diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 644beeb..546467a 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1304,10 +1304,19 @@ onboarding/product-surface polish, focused security/quality QA, and controlled private merchant pilots. Engineering is frozen except for pilot-blocking defects. PPR-1 is complete and merged to canonical `main`; `docs/privacy/` contains its inventory, disclosure, WordPress privacy integration and GPL -plugin licensing under D-034. PPR-2 is the active data-protection implementation -task from `f5903fd99c999912a1bbbe69f28ecb0c42693581`. See -`docs/privacy/PPR2_DATA_PROTECTION.md` for exact Store-scoped controls, -retention values, backup key/recovery boundaries and remaining pilot gates. +plugin licensing under D-034. PPR-2 remains Active after the post-PR #50 +canonical baseline `6d1f3a8ea1b7bbc6660303761b918e36f3fd4746`. +Production passed encrypted off-site backup/isolated restore, legacy plaintext +disposal, bounded logs, dedicated PostgreSQL retention, BullMQ cleanup, manual +Redis AOF compaction and all three timers. This branch adds repository-tested +Tenant/User/Telegram identity lifecycle, connector salt-loss cleanup checks and +automatic AOF rewrite settings. See `docs/privacy/PPR2_DATA_PROTECTION.md` for +the validation matrix and remaining live-only account, representative-site and +AOF checks; `docs/privacy/WORDPRESS_CONNECTOR_VALIDATION.md` has the isolated +site procedure. Lost WordPress salts have fail-closed behavior, but recovery +without restoring original salts needs a reviewed operator path because M7 +does not return the existing webhook secret. Real-merchant onboarding remains +Blocked. No real-merchant pilot or broad public launch is authorized by this work. ### Last completed product milestone: M22 diff --git a/docs/MASTER-ROADMAP.md b/docs/MASTER-ROADMAP.md index 3f19fcd..3d80d71 100644 --- a/docs/MASTER-ROADMAP.md +++ b/docs/MASTER-ROADMAP.md @@ -322,10 +322,12 @@ canonical `main`. The code-backed inventory, merchant notice, WordPress privacy integration and GPL-2.0-or-later plugin license are in `docs/privacy/`. **PPR-2 — Pilot Data Protection Controls:** active implementation task. The -branch adds minimized webhook/order persistence, a trusted Store-scoped -disconnect/export/erasure workflow, explicit logical retention, WordPress-owned -credential encryption and encrypted off-site backups. The exact limits and -remaining pilot gates are in `docs/privacy/PPR2_DATA_PROTECTION.md`. This work +repository includes minimized webhook/order persistence, trusted Store and +account lifecycle workflows, explicit retention, WordPress-owned credential +encryption and encrypted off-site backups. Production backup, retention and +timer checks passed; account, representative-site connector, salt-loss recovery +and automatic AOF checks remain. The exact limits and validation matrix are in +`docs/privacy/PPR2_DATA_PROTECTION.md`. This work does not authorize a real-merchant pilot or resume Phase 7. --- diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 703b778..3686318 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -18,17 +18,19 @@ and production-validated. The corrected scheduled backup passed live systemd execution on `waltpack`, and the daily timer is enabled. D-033 is Accepted: Phase 7 is intentionally paused and incomplete before P7.4; P7.4–P7.8 and Phase 6 remain deferred/unstarted. Private Pilot Readiness is the current active -initiative. PPR-1 is complete on canonical `main`; PPR-2 is the active -implementation task for pilot data protection. New minimization, operator -lifecycle, retention and backup encryption controls are under local validation. -Store privacy exports now have scoped protected files, completion cleanup and -an hourly 24-hour retention bound awaiting operator-host validation. -Remaining operational and account-deletion gates block real-merchant onboarding. -Live PPR-2 validation exposed a PostgreSQL retention-role boundary: the normal -`wctm_runtime` account correctly lacks DELETE and search-reference UPDATE. -The repository correction moves destructive retention to a separate protected -daily oneshot/`wctm_retention` identity; live role and scheduler proof are still -pending. Audit rows remain immutable to the normal runtime account. +initiative. PPR-1 is complete; PPR-2 remains **Active**. Production validation +on `waltpack` passed encrypted off-site backup and isolated recovery, legacy +plaintext off-site disposal, bounded Docker/journald logs, dedicated +`wctm_retention` PostgreSQL cleanup, BullMQ 24-hour/7-day cleanup, manual +Redis AOF compaction, and all three operational systemd timers. This branch +adds repository-tested Tenant/User/Telegram identity lifecycle controls, +WordPress salt-loss/webhook cleanup coverage, and explicit automatic Redis +AOF rewrite settings. Account workflow acceptance on isolated production-like +data, representative-site connector validation and post-deployment automatic +AOF setting/proof remain. WordPress salt loss also needs a reviewed recovery +path if original host salts cannot be restored; a fresh M7 token alone cannot +recover unreadable existing webhook material. Real-merchant onboarding is +**Blocked**. Engineering remains frozen except for pilot-blocking defects. Unrestricted public launch is not approved. @@ -41,9 +43,9 @@ revision `034b2f565fd6b2fc50d6942ff1a40ca16598703c`. P7.3 backup, OneDrive off-site SHA-256, isolated restore, and corrected live scheduled service all passed. `wctm-backup.service` completed as `wctm` with `SERVICE_RC=0`; the daily timer is enabled and active. PPR-1 is merged in -canonical `main` `f5903fd99c999912a1bbbe69f28ecb0c42693581`. PPR-2 is -implementing the approved protection controls; the exact controls and -remaining pilot gates are in `docs/privacy/PPR2_DATA_PROTECTION.md`. The +canonical `main`. PPR-2 is implementing the approved protection controls from +the post-PR #50 main baseline; repository and production results and remaining +pilot gates are in `docs/privacy/PPR2_DATA_PROTECTION.md`. The earlier migration from `hetz` succeeded; its WCTM deployment was removed and is not a rollback host. @@ -57,8 +59,8 @@ Project Version Repository -Current implementation branch: `feat/ppr2-pilot-data-protection` from -canonical `main` `f5903fd99c999912a1bbbe69f28ecb0c42693581`. +Current implementation branch: `feat/ppr2-final-privacy-readiness` from +canonical `main` `6d1f3a8ea1b7bbc6660303761b918e36f3fd4746` (PR #50 merged). M22 implementation commit: `35f9e72335c8ed0c6a039497d92bed763dc68fb5 feat(entitlements): add MVP tenant diff --git a/docs/privacy/MERCHANT_PRIVACY.md b/docs/privacy/MERCHANT_PRIVACY.md index ac60e90..351d9c3 100644 --- a/docs/privacy/MERCHANT_PRIVACY.md +++ b/docs/privacy/MERCHANT_PRIVACY.md @@ -6,13 +6,13 @@ When you connect a store, WCTM receives store identity/configuration and WooCommerce REST credentials, then installs authenticated order and product webhooks through its WordPress connector. WooCommerce sends order and product webhook payloads to WCTM. WCTM's new processing path stores an explicit subset: order identity/status, totals, selected customer name and shipping address, item summaries, and inventory/stock fields needed for store management. It discards webhook notes, customer email/phone, billing address and arbitrary WooCommerce metadata. Earlier records may still contain fuller data until an operator scrubs them or their backup sets expire. WCTM also stores account email, team membership/roles, Telegram user/chat IDs, operational audit/delivery records and integration health information. -The WordPress connector keeps the store ID, connector credential, webhook secret and route key in WordPress options with autoload disabled. The WCTM-owned credentials are authenticated-encrypted with a key derived from WordPress installation secrets outside its database; previously stored plaintext credentials migrate when read. WooCommerce webhook records also hold their signing secret under WooCommerce's own storage rules. The connector does not install tracking cookies or telemetry and does not keep its own local customer/order database. WordPress/WooCommerce and their normal host/database backups remain under the store operator's control. +The WordPress connector keeps the store ID, connector credential, webhook secret and route key in WordPress options with autoload disabled. The WCTM-owned credentials are authenticated-encrypted with a key derived from WordPress installation secrets outside its database; previously stored plaintext credentials migrate when read. If those installation secrets are lost, the saved WCTM secrets cannot be read; restoring the original protected host configuration is the supported first recovery step. A new registration token alone cannot recover an unreadable existing webhook secret. WooCommerce webhook records also hold their signing secret under WooCommerce's own storage rules. The connector does not install tracking cookies or telemetry and does not keep its own local customer/order database. WordPress/WooCommerce and their normal host/database backups remain under the store operator's control. ## Where it goes WCTM stores account, store, event, order and inventory records in its PostgreSQL service. Its Redis queue holds job identifiers/context and rate-limit state; Redis persistence is enabled. The backend makes authorized WooCommerce REST requests. Only authorized private-chat store managers can request operational views/actions. WCTM sends selected order and inventory details to Telegram to deliver those views and notifications; order detail may include customer name and shipping address. Telegram is an external platform with its own message and account retention behavior. Do not put information in manager notes that staff should not share with Telegram or WooCommerce. -WCTM makes custom-format PostgreSQL backups for recovery. The new off-site path encrypts a dump locally before OneDrive transfer and verifies the remote encrypted content by SHA-256. Recovery keys are held separately from OneDrive. The local default retains 14 valid sets; new encrypted off-site sets expire after 30 days while preserving the newest two complete sets. Earlier plaintext off-site sets remain subject to a separate reviewed cleanup decision. A restored older backup must have outstanding erasure requests reapplied before service resumes. +WCTM makes custom-format PostgreSQL backups for recovery. The off-site path encrypts a dump locally before OneDrive transfer and verifies the remote encrypted content by SHA-256. Recovery keys are held separately from OneDrive. The local default retains 14 valid sets; encrypted off-site sets expire after 30 days while preserving the newest two complete sets. Earlier plaintext off-site sets were disposed of in a separate production operation. A restored older backup must have outstanding erasure requests reapplied before service resumes. ## Security and your responsibilities @@ -24,6 +24,6 @@ As the store operator, inform customers and staff about the store's use of WCTM Plugin **deactivation** retains connector settings and WooCommerce webhooks; it does not stop all webhook delivery. The normal Store `DELETE` operation is a **soft delete**, not the verified privacy disconnect. Confirmed Telegram `/unlink` revokes the active chat link, but prior WCTM and Telegram records remain. Plugin **uninstall** deletes connector options and matching local webhooks when WooCommerce APIs are available; it does not erase WCTM, OneDrive or Telegram data. If WooCommerce was inactive during uninstall, check and remove remaining WCTM webhooks after reactivation. Rotate credentials when a connection is retired or compromised. -For a private-pilot service-side export, disconnect or erasure request, contact your Walterbyte pilot contact through the agreed support channel and identify your account/store. An operator verifies the requester and exact Store, inspects the scope, and can revoke processing access and erase/anonymize Store-scoped live records through a protected maintenance workflow. Narrow security/audit identifiers remain; Telegram messages, merchant WooCommerce records and historical backups are outside live Store erasure. Older backups expire by retention and must have outstanding erasure requests replayed after restore. Whole-account deletion involving shared users/Telegram identities needs separate review. Real-merchant onboarding remains blocked pending the PPR-2 operational and retention gates. +For a private-pilot service-side export, disconnect or erasure request, contact your Walterbyte pilot contact through the agreed support channel and identify your account/store. An operator verifies the requester and exact Tenant, Store or User membership before acting through a protected maintenance workflow. Store processing can be revoked; Store records can be erased or anonymized. A Tenant request requires each linked Store to be handled first. For a User shared with another Tenant, WCTM removes only the requested Tenant membership and access, preserving the global identity needed by the other Tenant. An exclusive User's email and Telegram identifiers can be replaced with tombstones, while narrow referential/security audit records remain until their retention expires. Telegram messages, merchant WooCommerce records and historical backups are outside live erasure. Older backups expire by retention and must have outstanding erasure requests replayed after restore. This is not a promise of immediate removal of all traces. Real-merchant onboarding remains blocked pending the remaining PPR-2 acceptance gates. -Store export files are created only for an explicit private-pilot request in a protected operator directory. They are removed after confirmed delivery or completed Store erasure; an hourly cleanup is designed to remove abandoned files within 24 hours. A delivered copy is governed by the agreed secure delivery channel, and historical backups follow their separate retention rules. +Store, Tenant and User export files are created only for an explicit private-pilot request in a protected operator directory. They are removed after confirmed delivery or completed erasure; hourly cleanup removes abandoned files within 24 hours. A delivered copy is governed by the agreed secure delivery channel, and historical backups follow their separate retention rules. diff --git a/docs/privacy/PPR2_DATA_PROTECTION.md b/docs/privacy/PPR2_DATA_PROTECTION.md index 84c4210..f07283a 100644 --- a/docs/privacy/PPR2_DATA_PROTECTION.md +++ b/docs/privacy/PPR2_DATA_PROTECTION.md @@ -1,8 +1,10 @@ # PPR-2 pilot data protection controls -Status: implementation branch, **not production validated**. PPR-1 is complete on -`main`. This document describes the repository behavior and the remaining gates -for a controlled real-merchant pilot. Phase 7 remains paused. +Status: PPR-2 **Active**; real-merchant onboarding **Blocked**. PPR-1 is complete. +The production operations listed in the validation matrix below passed on +`waltpack`. New account-lifecycle and connector changes in this branch have +repository validation only; they require separate live acceptance before a +controlled real-merchant pilot. Phase 7 remains paused. ## Data minimization @@ -29,8 +31,8 @@ original data until expiry and require erasure-ledger replay after restore. ## Verified operator lifecycle -`scripts/ops/pilot-privacy.sh` is an operator-only, non-HTTP entry point. It -requires an exact Tenant ID, Store ID and HTTPS base URL, a protected database +`scripts/ops/pilot-privacy.sh` is an operator-only, non-HTTP entry point. Store +operations require an exact Tenant ID, Store ID and HTTPS base URL. It requires a protected database owner credential file, the application encryption key already in protected Compose configuration, and a 0700 external privacy directory with a 0600 erasure ledger. The credential file contains exactly one `DATABASE_URL=` entry, @@ -79,7 +81,10 @@ and secret, replaces the encrypted Woo REST key/secret with unusable random values, marks the Store disconnected/deleted, revokes its active Telegram chat context, and fails queued/processing Store webhook events. The operator command then tries to remove only exact WCTM-owned WooCommerce webhook records using the -pre-revocation credentials held in memory. If Woo removal fails, the report says +pre-revocation credentials held in memory and the operator-verified HTTPS +`--connector-origin`. Matching name, topic and route at a different origin +requires manual reconciliation; an absent/invalid origin never authorizes +remote deletion. If Woo removal fails, the report says `manual-required`; the local route and credentials remain revoked. The operator must reconcile the Woo side before closing the request. Plugin deactivation, uninstall, normal Store soft delete and Telegram unlink are **not** this workflow. @@ -92,34 +97,80 @@ order customer/line/payment/shipping snapshots and totals, inventory labels/SKU, encrypted callback/search text and action results. Store name/base URL become a tombstone. Referential IDs, delivery state and narrow audit events remain for integrity/security evidence. The operator must inspect the export and confirm -scope/authority before `--execute`. This is **Store-scoped** erasure; it does not -delete shared Tenant/User/Telegram identities or Telegram messages. Whole-account -requests require a separately reviewed procedure. +scope/authority before `--execute`. This Store operation does not delete +Tenant/User/Telegram identities or Telegram messages. + +### Tenant, User and Telegram identity requests + +The operator can inspect/export/erase an exact Tenant using its ID and the +SHA-256 fingerprint of its current name, or one User membership using the exact +Tenant ID, User ID and SHA-256 fingerprint of the current email. These hashes +are identity checks, not replacements for verifying request authority. Protected +0600 exports include scoped identity, membership and audit data; a Tenant +request lists linked Stores but does not silently export their orders. Use +separate Store exports for those records. Exports use the same protected +directory, explicit delivery retention and 24-hour maximum as Store exports. + +For Tenant erasure, first disconnect and erase **every** linked Store, and +archive each Store ledger entry. The Tenant workflow verifies that all linked +Stores have the erased tombstone and ledger entry, then archives its own +minimal name-fingerprint ledger entry before mutation. It revokes active +Telegram access, soft-deletes memberships, suspends/tombstones the Tenant and +preserves foreign keys. A User request similarly archives a scoped +email-fingerprint entry before erasing that membership and revoking its +Tenant access. A User with any other Tenant membership retains the global +User email and Telegram identity for those other Tenants. When no other +membership exists, the workflow replaces the email and Telegram user/chat IDs +with tombstones, clears the password, link tokens and selected reference +contents, and preserves only referential IDs, delivery state and security +audit records until their own retention expires. Telegram's delivered messages +remain under Telegram's separate controls. This is not complete removal of +all account traces. + +For an authorized account request, record the exact IDs and fingerprints in +protected operator records. With backend and bot stopped, use the same external +credential/directory arguments and protected `WCTM_OFFSITE_DESTINATION` setting +as the Store workflow: + +```sh +scripts/ops/pilot-privacy.sh --credential-file /etc/wctm/privacy-owner.env --privacy-directory /var/lib/wctm/privacy -- tenant-inspect --tenant-id TENANT_ID --name-sha256 TENANT_NAME_SHA256 +scripts/ops/pilot-privacy.sh --credential-file /etc/wctm/privacy-owner.env --privacy-directory /var/lib/wctm/privacy -- tenant-export --tenant-id TENANT_ID --name-sha256 TENANT_NAME_SHA256 --retain-for-delivery +scripts/ops/pilot-privacy.sh --credential-file /etc/wctm/privacy-owner.env --privacy-directory /var/lib/wctm/privacy -- tenant-erase --tenant-id TENANT_ID --name-sha256 TENANT_NAME_SHA256 --execute +``` + +Use `identity-inspect`, `identity-export` and `identity-erase` with +`--tenant-id`, `--user-id` and `--email-sha256` for one membership. The wrapper +prepares the ledger entry, verifies its encrypted off-site archive, executes +erasure and purges scoped local exports. Check the Store ledger entries and +their remote archive first. If archival fails, erasure must not run. A +separately protected delivered export copy follows the agreed delivery policy; +the local operator artifact expires within 24 hours. Before a restored old backup becomes authoritative, recover the latest external erasure ledger, verify/decrypt it, keep backend and bot stopped, and execute -`replay`. Replay checks each Tenant/Store and base-URL hash, disconnects and -erases matching restored records, skips already erased stores, and fails closed -on mismatches. Only after replay and normal role/config/readiness checks may +`replay`. The ledger is chronological: Store entries precede dependent User +and Tenant entries. Replay checks each exact identity fingerprint, disconnects +and erases matching restored records, skips already erased records, and fails +closed on mismatches. Only after replay and normal role/config/readiness checks may application service resume. Keep the ledger and its encryption key for as long as any recoverable backup could predate an erasure. Historic backups expire by retention; they are not rewritten in place. ## Explicit pilot retention decisions -| Data | Repository rule | Boundary | -| ----------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| BullMQ completed jobs | 24 hours; terminal cleanup hourly, up to 1000 per sweep | Active/waiting/delayed work is never selected. Heavy backlog may take additional sweeps. | -| BullMQ failed jobs | 7 days; same terminal sweep | Diagnostic result/IDs persist until then. | -| Redis rate-limit and dedupe state | Existing 60-second rate windows; job keys as above | Redis AOF is persistent and old bytes may remain until compaction; physical data expiry needs live proof. | -| Link/registration tokens and encrypted callback/search text | Clear 1 day after their expiry; daily PostgreSQL oneshot | Existing active TTLs remain unchanged; backend runtime cannot DELETE. | -| Completed webhook payload | Clear 30 days after completion; daily PostgreSQL oneshot | Event metadata/dedupe stays for operational integrity. | -| Failed webhook payload | Clear 90 days after failure; daily PostgreSQL oneshot | Pending/active events are untouched. | -| Security audit rows | 365 days; daily PostgreSQL oneshot | Backend audit writes remain append-only; only the separate retention identity may expire old rows. Minimal erasure ledger stays separate. | -| Protected Store privacy exports | Remove after erasure or confirmed delivery; 24-hour maximum | Exact scoped names only; hourly sweep begins at 23 hours, with a sweep on every operator entry. Timer installation/live proof remains a pilot gate. | -| Local PostgreSQL backups | Existing 14 validated sets | Newer 14 sets always protected; this is count-based, not a calendar age. | -| New encrypted OneDrive sets | 30 days, always keep newest 2 complete sets | Legacy plaintext and incomplete sets are never auto-deleted. | -| Application and scheduler logs | No repository-enforced calendar limit yet | Host Docker/journald policy and live proof are a pilot gate. | +| Data | Repository rule | Boundary | +| ----------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| BullMQ completed jobs | 24 hours; terminal cleanup hourly, up to 1000 per sweep | Active/waiting/delayed work is never selected. Heavy backlog may take additional sweeps. | +| BullMQ failed jobs | 7 days; same terminal sweep | Diagnostic result/IDs persist until then. | +| Redis rate-limit and dedupe state | Existing 60-second rate windows; job keys as above | Redis AOF is persistent. Automatic rewrite is configured at 100% growth and 64 MiB minimum; logical expiry does not immediately remove old AOF bytes. | +| Link/registration tokens and encrypted callback/search text | Clear 1 day after their expiry; daily PostgreSQL oneshot | Existing active TTLs remain unchanged; backend runtime cannot DELETE. | +| Completed webhook payload | Clear 30 days after completion; daily PostgreSQL oneshot | Event metadata/dedupe stays for operational integrity. | +| Failed webhook payload | Clear 90 days after failure; daily PostgreSQL oneshot | Pending/active events are untouched. | +| Security audit rows | 365 days; daily PostgreSQL oneshot | Backend audit writes remain append-only; only the separate retention identity may expire old rows. Minimal erasure ledger stays separate. | +| Protected Store/Tenant/User privacy exports | Remove after erasure or confirmed delivery; 24-hour maximum | Exact scoped names only; hourly sweep begins at 23 hours, with a sweep on every operator entry. | +| Local PostgreSQL backups | Existing 14 validated sets | Newer 14 sets always protected; this is count-based, not a calendar age. | +| New encrypted OneDrive sets | 30 days, always keep newest 2 complete sets | Legacy plaintext and incomplete sets are never auto-deleted. | +| Application and scheduler logs | Bounded host Docker/journald policy | Production rotation and bounded retention were validated on `waltpack`; host configuration remains an operations control. | ### Production-discovered PostgreSQL privilege correction @@ -191,9 +242,8 @@ sudo systemctl list-timers wctm-postgres-retention.timer --no-pager Run the grants/verification as the owner after role recreation on restored clusters, but retain the protected runtime role and separate migration identity. Do not enable the timer until the role verification and first oneshot PASS. The -script emits only a start/PASS or non-secret failure message. Live execution, -role proof, and absence of backend permission errors remain PPR-2 production -validation gates; real-merchant onboarding stays blocked. +script emits only a start/PASS or non-secret failure message. The dedicated +role, retention sweep and timer passed production validation on `waltpack`. ## WordPress and backup confidentiality @@ -202,8 +252,14 @@ use AES-256-GCM with separate associated-data labels. The key derives by HKDF from WordPress `AUTH_KEY` and `SECURE_AUTH_KEY`, which should live outside the WordPress database. Exact legacy plaintext token shapes migrate on first read. Missing salts or authentication failure yields no credential and no plaintext -fallback. Reconnect/rotation must reprovision secrets with the service operator -if host salts are lost. WordPress administrators, host files and backups remain +fallback. Retry fails closed in that state. Restore the original salts from a +protected host backup where possible. A fresh M7 token alone cannot recover +unreadable existing webhook material because M7 intentionally does not return +the stored signing secret; a separately reviewed operator rotation/reconnect +procedure is needed if the original salts are unavailable. Plugin uninstall can remove exact WCTM-owned +WooCommerce hooks despite lost salts by matching its full delivery URL, topic +and name while preserving unrelated hooks. The local fixture covers this; +representative-site confirmation remains pending. WordPress administrators, host files and backups remain within the merchant's trust boundary. WooCommerce itself persists its webhook signing secret in its own database; this plugin does not alter Woo internals. @@ -220,9 +276,9 @@ It inherits the immutable reviewed Node 24.20.0 / Alpine 3.24 base and the SHA-256-verified OpenSSL 3.5.9-r0 package installation, then removes global npm, Corepack, Yarn and APK tooling. The runner resolves the reviewed local tag to an immutable image ID and uses it with `--pull=never`, no network, a read-only root and only -explicit input/key/output mounts. Docker access, that locally built ops image -and the readable key file require live validation; no host Node installation is -needed. The decrypted dump is only created in +explicit input/key/output mounts. The encrypted scheduled backup and isolated +decrypt/restore path passed production validation on `waltpack`; no host Node +installation is needed. The decrypted dump is only created in an operator-controlled 0700 directory, then used with the existing isolated restore procedure. Encryption does not replace checksum or restore tests. @@ -232,29 +288,39 @@ verified with the replacement key. Rotation is a reviewed maintenance action: create a new private key, retain the old key in separate protected recovery storage, update the key path, make a new encrypted backup, decrypt/restore it in isolation, and verify ledger recovery before retiring the old key. Losing the -key loses the off-site recovery path. Existing plaintext OneDrive sets are -**not** silently deleted or converted; inventory them and obtain a reviewed -retention/deletion decision. Treat them as sensitive until removed. - -## Remaining gates before real merchants - -1. Review and execute the lifecycle, scrub and encrypted backup/restore paths - against isolated production-like data, including cross-tenant checks and - erasure replay, then validate the scheduled encrypted path on `waltpack` in - a separately authorized production operation. -2. Inventory and decide disposal of pre-PPR-2 plaintext OneDrive sets. No - automatic legacy deletion is authorized by this branch. -3. Set and verify host Docker/journald log rotation/retention and Redis AOF - compaction/physical expiry. The repository controls logical TTLs, not those - host storage lifetimes. -4. Define a reviewed whole-Tenant/User/Telegram-account export and deletion - process, including shared identities and security audit exceptions, before - promising complete account erasure. -5. Verify WordPress salt-loss reconnect/rotation and Woo-owned signing-secret - cleanup on a representative pilot merchant site. -6. Install and verify the protected privacy-export retention timer on the - operator host before processing real merchant exports. Review its service - status and confirm the privacy directory ownership and mode. - -Until these gates are closed and accepted, PPR-2 remains active and real-merchant -pilot onboarding remains blocked. No public-launch readiness is claimed. +key loses the off-site recovery path. Legacy plaintext OneDrive sets were +disposed of in a separately validated production operation. Repository code +does not automatically remove legacy sets. + +## PPR-2 validation matrix + +Repository PASS means automated checks against synthetic data and checked-in +configuration. Production PASS means the authorized operator's observed result +on `waltpack`; this branch has not accessed that host. + +| Control | Repository | Production | Remaining boundary | +| ---------------------------------------------------------- | --------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Encrypted off-site backup, integrity and isolated recovery | PASS | PASS | Keep recovery key separate from OneDrive. | +| Legacy plaintext off-site set disposal | Procedure documented | PASS | No automatic legacy deletion. | +| Docker/journald bounded logs | Configuration documented | PASS | Host policy remains an operations control. | +| PostgreSQL retention via `wctm_retention` | PASS | PASS | Runtime role remains restricted. | +| BullMQ completed/failed cleanup | PASS | PASS | 24-hour/7-day policy; active jobs excluded. | +| Redis logical expiry and AOF compaction | PASS | PASS for manual compaction | Automatic rewrite settings require post-deployment `CONFIG GET` proof; rewrite thresholds do not themselves guarantee a calendar bound on old bytes. | +| Backup, privacy-export and PostgreSQL-retention timers | PASS | PASS | All three timers installed and operating. | +| Store lifecycle, ledger replay and scoped privacy exports | PASS | Pending account workflow acceptance | Execute against isolated production-like data under approved maintenance procedure. | +| Tenant/User/Telegram account lifecycle | PASS on isolated synthetic PostgreSQL | Pending | Verify request authority, shared identity behavior, archived ledger and replay on isolated production-like data. | +| WordPress salt-loss/reconnect and exact webhook cleanup | Salt-loss failure and cleanup PASS in local fixture | Pending | Representative non-merchant site proof and a reviewed recovery procedure when original salts are unavailable remain pilot gates. | + +Redis `appendonly yes` is explicit in Compose, and automatic AOF rewrite is +configured at 100% growth after a 64 MiB minimum. Logical TTL/delete removes +keys from Redis's active dataset; AOF may still contain prior write bytes until +rewrite. Manual production compaction reduced the AOF from approximately +66.9 MB to 4.67 MB. After deployment, verify `CONFIG GET appendonly +auto-aof-rewrite-percentage auto-aof-rewrite-min-size` and inspect subsequent +rewrite operation; avoid treating compaction as proof of erasure from older +backups or storage snapshots. + +PPR-2 stays **Active** and real-merchant onboarding stays **Blocked** until +the pending account, WordPress recovery/representative-site and automatic AOF +checks are accepted. +No public-launch readiness is claimed. diff --git a/docs/privacy/WORDPRESS_CONNECTOR_VALIDATION.md b/docs/privacy/WORDPRESS_CONNECTOR_VALIDATION.md new file mode 100644 index 0000000..e36d9b4 --- /dev/null +++ b/docs/privacy/WORDPRESS_CONNECTOR_VALIDATION.md @@ -0,0 +1,34 @@ +# Representative-site connector privacy validation + +Run this on an authorized disposable WordPress/WooCommerce site with synthetic +data. Do not use a merchant site or production credentials. Record outcomes and +webhook IDs without copying credential values into tickets or logs. + +1. Back up the disposable site and record its WordPress/WooCommerce/plugin + versions. Use an isolated WCTM test Store and test-only M7 registration token. +2. Connect the plugin. Confirm the WCTM-owned connector credential and webhook + secret are encrypted values in `wp_options`, autoload is disabled, and no + secret appears in the admin page or logs. Confirm the required WCTM hooks + have the exact expected name, topic, delivery URL and signing secret. +3. Add an unrelated WooCommerce webhook, including one with a similar name or + route on another origin. Confirm retry/setup does not alter it. +4. In the disposable site only, replace the two WordPress host-side key + constants used by the connector. Confirm saved WCTM secrets cannot decrypt, + retry fails closed, and neither plaintext fallback nor secret output occurs. +5. Restore the original keys. Confirm connection health returns without + credential regeneration. Repeat the lost-key state and verify that a fresh + M7 token alone does **not** make the unreadable stored signing secret + available. Record a fail-closed result. A separately reviewed operator + rotation/reconnect procedure is required if the original salts cannot be + restored; do not reuse an unreadable old secret or claim this path passes. +6. Exercise plugin uninstall on the disposable site while the old secret is + unreadable. Confirm only hooks matching the exact configured WCTM delivery + URL, topic and name are removed, unrelated hooks remain, and WCTM options + are deleted. Deactivation alone must retain configuration and hooks. +7. Confirm no unexpected external requests, secret-bearing logs or customer + data were produced. Restore/delete the disposable site according to its + test-data policy. + +Repository fixtures cover the salt-loss failure and exact uninstall selection. +The no-salts recovery procedure and this representative-site check remain +pilot blockers; neither has been run by this branch. diff --git a/scripts/ops/pilot-privacy.sh b/scripts/ops/pilot-privacy.sh index bebb991..2ea1df3 100755 --- a/scripts/ops/pilot-privacy.sh +++ b/scripts/ops/pilot-privacy.sh @@ -52,7 +52,7 @@ ledger_mode=$(stat -c '%a' "$ledger" 2>/dev/null || stat -f '%Lp' "$ledger") mode=$1 shift case "$mode" in - inspect|disconnect|export|erase|scrub|replay) ;; + inspect|disconnect|export|erase|scrub|replay|tenant-inspect|tenant-export|tenant-erase|identity-inspect|identity-export|identity-erase) ;; *) echo >&2 'privacy operation refused: invalid mode'; exit 64 ;; esac @@ -62,6 +62,7 @@ scripts/ops/privacy-export-artifacts.sh sweep "$privacy_directory" target_ids() { tenant_id='' store_id='' + user_id='' while (($#)); do case "$1" in --tenant-id) @@ -70,13 +71,21 @@ target_ids() { --store-id) [[ -z "$store_id" && $# -ge 2 ]] || return 1 store_id=$2; shift 2 ;; + --user-id) + [[ -z "$user_id" && $# -ge 2 ]] || return 1 + user_id=$2; shift 2 ;; *) shift ;; esac done - [[ -n "$tenant_id" && -n "$store_id" ]] + [[ -n "$tenant_id" ]] || return 1 + case "$mode" in + tenant-*) store_id=acc_tenant ;; + identity-*) [[ -n "$user_id" ]] || return 1; store_id=$user_id ;; + *) [[ -n "$store_id" ]] || return 1 ;; + esac } -if [[ "$mode" = export ]]; then +if [[ "$mode" = export || "$mode" = tenant-export || "$mode" = identity-export ]]; then [[ " $* " = *' --retain-for-delivery '* && " $* " != *' --output '* ]] || { echo >&2 'privacy export refused: explicit delivery retention is required; output path is managed' exit 64 @@ -88,7 +97,7 @@ fi if [[ " $* " = *' --execute '* ]]; then case "$mode" in - disconnect|scrub|erase|replay) + disconnect|scrub|erase|tenant-erase|identity-erase|replay) running=$(docker compose ps --status running -q backend telegram-bot) || { echo >&2 'privacy operation refused: application quiescence could not be verified' exit 1 @@ -116,19 +125,22 @@ run_privacy() { dist/privacy/pilot-data.cli.js "$1" "${@:2}" --ledger "$ledger" } -if [[ "$mode" = erase && " $* " = *' --execute '* ]]; then +if [[ ( "$mode" = erase || "$mode" = tenant-erase || "$mode" = identity-erase ) && + " $* " = *' --execute '* ]]; then [[ -n "${WCTM_OFFSITE_DESTINATION:-}" ]] || { echo >&2 'privacy erasure refused: verified off-site ledger destination is required' exit 66 } - run_privacy prepare-erasure "$@" + prepare_mode=prepare-erasure + [[ "$mode" = erase ]] || prepare_mode=${mode%-erase}-prepare-erasure + run_privacy "$prepare_mode" "$@" scripts/ops/archive-erasure-ledger.sh "$ledger" "${WCTM_OFFSITE_DESTINATION%/}/privacy-erasure-ledger" - run_privacy erase "$@" + run_privacy "$mode" "$@" target_ids "$@" scripts/ops/privacy-export-artifacts.sh purge "$privacy_directory" "$tenant_id" "$store_id" else run_privacy "$mode" "$@" - if [[ "$mode" = export ]]; then + if [[ "$mode" = export || "$mode" = tenant-export || "$mode" = identity-export ]]; then printf 'privacy export retained for delivery: %s\n' "$output" fi fi diff --git a/scripts/ops/privacy-export-artifacts.sh b/scripts/ops/privacy-export-artifacts.sh index 9256690..29adcb5 100755 --- a/scripts/ops/privacy-export-artifacts.sh +++ b/scripts/ops/privacy-export-artifacts.sh @@ -26,7 +26,8 @@ ledger_mode=$(stat -c '%a' "$ledger" 2>/dev/null || stat -f '%Lp' "$ledger") || [[ $ledger_mode = 600 ]] || fail scope_hash() { - [[ $1 =~ ^ten_[A-Za-z0-9_-]+$ && $2 =~ ^sto_[A-Za-z0-9_-]+$ ]] || fail + [[ $1 =~ ^ten_[A-Za-z0-9_-]+$ && + ( $2 =~ ^sto_[A-Za-z0-9_-]+$ || $2 = acc_tenant || $2 =~ ^usr_[A-Za-z0-9_-]+$ ) ]] || fail if command -v sha256sum >/dev/null 2>&1; then printf '%s\0%s' "$1" "$2" | sha256sum | awk '{print $1}' else diff --git a/scripts/ops/test-pilot-account-integration.sh b/scripts/ops/test-pilot-account-integration.sh new file mode 100755 index 0000000..2497ca4 --- /dev/null +++ b/scripts/ops/test-pilot-account-integration.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +image='postgres:16.15-alpine3.24@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685' +container="wctm-account-test-$$" +database="wctm_ppr2_account_test_$$" +workspace=$(mktemp -d) +cleanup() { + docker rm --force "$container" >/dev/null 2>&1 || true + rm -rf -- "$workspace" +} +trap cleanup EXIT HUP INT TERM + +docker run -d --pull=never --name "$container" --network bridge \ + -p 127.0.0.1::5432 -e POSTGRES_DB="$database" -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=fixture-account-test "$image" >/dev/null +ready=false +for _ in {1..60}; do + if docker exec "$container" pg_isready -U postgres -d "$database" >/dev/null 2>&1; then + ready=true + break + fi + sleep 1 +done +[[ $ready = true ]] +port=$(docker port "$container" 5432/tcp | sed -n 's/^127\.0\.0\.1:\([0-9][0-9]*\)$/\1/p') +[[ $port =~ ^[0-9]+$ ]] +url="postgresql://postgres:fixture-account-test@127.0.0.1:$port/$database" +if ! (cd backend && DATABASE_URL="$url" ../node_modules/.bin/prisma migrate deploy \ + --config prisma.config.ts) >"$workspace/migrate.log" 2>&1; then + echo >&2 'pilot account integration: migration setup failed' + exit 1 +fi +[[ $(docker exec "$container" psql -XAt -U postgres -d "$database" \ + -c "select count(*) from public._prisma_migrations where finished_at is not null") = 16 ]] +PPR2_ACCOUNT_TEST_DATABASE_URL="$url" PPR2_ACCOUNT_TEST_CONTAINER="$container" node backend/test/integration/pilot-account.integration.cjs \ + >"$workspace/account.log" 2>&1 || { + echo >&2 'pilot account integration: account checks failed' + sed 's/fixture-account-test/[redacted]/g' "$workspace/account.log" >&2 + exit 1 +} +grep -q 'pilot account integration: PASS' "$workspace/account.log" +! grep -q 'fixture-account-test' "$workspace/migrate.log" "$workspace/account.log" +cat "$workspace/account.log" diff --git a/scripts/ops/test-pilot-privacy.sh b/scripts/ops/test-pilot-privacy.sh index 5388191..bc1eec8 100755 --- a/scripts/ops/test-pilot-privacy.sh +++ b/scripts/ops/test-pilot-privacy.sh @@ -73,6 +73,7 @@ grep -q 'erasure archive: PASS' "$workspace/erase.log" [[ ! -e "$export_path" ]] ! grep -q 'fixture-only' "$workspace"/*.log +calls_before_missing=$(wc -l <"$workspace/docker.log" | tr -d ' ') unset WCTM_OFFSITE_DESTINATION if scripts/ops/pilot-privacy.sh --credential-file "$workspace/database.conf" \ --privacy-directory "$workspace/privacy" -- erase \ @@ -81,9 +82,43 @@ if scripts/ops/pilot-privacy.sh --credential-file "$workspace/database.conf" \ echo >&2 'test failed: erasure proceeded without off-site ledger destination' exit 1 fi -[[ $(wc -l <"$workspace/docker.log" | tr -d ' ') = 7 ]] +if [[ $(wc -l <"$workspace/docker.log" | tr -d ' ') != $((calls_before_missing + 1)) ]]; then + echo >&2 'test failed: missing off-site destination started a privacy container' + exit 1 +fi export WCTM_OFFSITE_DESTINATION="testremote:$workspace/remote" +scripts/ops/pilot-privacy.sh --credential-file "$workspace/database.conf" \ + --privacy-directory "$workspace/privacy" -- tenant-export \ + --tenant-id ten_fixture --name-sha256 "$(printf '%064d' 0)" \ + --retain-for-delivery >"$workspace/tenant-export.log" +tenant_export=$(sed -n 's/^privacy export retained for delivery: //p' "$workspace/tenant-export.log") +[[ -f "$tenant_export" ]] +scripts/ops/pilot-privacy.sh --credential-file "$workspace/database.conf" \ + --privacy-directory "$workspace/privacy" -- identity-export \ + --tenant-id ten_fixture --user-id usr_fixture --email-sha256 "$(printf '%064d' 0)" \ + --retain-for-delivery >"$workspace/identity-export.log" +identity_export=$(sed -n 's/^privacy export retained for delivery: //p' "$workspace/identity-export.log") +[[ -f "$identity_export" ]] +scripts/ops/pilot-privacy.sh --credential-file "$workspace/database.conf" \ + --privacy-directory "$workspace/privacy" -- tenant-erase \ + --tenant-id ten_fixture --name-sha256 "$(printf '%064d' 0)" --execute \ + >"$workspace/tenant-erase.log" +grep -q 'pilot-data.cli.js tenant-prepare-erasure' "$workspace/docker.log" || { + cat "$workspace/docker.log" >&2 + exit 1 +} +grep -q 'pilot-data.cli.js tenant-erase' "$workspace/docker.log" +[[ ! -e "$tenant_export" && -e "$identity_export" ]] +scripts/ops/pilot-privacy.sh --credential-file "$workspace/database.conf" \ + --privacy-directory "$workspace/privacy" -- identity-erase \ + --tenant-id ten_fixture --user-id usr_fixture --email-sha256 "$(printf '%064d' 0)" --execute \ + >"$workspace/identity-erase.log" +grep -q 'pilot-data.cli.js identity-prepare-erasure' "$workspace/docker.log" +grep -q 'pilot-data.cli.js identity-erase' "$workspace/docker.log" +[[ ! -e "$identity_export" ]] +! grep -q 'fixture-only' "$workspace"/*.log + if TEST_APP_RUNNING=true scripts/ops/pilot-privacy.sh \ --credential-file "$workspace/database.conf" \ --privacy-directory "$workspace/privacy" -- disconnect \ diff --git a/scripts/ops/test-privacy-export-artifacts.mjs b/scripts/ops/test-privacy-export-artifacts.mjs index 0fb2f5f..310ab96 100644 --- a/scripts/ops/test-privacy-export-artifacts.mjs +++ b/scripts/ops/test-privacy-export-artifacts.mjs @@ -67,6 +67,8 @@ try { await writeFile(path, 'must survive', { mode: 0o600 }); } const stale = await create(tenantA, storeA, 25); + const staleTenant = await create(tenantA, 'acc_tenant', 25); + const staleIdentity = await create(tenantA, 'usr_fixture_a', 25); const fresh = await create(tenantA, storeA); const expectedScope = createHash('sha256') .update(`${tenantA}\0${storeA}`) @@ -76,8 +78,10 @@ try { const oldMtime = await create(tenantA, storeB); const yesterday = new Date(Date.now() - 25 * 3600000); await utimes(oldMtime, yesterday, yesterday); - assert.match(run('sweep', directory), /removed=3$/); + assert.match(run('sweep', directory), /removed=5$/); assert.equal(await exists(stale), false); + assert.equal(await exists(staleTenant), false); + assert.equal(await exists(staleIdentity), false); assert.equal(await exists(otherScope), false); assert.equal(await exists(oldMtime), false); assert.equal(await exists(fresh), true); @@ -86,12 +90,19 @@ try { const untouchedTenant = await create(tenantB, storeB); const sameStoreOtherTenant = await create(tenantB, storeA); const sameTenantOtherStore = await create(tenantA, storeB); + const tenantExport = await create(tenantA, 'acc_tenant'); + const identityExport = await create(tenantA, 'usr_fixture_a'); assert.match(run('purge', directory, tenantA, storeA), /removed=2$/); assert.equal(await exists(fresh), false); assert.equal(await exists(delivered), false); assert.equal(await exists(untouchedTenant), true); assert.equal(await exists(sameStoreOtherTenant), true); assert.equal(await exists(sameTenantOtherStore), true); + assert.equal(await exists(tenantExport), true); + assert.equal(await exists(identityExport), true); + assert.match(run('purge', directory, tenantA, 'usr_fixture_a'), /removed=1$/); + assert.equal(await exists(identityExport), false); + assert.equal(await exists(tenantExport), true); for (const path of [ledger, key, unrelated, lookalike, backup, outsideFile]) { assert.equal(await exists(path), true); } diff --git a/wp-content/plugins/wc-telegram-connector.php b/wp-content/plugins/wc-telegram-connector.php index f844f3b..a172f59 100644 --- a/wp-content/plugins/wc-telegram-connector.php +++ b/wp-content/plugins/wc-telegram-connector.php @@ -71,21 +71,20 @@ function wc_telegram_connector_privacy_policy_content(): void function wc_telegram_connector_uninstall(): void { - $endpoint_key = wc_telegram_connector_read_option('webhook_endpoint_key'); + $delivery_url = wc_telegram_connector_delivery_url(); $secret = wc_telegram_connector_read_secret('webhook_secret'); - // Match the saved route and secret; never remove another integration's hook. - if (is_string($endpoint_key) && $endpoint_key !== '' && - is_string($secret) && $secret !== '') { + // Exact URL, topic and name identify this connector even if installation salts were lost. + // When the secret is readable, require it to match as an additional check. + if ($delivery_url !== '') { try { foreach (wc_telegram_connector_load_webhooks() as $webhook) { $data = $webhook->get_data(); $topic = is_array($data) ? ($data['topic'] ?? null) : null; - $route = is_array($data) && is_string($data['delivery_url'] ?? null) - ? wp_parse_url($data['delivery_url'], PHP_URL_PATH) : null; if (is_string($topic) && in_array($topic, wc_telegram_connector_required_topics(), true) && ($data['name'] ?? null) === wc_telegram_connector_webhook_name($topic) && - $route === '/api/webhooks/woocommerce/' . $endpoint_key && - is_string($data['secret'] ?? null) && hash_equals($secret, $data['secret'])) { + ($data['delivery_url'] ?? null) === $delivery_url && + ($secret === '' || + (is_string($data['secret'] ?? null) && hash_equals($secret, $data['secret'])))) { $webhook->delete(true); } } @@ -280,7 +279,7 @@ function wc_telegram_connector_render_admin_page(): void -

+