fix: accept audit harness tenant rehome CLI aliases

This commit is contained in:
Ochenstarik 2026-09-26 17:02:16 +07:00
parent c73bb3b807
commit d52ecf3261
4 changed files with 42 additions and 13 deletions

View file

@ -2,13 +2,15 @@
`scripts/rehome-tenant.mjs` переносит все строки из одного tenant ID в другой в схеме `public`. Скрипт рассчитан на остановленное приложение и запускается только привилегированной ролью БД, способной обходить RLS и изменять таблицы. Строки пароля или подключения в отчёты не выводятся.
Сначала на восстановленной копии базы выполните dry-run:
CLI по умолчанию выполняет только read-only dry-run; `--dry-run` можно указать явно. Совместимые имена `--from`/`--to` и `--json` принимаются для rehearsal-обвязок; вывод всегда JSON. Сначала на восстановленной копии базы выполните dry-run:
```powershell
$env:DATABASE_REHOME_URL = 'postgresql://…'
node scripts/rehome-tenant.mjs --dry-run --source <ULID> --target <ULID>
$env:DATABASE_ADMIN_URL = 'postgresql://…'
node scripts/rehome-tenant.mjs --from <ULID> --to <ULID> --dry-run --json
```
Для совместимости со старыми локальными инструкциями также принимаются `DATABASE_REHOME_URL` и `DATABASE_MIGRATION_URL` как fallback. `--apply` по-прежнему требует явный флаг, actor и проверенный `--backup-evidence`; aliases и default dry-run не разрешают запись сами по себе.
Dry-run открывает транзакцию `READ ONLY` и выводит только идентификаторы переноса и количество строк по таблицам. Он не блокирует записи; поэтому результат является снимком для планирования, а не гарантией актуальности на момент apply. На копии и в согласованное окно с остановленным приложением повторите dry-run непосредственно перед переносом.
## Обязательное подтверждение восстановления

View file

@ -35,7 +35,16 @@ export interface RehomeReport {
payrollLedger?: Array<{ currency: string; amountMinor: string }>;
}
export function parseTenantRehomeArgs(args: string[]): Record<string, string>;
export interface ParsedTenantRehomeArgs {
mode: 'dry-run' | 'apply';
source: string;
target: string;
actor?: string;
backup_evidence?: string;
json?: true;
}
export function parseTenantRehomeArgs(args: string[]): ParsedTenantRehomeArgs;
export function validateBackupEvidenceObject(
evidence: unknown,
): BackupEvidenceReport;

View file

@ -47,11 +47,21 @@ export function parseTenantRehomeArgs(args) {
if (key === '--dry-run' || key === '--apply') {
if (result.mode) throw new SafeOperationError('Choose exactly one of --dry-run or --apply.');
result.mode = key.slice(2);
} else if (['--source', '--target', '--actor', '--backup-evidence'].includes(key)) {
} else if (key === '--json') {
if (result.json) throw new SafeOperationError('Duplicate option --json.');
result.json = true;
} else if (['--source', '--from', '--target', '--to', '--actor', '--backup-evidence'].includes(key)) {
const value = args[index + 1];
if (!value || value.startsWith('--')) throw new SafeOperationError(`Missing value for ${key}.`);
const name = key.slice(2).replaceAll('-', '_');
if (result[name]) throw new SafeOperationError(`Duplicate option ${key}.`);
const name = ({
'--source': 'source',
'--from': 'source',
'--target': 'target',
'--to': 'target',
'--actor': 'actor',
'--backup-evidence': 'backup_evidence',
})[key];
if (result[name]) throw new SafeOperationError(`Duplicate option for ${name.replaceAll('_', '-')}.`);
result[name] = value;
index += 1;
} else {
@ -59,7 +69,7 @@ export function parseTenantRehomeArgs(args) {
}
}
if (!result.mode) throw new SafeOperationError('Specify --dry-run or --apply.');
result.mode ??= 'dry-run';
for (const name of ['source', 'target']) {
if (!ULID_RE.test(result[name] ?? '')) throw new SafeOperationError(`--${name} must be a valid ULID.`);
}
@ -362,8 +372,8 @@ export async function rehomeTenant(client, options) {
function parseCli(args) {
const options = parseTenantRehomeArgs(args);
const connectionString = process.env.DATABASE_REHOME_URL ?? process.env.DATABASE_MIGRATION_URL;
if (!connectionString) throw new SafeOperationError('Set DATABASE_REHOME_URL or DATABASE_MIGRATION_URL.');
const connectionString = process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_REHOME_URL ?? process.env.DATABASE_MIGRATION_URL;
if (!connectionString) throw new SafeOperationError('Set DATABASE_ADMIN_URL, DATABASE_REHOME_URL, or DATABASE_MIGRATION_URL.');
return { ...options, connectionString };
}

View file

@ -10,10 +10,18 @@ const actor = '01J00000000000000000000003';
const sha256 = 'a'.repeat(64);
describe('tenant rehome argument and evidence validation', () => {
it('requires explicit apply mode and a backup evidence path', () => {
expect(() => parseTenantRehomeArgs(['--source', source, '--target', target])).toThrow('--dry-run or --apply');
expect(() => parseTenantRehomeArgs(['--apply', '--source', source, '--target', target, '--actor', actor]))
it('defaults to read-only dry-run and accepts the published CLI aliases', () => {
expect(parseTenantRehomeArgs(['--from', source, '--to', target, '--json'])).toEqual({
mode: 'dry-run', source, target, json: true,
});
expect(parseTenantRehomeArgs(['--dry-run', '--source', source, '--target', target]).mode).toBe('dry-run');
});
it('requires backup evidence for every apply invocation', () => {
expect(() => parseTenantRehomeArgs(['--apply', '--from', source, '--to', target, '--actor', actor]))
.toThrow('--backup-evidence is required');
expect(() => parseTenantRehomeArgs(['--from', source, '--source', source, '--to', target]))
.toThrow('Duplicate option for source.');
});
it('accepts only evidence with encrypted off-host backup and a matching successful restore', () => {