Files
accounted/docs/security/authorization-policy.md
T
Mattsson 3a1b842e4a 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
2026-08-19 12:04:24 +02:00

212 lines
11 KiB
Markdown

# Authorization Policy: Privileged RPCs
This document records the access-control model of database functions that run
with elevated privileges (SECURITY DEFINER) and can mutate or destroy tenant
data. It exists so the authorization contract of each function is reviewable
without reading migration SQL, and so changes to that contract are deliberate.
No machine-readable authorization matrix (authorization-matrix.csv or similar)
exists in this repository yet; this document is currently the authoritative
inventory. If such a matrix is introduced, every function listed here must get
a row in it.
## SIE bulk-delete pair: `replace_sie_import` and `undo_sie_import`
Defined in:
- `supabase/migrations/20260727120000_replace_sie_import_authorize_actor.sql`
- `supabase/migrations/20260727121000_undo_sie_import_caller_guard.sql`
Both functions hard-delete a completed SIE import's verifikationer so a fiscal
period can be re-imported (replace) or restored to its pre-import state (undo).
To do that they call `set_config('gnubok.allow_delete', 'true', true)`, which
disarms the BFL immutability and 7-year retention triggers for the transaction.
That makes them the two most dangerous entry points in the schema, and their
authorization model is correspondingly strict.
### Why SECURITY DEFINER
The functions must bypass RLS and the enforcement triggers to perform the
sanctioned bulk delete atomically. Running as the function owner is what allows
the `gnubok.allow_delete` escape hatch to work; the compensating control is the
in-function authorization gate described below, which runs before any mutation.
### Actor resolution
Each function takes `p_user_id uuid DEFAULT NULL` and resolves the acting user
as follows:
- If `auth.role() = 'service_role'`: the actor is
`COALESCE(p_user_id, auth.uid())`. The service-role client is the cookieless
server client (`rpcClientForBulkDelete` in `lib/import/sie-import.ts`), used
to escape the authenticator role's 8s statement timeout. Inside it
`auth.uid()` is NULL, so the application passes the human user it already
authenticated as `p_user_id`.
- Every other caller is pinned to its own `auth.uid()`, regardless of what it
passes as `p_user_id`. This closes the impersonation hole where an
authenticated PostgREST caller could pass an owner's UUID and walk through
the gate (the pre-fix behavior of `undo_sie_import`).
This is the same shape as `list_invoice_delivery_summaries_for_service`
(migration `20260727100000`); treat it as the house pattern for any
SECURITY DEFINER function that must accept a caller-asserted actor.
### Authorization gate
The resolved actor must hold the `owner` or `admin` role in
`company_members` for `p_company_id`. The gate fails closed:
- An anon or unauthenticated caller has no membership row, `v_caller_role`
resolves NULL, and the function raises before any mutation and before
`gnubok.allow_delete` is ever set.
- The raise uses `ERRCODE 42501` (insufficient_privilege) so application
routes can map it to a 403.
### Grants
Supabase's default privileges grant EXECUTE on every new public function to
PUBLIC and to anon/authenticated/service_role, and CREATE OR REPLACE
re-introduces those grants. Both migrations therefore end with an explicit:
- `REVOKE EXECUTE ... FROM PUBLIC, anon` (revoking anon alone is not enough;
anon is a member of PUBLIC and would stay callable through the PUBLIC grant)
- `GRANT EXECUTE ... TO authenticated, service_role`
`authenticated` retains EXECUTE on purpose: on self-hosted installs without a
`SUPABASE_SERVICE_ROLE_KEY`, the application falls back to running these RPCs
on the caller's own session client. The in-function owner/admin gate scopes
such callers to companies they actually administer, so this is tenant-scoped
access, not a privilege escalation.
### Tenant isolation contract
Every mutation inside both functions filters on `p_company_id`, and the gate
guarantees the actor administers that company. A caller can therefore never
reach another tenant's data: the pre-fix `replace_sie_import` (no gate,
EXECUTE held by anon) was a cross-tenant data-destruction primitive, and the
gate plus the REVOKEs are what closed it.
### Verification
The contract is pinned by pg-real tests (run with `npm run test:pg`):
- `lib/import/__tests__/sie-import.replace.pg.test.ts`
- `lib/import/__tests__/undo-sie-import-actor.pg.test.ts` (spoofed
`p_user_id` rejection, the 42501 errcode, and the tightened grants)
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.