feat: add safe owner-only migration reset (#1682)
* feat: add safe company migration reset * fix: harden company reset eligibility * fix: close company reset compliance gaps * test: fix migration reset pg-real probes * fix: preserve migration archive access * docs: explain migration numbering continuity * fix: block reset with VAT workflow state * fix: block externally staged reset data * fix: address migration reset review findings * fix: clear stale migration archive estimate * fix: retry migration archive estimates
This commit is contained in:
@@ -96,3 +96,116 @@ The contract is pinned by pg-real tests (run with `npm run test:pg`):
|
||||
|
||||
Any change to either function's signature, gate, or grants must update these
|
||||
tests and this document in the same change.
|
||||
|
||||
## Company migration reset: `get_company_migration_reset_eligibility` and `reset_company_for_migration`
|
||||
|
||||
Defined in:
|
||||
|
||||
- `supabase/migrations/20260818084050_company_migration_reset.sql`
|
||||
- `supabase/migrations/20260818141018_harden_company_migration_reset_eligibility.sql`
|
||||
- `supabase/migrations/20260818143004_close_migration_reset_archive_gaps.sql`
|
||||
- `supabase/migrations/20260818224000_block_vat_state_migration_reset.sql`
|
||||
- `supabase/migrations/20260818231500_block_external_filing_staging_state.sql`
|
||||
|
||||
These functions support the owner-only archive-and-replace recovery flow for a
|
||||
failed migration. The execution function archives the source company and
|
||||
creates a clean replacement. It does not delete or rewrite source accounting
|
||||
records and never sets a retention-trigger bypass.
|
||||
|
||||
### Why SECURITY DEFINER
|
||||
|
||||
The operation must atomically create a company, copy memberships and settings,
|
||||
move operational provider consent and subscription state, preserve the
|
||||
original entitlement expiry, move inbound document routing, switch active
|
||||
preferences and pending invitations, and insert immutable audit records.
|
||||
Authenticated callers do not have direct write policies for all of those
|
||||
tables. SECURITY DEFINER makes the single transaction possible while the
|
||||
in-function gate below keeps it tenant-scoped.
|
||||
|
||||
The internal `company_migration_reset_snapshot` function is also SECURITY
|
||||
DEFINER so both preview and execution use one fail-closed eligibility
|
||||
implementation. It has no EXECUTE grant for authenticated callers and is only
|
||||
reached through the two guarded entry points.
|
||||
|
||||
The source-mutation trigger functions are SECURITY DEFINER only so their audit
|
||||
lookup cannot be hidden by RLS from an invitation acceptor or a delayed
|
||||
request. They accept no caller-controlled identifiers, expose no rows, and can
|
||||
only return the row unchanged or raise a generic exception.
|
||||
|
||||
### Actor and tenant gate
|
||||
|
||||
Neither entry point accepts an actor parameter. The actor is always
|
||||
`auth.uid()`, so a cookie-session caller cannot assert another user's identity
|
||||
and a service-role call with no user identity cannot pass the gate.
|
||||
|
||||
The actor must have a `company_members.role = 'owner'` row for the requested
|
||||
company. A non-member receives `COMPANY_RESET_NOT_FOUND`; a member with any
|
||||
other role receives `COMPANY_RESET_FORBIDDEN`. The HTTP route also requires the
|
||||
URL company ID to equal the active company resolved by `withRouteContext`.
|
||||
|
||||
Execution locks the active source company row and repeats the exact-name,
|
||||
reason, attestation, and eligibility checks inside the transaction. Any
|
||||
failure returns a structured result before the first write. Unexpected
|
||||
database failures roll the transaction back.
|
||||
|
||||
### Eligibility and retention contract
|
||||
|
||||
Self-service is restricted to active companies created within 30 days. It is
|
||||
blocked by the company lock date, a closed or locked period, any journal entry
|
||||
in any status or source, any voucher-sequence row, any customer or supplier
|
||||
invoice, an incomplete import, a known authority submission, or persisted VAT
|
||||
declaration workflow state. The VAT state closes the historical direct-lock
|
||||
path where the signing lock was stored in `extension_data` without a matching
|
||||
audit row. This prevents a replacement for the same legal
|
||||
entity from restarting voucher numbering after a draft, migrated voucher, or
|
||||
sequence state already exists. Live integrations, bank connections, recurring
|
||||
invoice schedules, pending accrual installments, and non-terminal background
|
||||
jobs also block because they can write after the interactive session moves.
|
||||
The owner must attest that no filing was made outside Accounted and acknowledge
|
||||
that the source remains retained. Accounted cannot independently observe every
|
||||
filing made directly at an authority, so uncertainty must fail closed and be
|
||||
escalated rather than inferred from an empty internal audit log.
|
||||
|
||||
The source company's imports, transactions, periods, documents, journal
|
||||
entries, and voucher sequences are not mutated. The replacement starts with no
|
||||
such rows. An append-only `company_migration_resets` row captures the reason,
|
||||
confirmations, and source counts, and ordinary immutable `audit_log` rows link
|
||||
the source and replacement company IDs. The active inbound email address and
|
||||
custom inbound domain move to the replacement; already received documents do
|
||||
not move. A database trigger also rejects new memberships on the archived
|
||||
source, closing the race with an invitation acceptance that began before the
|
||||
reset transaction. Database mutation guards make the retained source's
|
||||
imports, transactions, periods, documents, journal rows, invoices, and voucher
|
||||
sequences write-closed after the audit row is committed. Filing-adjacent payroll,
|
||||
AGI, annual report, ROT/RUT, bank-connection, authority-audit, and newly arriving
|
||||
VAT workflow rows receive the same archive guard. Team membership sync
|
||||
selects active companies only, so an archived source cannot block a later team
|
||||
member from reaching the replacement.
|
||||
|
||||
### Grants
|
||||
|
||||
The migration explicitly revokes EXECUTE from PUBLIC and anon, grants the two
|
||||
entry points only to authenticated, and revokes authenticated access to the
|
||||
internal snapshot. The audit table grants authenticated SELECT only through an
|
||||
RLS policy based on active membership of the replacement company. It has no
|
||||
user DML policy, and UPDATE, DELETE, and TRUNCATE are blocked by triggers even
|
||||
for elevated callers. An owner-only archive endpoint follows that immutable
|
||||
replacement-to-source link, rechecks current ownership of the active
|
||||
replacement, requires the source archive marker with a service-role client,
|
||||
and exports without activating or mutating the source. Authorization does not
|
||||
depend on retained-source membership because normal team removal and account
|
||||
anonymization can legitimately change those rows; the immutable reset link
|
||||
keeps the statutory archive reachable to the legal entity's current owners.
|
||||
Support inspection follows reset chains with the service-side read-only query
|
||||
in the runbook.
|
||||
|
||||
### Verification
|
||||
|
||||
The contract is pinned by:
|
||||
|
||||
- `tests/pg/company-migration-reset.pg.test.ts`
|
||||
- `app/api/company/[id]/migration-reset/__tests__/route.test.ts`
|
||||
|
||||
Any change to the owner gate, eligibility boundary, source-retention
|
||||
invariant, grants, or audit immutability must update those tests and this
|
||||
document in the same change.
|
||||
|
||||
Reference in New Issue
Block a user