Delete features (#218)
* Implement company and account deletion features - Add event types for company and account deletion to CoreEvent. - Enhance Supabase middleware to handle company context resolution and cookie management for archived companies. - Create API routes for deleting accounts and companies, including necessary validations and event emissions. - Implement tests for account and company deletion endpoints to ensure proper functionality and error handling. - Add retention notice component to inform users about bookkeeping data retention during destructive actions. - Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws. * feat: enhance account deletion process and update user notifications * Add service client for onboarding completion check and update escape hatch visibility * Enhance invite flow and email handling for company members * Refactor company context and RLS policies for active company isolation - Update `switchCompany` to remove unnecessary revalidation as client handles navigation. - Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships. - Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility. - Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership. - Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization. - Implement `CompanyTabSync` component for real-time active company enforcement across tabs. - Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`. * feat: implement viewer role enforcement for write permissions - Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company. - Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions. - Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers. - Created tests to verify the behavior of the viewer role and write permissions. - Added database migration to enforce read-only access for viewers at the database level.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
-- Filter archived companies out of user_company_ids() helper.
|
||||
--
|
||||
-- Soft-deleted (archived) companies must disappear from the user's UI while
|
||||
-- the underlying bookkeeping data stays intact for BFL 7 kap. 2§ 7-year
|
||||
-- retention. Routing this filter through user_company_ids() makes every
|
||||
-- company-scoped RLS policy honor it automatically.
|
||||
--
|
||||
-- Also adds companies.archived_by (who archived it) and an index on
|
||||
-- archived_at IS NULL to speed up the common "list my active companies"
|
||||
-- picker query.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.user_company_ids()
|
||||
RETURNS SETOF uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT cm.company_id
|
||||
FROM public.company_members cm
|
||||
JOIN public.companies c ON c.id = cm.company_id
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND c.archived_at IS NULL;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.user_company_ids() TO authenticated;
|
||||
|
||||
-- Track who archived a company. ON DELETE SET NULL so a future hard-purge
|
||||
-- (7 years out) of the archiving user doesn't break the tombstone row.
|
||||
ALTER TABLE public.companies
|
||||
ADD COLUMN archived_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- Partial index: picker queries almost always filter to active companies.
|
||||
CREATE INDEX companies_active_idx
|
||||
ON public.companies (id)
|
||||
WHERE archived_at IS NULL;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Add tombstone columns to profiles for account anonymization.
|
||||
--
|
||||
-- When a user deletes their account we must keep the auth.users row alive
|
||||
-- (otherwise ON DELETE CASCADE from companies.created_by would destroy
|
||||
-- retained bookkeeping data). Instead we anonymize the profile row: strip
|
||||
-- PII, stamp deleted_at + anonymized_at, and the UI falls back to
|
||||
-- "Borttagen användare" wherever the profile is displayed.
|
||||
--
|
||||
-- email and full_name are already nullable per migration 20240101000001.
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN deleted_at timestamptz,
|
||||
ADD COLUMN anonymized_at timestamptz;
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Replace hard-delete with anonymization: retain auth.users as a tombstone.
|
||||
--
|
||||
-- Why: BFL 7 kap. 2§ requires räkenskapsinformation to be preserved for 7
|
||||
-- years. companies.created_by references auth.users ON DELETE CASCADE, so
|
||||
-- hard-deleting the user would wipe retained verifikationer. Instead we:
|
||||
-- 1) verify zero owned, non-archived companies
|
||||
-- 2) remove the user from all company/team memberships
|
||||
-- 3) delete per-user operational state (prefs, api keys)
|
||||
-- 4) anonymize the profile
|
||||
-- The API route caller is responsible for banning the auth.users row via
|
||||
-- the admin API afterwards (SQL can't reach supabase.auth.admin).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.anonymize_user_account(target_user_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
blocker_count int;
|
||||
BEGIN
|
||||
IF auth.uid() IS DISTINCT FROM target_user_id THEN
|
||||
RAISE EXCEPTION 'Can only delete your own account';
|
||||
END IF;
|
||||
|
||||
-- Precondition: zero owned, non-archived companies
|
||||
SELECT count(*) INTO blocker_count
|
||||
FROM public.company_members cm
|
||||
JOIN public.companies c ON c.id = cm.company_id
|
||||
WHERE cm.user_id = target_user_id
|
||||
AND cm.role = 'owner'
|
||||
AND c.archived_at IS NULL;
|
||||
|
||||
IF blocker_count > 0 THEN
|
||||
RAISE EXCEPTION 'Cannot delete account: user still owns % active compan(y/ies)', blocker_count
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- Remove memberships
|
||||
DELETE FROM public.company_members WHERE user_id = target_user_id;
|
||||
DELETE FROM public.team_members WHERE user_id = target_user_id;
|
||||
|
||||
-- Remove per-user operational state
|
||||
DELETE FROM public.user_preferences WHERE user_id = target_user_id;
|
||||
DELETE FROM public.api_keys WHERE user_id = target_user_id;
|
||||
|
||||
-- Anonymize profile tombstone
|
||||
UPDATE public.profiles
|
||||
SET email = NULL,
|
||||
full_name = NULL,
|
||||
avatar_url = NULL,
|
||||
deleted_at = now(),
|
||||
anonymized_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = target_user_id;
|
||||
|
||||
-- NOTE: auth.users row, companies.created_by FK, and audit_log.user_id
|
||||
-- are intentionally left intact. They now reference a banned, anonymized
|
||||
-- tombstone row that exists solely to keep BFL-retained data valid.
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.anonymize_user_account(uuid) TO authenticated;
|
||||
|
||||
-- The old delete_user_account RPC hard-deleted auth.users, which cascaded
|
||||
-- away retained verifikationer via companies.created_by. It is incompatible
|
||||
-- with the retention model and must be removed.
|
||||
DROP FUNCTION IF EXISTS public.delete_user_account(uuid);
|
||||
@@ -0,0 +1,664 @@
|
||||
-- =============================================================================
|
||||
-- Active-company tenant isolation in RLS
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Before this migration, company-scoped RLS policies allowed access to any
|
||||
-- company the user was a MEMBER of. This meant that a user with multiple
|
||||
-- memberships could see cross-company data leak into dropdowns and lists
|
||||
-- via direct browser-client queries (e.g. /invoices/new customer dropdown),
|
||||
-- because RLS only checked membership, not which company was active.
|
||||
--
|
||||
-- This migration replaces `company_id IN (SELECT user_company_ids())` with
|
||||
-- `company_id = current_active_company_id()` on every company-scoped policy.
|
||||
-- The helper function reads `user_preferences.active_company_id` with a
|
||||
-- fallback to the user's first non-archived membership. It is STABLE +
|
||||
-- SECURITY DEFINER so the result is cached per query plan and it bypasses
|
||||
-- RLS on the user_preferences + company_members tables it reads from.
|
||||
--
|
||||
-- Tables NOT updated (they stay on user_company_ids() because they must be
|
||||
-- visible across all companies the user is a member of, or use a different
|
||||
-- auth model entirely):
|
||||
-- companies, company_members, user_preferences, profiles
|
||||
-- teams, team_members, team_invitations, company_invitations
|
||||
-- provider_consent_tokens, provider_otc (consent-id scoped via team_members)
|
||||
-- bankid_identities (user-scoped, not company-scoped)
|
||||
-- storage.objects/sie-files bucket policies (different auth surface)
|
||||
--
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. current_active_company_id() helper
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.current_active_company_id()
|
||||
RETURNS uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
-- Active preference, validated against non-archived membership
|
||||
(
|
||||
SELECT up.active_company_id
|
||||
FROM public.user_preferences up
|
||||
JOIN public.company_members cm
|
||||
ON cm.user_id = up.user_id AND cm.company_id = up.active_company_id
|
||||
JOIN public.companies c
|
||||
ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
WHERE up.user_id = auth.uid()
|
||||
),
|
||||
-- Fallback: first non-archived membership by created_at
|
||||
(
|
||||
SELECT cm.company_id
|
||||
FROM public.company_members cm
|
||||
JOIN public.companies c
|
||||
ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
WHERE cm.user_id = auth.uid()
|
||||
ORDER BY cm.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.current_active_company_id() TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.current_active_company_id() IS
|
||||
'Returns the active company id for auth.uid(), reading user_preferences.active_company_id '
|
||||
'with validation against non-archived company_members, and falling back to the user''s '
|
||||
'first non-archived membership. Used by RLS policies to enforce single-active-company '
|
||||
'tenant isolation. STABLE so the result is cached per query plan.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Drop old policies on company-scoped data tables
|
||||
-- =============================================================================
|
||||
-- We enumerate every company-scoped data table that currently uses
|
||||
-- user_company_ids() and drop all of its policies so we can recreate them
|
||||
-- below with current_active_company_id().
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
pol RECORD;
|
||||
affected_tables TEXT[] := ARRAY[
|
||||
'company_settings', 'chart_of_accounts', 'fiscal_periods',
|
||||
'journal_entries', 'journal_entry_lines',
|
||||
'account_balances', 'voucher_sequences',
|
||||
'transactions', 'bank_connections', 'bank_file_imports',
|
||||
'customers', 'invoices', 'invoice_items',
|
||||
'invoice_reminders', 'invoice_payments',
|
||||
'suppliers', 'supplier_invoices', 'supplier_invoice_items',
|
||||
'supplier_invoice_payments',
|
||||
'receipts', 'receipt_line_items',
|
||||
'document_attachments', 'invoice_inbox_items',
|
||||
'mapping_rules', 'categorization_templates',
|
||||
'deadlines', 'cost_centers', 'projects',
|
||||
'salary_payments', 'mileage_entries',
|
||||
'sie_imports', 'sie_account_mappings',
|
||||
'calendar_feeds', 'chat_sessions', 'chat_messages',
|
||||
'ai_usage_tracking', 'extension_data', 'api_keys',
|
||||
'skatteverket_tokens', 'pending_operations', 'payment_match_log',
|
||||
'event_log', 'notification_log', 'audit_log',
|
||||
'provider_consents', 'voucher_gap_explanations',
|
||||
'automation_webhooks', 'email_connections'
|
||||
];
|
||||
tbl TEXT;
|
||||
BEGIN
|
||||
FOREACH tbl IN ARRAY affected_tables
|
||||
LOOP
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = tbl
|
||||
) THEN
|
||||
FOR pol IN
|
||||
SELECT policyname FROM pg_policies
|
||||
WHERE schemaname = 'public' AND tablename = tbl
|
||||
LOOP
|
||||
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', pol.policyname, tbl);
|
||||
END LOOP;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Recreate policies using current_active_company_id()
|
||||
-- =============================================================================
|
||||
-- Same shape as the previous user_company_ids()-based policies, just
|
||||
-- swapping the membership IN clause for an equality check against the
|
||||
-- single active company.
|
||||
|
||||
-- company_settings
|
||||
CREATE POLICY "company_settings_select" ON public.company_settings
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "company_settings_insert" ON public.company_settings
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "company_settings_update" ON public.company_settings
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "company_settings_delete" ON public.company_settings
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- chart_of_accounts
|
||||
CREATE POLICY "chart_of_accounts_select" ON public.chart_of_accounts
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chart_of_accounts_insert" ON public.chart_of_accounts
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chart_of_accounts_update" ON public.chart_of_accounts
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chart_of_accounts_delete" ON public.chart_of_accounts
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- fiscal_periods
|
||||
CREATE POLICY "fiscal_periods_select" ON public.fiscal_periods
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "fiscal_periods_insert" ON public.fiscal_periods
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "fiscal_periods_update" ON public.fiscal_periods
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "fiscal_periods_delete" ON public.fiscal_periods
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- journal_entries
|
||||
CREATE POLICY "journal_entries_select" ON public.journal_entries
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "journal_entries_insert" ON public.journal_entries
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "journal_entries_update" ON public.journal_entries
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "journal_entries_delete" ON public.journal_entries
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- journal_entry_lines (child: join to parent journal_entries)
|
||||
CREATE POLICY "journal_entry_lines_select" ON public.journal_entry_lines
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = journal_entry_lines.journal_entry_id
|
||||
AND je.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "journal_entry_lines_insert" ON public.journal_entry_lines
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = journal_entry_lines.journal_entry_id
|
||||
AND je.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "journal_entry_lines_update" ON public.journal_entry_lines
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = journal_entry_lines.journal_entry_id
|
||||
AND je.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "journal_entry_lines_delete" ON public.journal_entry_lines
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = journal_entry_lines.journal_entry_id
|
||||
AND je.company_id = public.current_active_company_id())
|
||||
);
|
||||
|
||||
-- account_balances (may not exist on fresh DBs)
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'account_balances'
|
||||
) THEN
|
||||
EXECUTE 'CREATE POLICY "account_balances_select" ON public.account_balances FOR SELECT USING (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "account_balances_insert" ON public.account_balances FOR INSERT WITH CHECK (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "account_balances_update" ON public.account_balances FOR UPDATE USING (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "account_balances_delete" ON public.account_balances FOR DELETE USING (company_id = public.current_active_company_id())';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- voucher_sequences
|
||||
CREATE POLICY "voucher_sequences_select" ON public.voucher_sequences
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "voucher_sequences_insert" ON public.voucher_sequences
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "voucher_sequences_update" ON public.voucher_sequences
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- transactions
|
||||
CREATE POLICY "transactions_select" ON public.transactions
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "transactions_insert" ON public.transactions
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "transactions_update" ON public.transactions
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "transactions_delete" ON public.transactions
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- bank_connections
|
||||
CREATE POLICY "bank_connections_select" ON public.bank_connections
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "bank_connections_insert" ON public.bank_connections
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "bank_connections_update" ON public.bank_connections
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "bank_connections_delete" ON public.bank_connections
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- bank_file_imports
|
||||
CREATE POLICY "bank_file_imports_select" ON public.bank_file_imports
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "bank_file_imports_insert" ON public.bank_file_imports
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "bank_file_imports_update" ON public.bank_file_imports
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "bank_file_imports_delete" ON public.bank_file_imports
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- customers
|
||||
CREATE POLICY "customers_select" ON public.customers
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "customers_insert" ON public.customers
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "customers_update" ON public.customers
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "customers_delete" ON public.customers
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- invoices
|
||||
CREATE POLICY "invoices_select" ON public.invoices
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoices_insert" ON public.invoices
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoices_update" ON public.invoices
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoices_delete" ON public.invoices
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- invoice_items (child: join to parent invoices)
|
||||
CREATE POLICY "invoice_items_select" ON public.invoice_items
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_items.invoice_id
|
||||
AND i.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "invoice_items_insert" ON public.invoice_items
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_items.invoice_id
|
||||
AND i.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "invoice_items_update" ON public.invoice_items
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_items.invoice_id
|
||||
AND i.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "invoice_items_delete" ON public.invoice_items
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_items.invoice_id
|
||||
AND i.company_id = public.current_active_company_id())
|
||||
);
|
||||
|
||||
-- invoice_reminders
|
||||
CREATE POLICY "invoice_reminders_select" ON public.invoice_reminders
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_reminders_insert" ON public.invoice_reminders
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_reminders_update" ON public.invoice_reminders
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_reminders_delete" ON public.invoice_reminders
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- invoice_payments
|
||||
CREATE POLICY "invoice_payments_select" ON public.invoice_payments
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_payments_insert" ON public.invoice_payments
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_payments_update" ON public.invoice_payments
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_payments_delete" ON public.invoice_payments
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- suppliers
|
||||
CREATE POLICY "suppliers_select" ON public.suppliers
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "suppliers_insert" ON public.suppliers
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "suppliers_update" ON public.suppliers
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "suppliers_delete" ON public.suppliers
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- supplier_invoices
|
||||
CREATE POLICY "supplier_invoices_select" ON public.supplier_invoices
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "supplier_invoices_insert" ON public.supplier_invoices
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "supplier_invoices_update" ON public.supplier_invoices
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "supplier_invoices_delete" ON public.supplier_invoices
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- supplier_invoice_items (child: join to parent supplier_invoices)
|
||||
CREATE POLICY "supplier_invoice_items_select" ON public.supplier_invoice_items
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
||||
AND si.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "supplier_invoice_items_insert" ON public.supplier_invoice_items
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
||||
AND si.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "supplier_invoice_items_update" ON public.supplier_invoice_items
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
||||
AND si.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "supplier_invoice_items_delete" ON public.supplier_invoice_items
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
||||
AND si.company_id = public.current_active_company_id())
|
||||
);
|
||||
|
||||
-- supplier_invoice_payments
|
||||
CREATE POLICY "supplier_invoice_payments_select" ON public.supplier_invoice_payments
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "supplier_invoice_payments_insert" ON public.supplier_invoice_payments
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "supplier_invoice_payments_update" ON public.supplier_invoice_payments
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "supplier_invoice_payments_delete" ON public.supplier_invoice_payments
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- receipts
|
||||
CREATE POLICY "receipts_select" ON public.receipts
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "receipts_insert" ON public.receipts
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "receipts_update" ON public.receipts
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "receipts_delete" ON public.receipts
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- receipt_line_items (child: join to parent receipts)
|
||||
CREATE POLICY "receipt_line_items_select" ON public.receipt_line_items
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.receipts r
|
||||
WHERE r.id = receipt_line_items.receipt_id
|
||||
AND r.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "receipt_line_items_insert" ON public.receipt_line_items
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.receipts r
|
||||
WHERE r.id = receipt_line_items.receipt_id
|
||||
AND r.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "receipt_line_items_update" ON public.receipt_line_items
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.receipts r
|
||||
WHERE r.id = receipt_line_items.receipt_id
|
||||
AND r.company_id = public.current_active_company_id())
|
||||
);
|
||||
CREATE POLICY "receipt_line_items_delete" ON public.receipt_line_items
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.receipts r
|
||||
WHERE r.id = receipt_line_items.receipt_id
|
||||
AND r.company_id = public.current_active_company_id())
|
||||
);
|
||||
|
||||
-- document_attachments (DELETE is blocked by block_document_deletion trigger
|
||||
-- but we still add a policy for completeness — the trigger runs after RLS)
|
||||
CREATE POLICY "document_attachments_select" ON public.document_attachments
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "document_attachments_insert" ON public.document_attachments
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "document_attachments_update" ON public.document_attachments
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "document_attachments_delete" ON public.document_attachments
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- invoice_inbox_items
|
||||
CREATE POLICY "invoice_inbox_items_select" ON public.invoice_inbox_items
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_inbox_items_insert" ON public.invoice_inbox_items
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_inbox_items_update" ON public.invoice_inbox_items
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "invoice_inbox_items_delete" ON public.invoice_inbox_items
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- mapping_rules (system rules have company_id IS NULL)
|
||||
CREATE POLICY "mapping_rules_select" ON public.mapping_rules
|
||||
FOR SELECT USING (company_id = public.current_active_company_id() OR company_id IS NULL);
|
||||
CREATE POLICY "mapping_rules_insert" ON public.mapping_rules
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "mapping_rules_update" ON public.mapping_rules
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "mapping_rules_delete" ON public.mapping_rules
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- categorization_templates
|
||||
CREATE POLICY "categorization_templates_select" ON public.categorization_templates
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "categorization_templates_insert" ON public.categorization_templates
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "categorization_templates_update" ON public.categorization_templates
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "categorization_templates_delete" ON public.categorization_templates
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- deadlines
|
||||
CREATE POLICY "deadlines_select" ON public.deadlines
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "deadlines_insert" ON public.deadlines
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "deadlines_update" ON public.deadlines
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "deadlines_delete" ON public.deadlines
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- cost_centers
|
||||
CREATE POLICY "cost_centers_select" ON public.cost_centers
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "cost_centers_insert" ON public.cost_centers
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "cost_centers_update" ON public.cost_centers
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "cost_centers_delete" ON public.cost_centers
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- projects
|
||||
CREATE POLICY "projects_select" ON public.projects
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "projects_insert" ON public.projects
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "projects_update" ON public.projects
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "projects_delete" ON public.projects
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- salary_payments (may not exist on fresh DBs)
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'salary_payments'
|
||||
) THEN
|
||||
EXECUTE 'CREATE POLICY "salary_payments_select" ON public.salary_payments FOR SELECT USING (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "salary_payments_insert" ON public.salary_payments FOR INSERT WITH CHECK (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "salary_payments_update" ON public.salary_payments FOR UPDATE USING (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "salary_payments_delete" ON public.salary_payments FOR DELETE USING (company_id = public.current_active_company_id())';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- mileage_entries (may not exist on fresh DBs)
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'mileage_entries'
|
||||
) THEN
|
||||
EXECUTE 'CREATE POLICY "mileage_entries_select" ON public.mileage_entries FOR SELECT USING (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "mileage_entries_insert" ON public.mileage_entries FOR INSERT WITH CHECK (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "mileage_entries_update" ON public.mileage_entries FOR UPDATE USING (company_id = public.current_active_company_id())';
|
||||
EXECUTE 'CREATE POLICY "mileage_entries_delete" ON public.mileage_entries FOR DELETE USING (company_id = public.current_active_company_id())';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- sie_imports
|
||||
CREATE POLICY "sie_imports_select" ON public.sie_imports
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "sie_imports_insert" ON public.sie_imports
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "sie_imports_update" ON public.sie_imports
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "sie_imports_delete" ON public.sie_imports
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- sie_account_mappings
|
||||
CREATE POLICY "sie_account_mappings_select" ON public.sie_account_mappings
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "sie_account_mappings_insert" ON public.sie_account_mappings
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "sie_account_mappings_update" ON public.sie_account_mappings
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "sie_account_mappings_delete" ON public.sie_account_mappings
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- calendar_feeds
|
||||
CREATE POLICY "calendar_feeds_select" ON public.calendar_feeds
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "calendar_feeds_insert" ON public.calendar_feeds
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "calendar_feeds_update" ON public.calendar_feeds
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "calendar_feeds_delete" ON public.calendar_feeds
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- chat_sessions
|
||||
CREATE POLICY "chat_sessions_select" ON public.chat_sessions
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chat_sessions_insert" ON public.chat_sessions
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chat_sessions_update" ON public.chat_sessions
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chat_sessions_delete" ON public.chat_sessions
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- chat_messages
|
||||
CREATE POLICY "chat_messages_select" ON public.chat_messages
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chat_messages_insert" ON public.chat_messages
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chat_messages_update" ON public.chat_messages
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "chat_messages_delete" ON public.chat_messages
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- ai_usage_tracking (SELECT + INSERT only — no UPDATE/DELETE policy)
|
||||
CREATE POLICY "ai_usage_tracking_select" ON public.ai_usage_tracking
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "ai_usage_tracking_insert" ON public.ai_usage_tracking
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
|
||||
-- extension_data
|
||||
CREATE POLICY "extension_data_select" ON public.extension_data
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "extension_data_insert" ON public.extension_data
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "extension_data_update" ON public.extension_data
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "extension_data_delete" ON public.extension_data
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- api_keys
|
||||
CREATE POLICY "api_keys_select" ON public.api_keys
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "api_keys_insert" ON public.api_keys
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "api_keys_update" ON public.api_keys
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "api_keys_delete" ON public.api_keys
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- skatteverket_tokens
|
||||
CREATE POLICY "skatteverket_tokens_select" ON public.skatteverket_tokens
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "skatteverket_tokens_insert" ON public.skatteverket_tokens
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "skatteverket_tokens_update" ON public.skatteverket_tokens
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "skatteverket_tokens_delete" ON public.skatteverket_tokens
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- pending_operations (SELECT + UPDATE only — inserts via service role)
|
||||
CREATE POLICY "pending_operations_select" ON public.pending_operations
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "pending_operations_update" ON public.pending_operations
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- payment_match_log (nullable company_id)
|
||||
CREATE POLICY "payment_match_log_select" ON public.payment_match_log
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "payment_match_log_insert" ON public.payment_match_log
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id() OR company_id IS NULL);
|
||||
|
||||
-- event_log (SELECT only for users — writes via handler in service role)
|
||||
CREATE POLICY "event_log_select" ON public.event_log
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- notification_log (nullable company_id for system-wide notifications)
|
||||
CREATE POLICY "notification_log_select" ON public.notification_log
|
||||
FOR SELECT USING (company_id = public.current_active_company_id() OR company_id IS NULL);
|
||||
|
||||
-- audit_log (SELECT only for users — writes via SECURITY DEFINER triggers)
|
||||
CREATE POLICY "audit_log_select" ON public.audit_log
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- provider_consents
|
||||
CREATE POLICY "provider_consents_select" ON public.provider_consents
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "provider_consents_insert" ON public.provider_consents
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "provider_consents_update" ON public.provider_consents
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "provider_consents_delete" ON public.provider_consents
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- voucher_gap_explanations (preserves the owner/admin team_members check)
|
||||
CREATE POLICY "voucher_gap_explanations_select" ON public.voucher_gap_explanations
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "voucher_gap_explanations_insert" ON public.voucher_gap_explanations
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = public.current_active_company_id()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.team_members tm
|
||||
JOIN public.companies c ON c.team_id = tm.team_id
|
||||
WHERE c.id = company_id
|
||||
AND tm.user_id = auth.uid()
|
||||
AND tm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "voucher_gap_explanations_update" ON public.voucher_gap_explanations
|
||||
FOR UPDATE USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.team_members tm
|
||||
JOIN public.companies c ON c.team_id = tm.team_id
|
||||
WHERE c.id = company_id
|
||||
AND tm.user_id = auth.uid()
|
||||
AND tm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- automation_webhooks
|
||||
CREATE POLICY "automation_webhooks_select" ON public.automation_webhooks
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "automation_webhooks_insert" ON public.automation_webhooks
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "automation_webhooks_update" ON public.automation_webhooks
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "automation_webhooks_delete" ON public.automation_webhooks
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
|
||||
-- email_connections
|
||||
CREATE POLICY "email_connections_select" ON public.email_connections
|
||||
FOR SELECT USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "email_connections_insert" ON public.email_connections
|
||||
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "email_connections_update" ON public.email_connections
|
||||
FOR UPDATE USING (company_id = public.current_active_company_id());
|
||||
CREATE POLICY "email_connections_delete" ON public.email_connections
|
||||
FOR DELETE USING (company_id = public.current_active_company_id());
|
||||
@@ -0,0 +1,364 @@
|
||||
-- =============================================================================
|
||||
-- Viewer role read-only enforcement
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Activates the `viewer` role in company_members. Until now the role existed
|
||||
-- in the type system (CompanyRole) but was dormant — a viewer had the same
|
||||
-- write capabilities as a member. This migration makes viewers truly
|
||||
-- read-only at the database level.
|
||||
--
|
||||
-- Mechanism:
|
||||
-- 1. New helper `public.current_user_can_write()` returns true only if
|
||||
-- the authenticated user's role in the active company is NOT 'viewer'.
|
||||
-- 2. Every INSERT / UPDATE / DELETE policy on company-scoped tables is
|
||||
-- recreated with an added `AND public.current_user_can_write()` clause.
|
||||
-- 3. SELECT policies are left untouched — viewers read everything.
|
||||
--
|
||||
-- After this migration, any write by a viewer is rejected with:
|
||||
-- "new row violates row-level security policy" (server-side)
|
||||
-- regardless of whether it originates from the UI, a browser console, or
|
||||
-- an API key bound to a viewer's session.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. current_user_can_write() helper
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.current_user_can_write()
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.company_id = public.current_active_company_id()
|
||||
AND cm.role <> 'viewer'
|
||||
);
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.current_user_can_write() TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.current_user_can_write() IS
|
||||
'Returns true if auth.uid() has a non-viewer role (owner / admin / member) '
|
||||
'in the current active company. Used by RLS INSERT/UPDATE/DELETE policies '
|
||||
'to make the ''viewer'' role truly read-only. Viewers, non-members, and '
|
||||
'unauthenticated callers all get false.';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Standard tables — recreate INSERT / UPDATE / DELETE policies
|
||||
-- =============================================================================
|
||||
-- Loop over all company-scoped tables that currently have the full set of
|
||||
-- insert/update/delete policies. SELECT policies are left alone (viewers
|
||||
-- must be able to read everything).
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t TEXT;
|
||||
standard_tables TEXT[] := ARRAY[
|
||||
'company_settings', 'chart_of_accounts', 'fiscal_periods', 'journal_entries',
|
||||
'transactions', 'bank_connections', 'bank_file_imports',
|
||||
'customers', 'invoices', 'invoice_reminders', 'invoice_payments',
|
||||
'suppliers', 'supplier_invoices', 'supplier_invoice_payments',
|
||||
'receipts', 'document_attachments', 'invoice_inbox_items',
|
||||
'categorization_templates', 'deadlines', 'cost_centers', 'projects',
|
||||
'sie_imports', 'sie_account_mappings', 'calendar_feeds',
|
||||
'chat_sessions', 'chat_messages',
|
||||
'extension_data', 'api_keys', 'skatteverket_tokens',
|
||||
'provider_consents', 'automation_webhooks', 'email_connections'
|
||||
];
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY standard_tables
|
||||
LOOP
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = t
|
||||
) THEN
|
||||
EXECUTE format('DROP POLICY IF EXISTS "%s_insert" ON public.%I', t, t);
|
||||
EXECUTE format('DROP POLICY IF EXISTS "%s_update" ON public.%I', t, t);
|
||||
EXECUTE format('DROP POLICY IF EXISTS "%s_delete" ON public.%I', t, t);
|
||||
|
||||
EXECUTE format(
|
||||
'CREATE POLICY "%s_insert" ON public.%I '
|
||||
'FOR INSERT WITH CHECK ('
|
||||
'company_id = public.current_active_company_id() '
|
||||
'AND public.current_user_can_write())',
|
||||
t, t
|
||||
);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY "%s_update" ON public.%I '
|
||||
'FOR UPDATE USING ('
|
||||
'company_id = public.current_active_company_id() '
|
||||
'AND public.current_user_can_write())',
|
||||
t, t
|
||||
);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY "%s_delete" ON public.%I '
|
||||
'FOR DELETE USING ('
|
||||
'company_id = public.current_active_company_id() '
|
||||
'AND public.current_user_can_write())',
|
||||
t, t
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. voucher_sequences (INSERT + UPDATE only, no DELETE policy)
|
||||
-- =============================================================================
|
||||
DROP POLICY IF EXISTS "voucher_sequences_insert" ON public.voucher_sequences;
|
||||
DROP POLICY IF EXISTS "voucher_sequences_update" ON public.voucher_sequences;
|
||||
CREATE POLICY "voucher_sequences_insert" ON public.voucher_sequences
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "voucher_sequences_update" ON public.voucher_sequences
|
||||
FOR UPDATE USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. mapping_rules (standard, but with OR company_id IS NULL on SELECT only)
|
||||
-- =============================================================================
|
||||
-- The SELECT policy keeps its OR company_id IS NULL exception for system
|
||||
-- rules — viewers can still read them. Writes drop the NULL branch because
|
||||
-- viewers never create rules (and non-viewers only create company-scoped
|
||||
-- rules, not system rules).
|
||||
DROP POLICY IF EXISTS "mapping_rules_insert" ON public.mapping_rules;
|
||||
DROP POLICY IF EXISTS "mapping_rules_update" ON public.mapping_rules;
|
||||
DROP POLICY IF EXISTS "mapping_rules_delete" ON public.mapping_rules;
|
||||
CREATE POLICY "mapping_rules_insert" ON public.mapping_rules
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "mapping_rules_update" ON public.mapping_rules
|
||||
FOR UPDATE USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "mapping_rules_delete" ON public.mapping_rules
|
||||
FOR DELETE USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. ai_usage_tracking (INSERT only)
|
||||
-- =============================================================================
|
||||
DROP POLICY IF EXISTS "ai_usage_tracking_insert" ON public.ai_usage_tracking;
|
||||
CREATE POLICY "ai_usage_tracking_insert" ON public.ai_usage_tracking
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. pending_operations (UPDATE only — inserts via service role)
|
||||
-- =============================================================================
|
||||
DROP POLICY IF EXISTS "pending_operations_update" ON public.pending_operations;
|
||||
CREATE POLICY "pending_operations_update" ON public.pending_operations
|
||||
FOR UPDATE USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. payment_match_log (INSERT only, nullable company_id)
|
||||
-- =============================================================================
|
||||
DROP POLICY IF EXISTS "payment_match_log_insert" ON public.payment_match_log;
|
||||
CREATE POLICY "payment_match_log_insert" ON public.payment_match_log
|
||||
FOR INSERT WITH CHECK (
|
||||
(company_id = public.current_active_company_id() OR company_id IS NULL)
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. Child tables — join to parent via EXISTS
|
||||
-- =============================================================================
|
||||
-- journal_entry_lines
|
||||
DROP POLICY IF EXISTS "journal_entry_lines_insert" ON public.journal_entry_lines;
|
||||
DROP POLICY IF EXISTS "journal_entry_lines_update" ON public.journal_entry_lines;
|
||||
DROP POLICY IF EXISTS "journal_entry_lines_delete" ON public.journal_entry_lines;
|
||||
CREATE POLICY "journal_entry_lines_insert" ON public.journal_entry_lines
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = journal_entry_lines.journal_entry_id
|
||||
AND je.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "journal_entry_lines_update" ON public.journal_entry_lines
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = journal_entry_lines.journal_entry_id
|
||||
AND je.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "journal_entry_lines_delete" ON public.journal_entry_lines
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = journal_entry_lines.journal_entry_id
|
||||
AND je.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- invoice_items
|
||||
DROP POLICY IF EXISTS "invoice_items_insert" ON public.invoice_items;
|
||||
DROP POLICY IF EXISTS "invoice_items_update" ON public.invoice_items;
|
||||
DROP POLICY IF EXISTS "invoice_items_delete" ON public.invoice_items;
|
||||
CREATE POLICY "invoice_items_insert" ON public.invoice_items
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_items.invoice_id
|
||||
AND i.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "invoice_items_update" ON public.invoice_items
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_items.invoice_id
|
||||
AND i.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "invoice_items_delete" ON public.invoice_items
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = invoice_items.invoice_id
|
||||
AND i.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- supplier_invoice_items
|
||||
DROP POLICY IF EXISTS "supplier_invoice_items_insert" ON public.supplier_invoice_items;
|
||||
DROP POLICY IF EXISTS "supplier_invoice_items_update" ON public.supplier_invoice_items;
|
||||
DROP POLICY IF EXISTS "supplier_invoice_items_delete" ON public.supplier_invoice_items;
|
||||
CREATE POLICY "supplier_invoice_items_insert" ON public.supplier_invoice_items
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
||||
AND si.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "supplier_invoice_items_update" ON public.supplier_invoice_items
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
||||
AND si.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "supplier_invoice_items_delete" ON public.supplier_invoice_items
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
||||
AND si.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- receipt_line_items
|
||||
DROP POLICY IF EXISTS "receipt_line_items_insert" ON public.receipt_line_items;
|
||||
DROP POLICY IF EXISTS "receipt_line_items_update" ON public.receipt_line_items;
|
||||
DROP POLICY IF EXISTS "receipt_line_items_delete" ON public.receipt_line_items;
|
||||
CREATE POLICY "receipt_line_items_insert" ON public.receipt_line_items
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.receipts r
|
||||
WHERE r.id = receipt_line_items.receipt_id
|
||||
AND r.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "receipt_line_items_update" ON public.receipt_line_items
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.receipts r
|
||||
WHERE r.id = receipt_line_items.receipt_id
|
||||
AND r.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
CREATE POLICY "receipt_line_items_delete" ON public.receipt_line_items
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.receipts r
|
||||
WHERE r.id = receipt_line_items.receipt_id
|
||||
AND r.company_id = public.current_active_company_id())
|
||||
AND public.current_user_can_write()
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 9. voucher_gap_explanations (preserves owner/admin team_members check)
|
||||
-- =============================================================================
|
||||
-- The existing policy already blocks non-(owner|admin) via team_members, so
|
||||
-- viewers are de facto blocked. We add current_user_can_write() as a
|
||||
-- defensive second layer so a future refactor of team_members can't
|
||||
-- accidentally re-open viewer writes.
|
||||
DROP POLICY IF EXISTS "voucher_gap_explanations_insert" ON public.voucher_gap_explanations;
|
||||
DROP POLICY IF EXISTS "voucher_gap_explanations_update" ON public.voucher_gap_explanations;
|
||||
CREATE POLICY "voucher_gap_explanations_insert" ON public.voucher_gap_explanations
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.team_members tm
|
||||
JOIN public.companies c ON c.team_id = tm.team_id
|
||||
WHERE c.id = company_id
|
||||
AND tm.user_id = auth.uid()
|
||||
AND tm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "voucher_gap_explanations_update" ON public.voucher_gap_explanations
|
||||
FOR UPDATE USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND public.current_user_can_write()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.team_members tm
|
||||
JOIN public.companies c ON c.team_id = tm.team_id
|
||||
WHERE c.id = company_id
|
||||
AND tm.user_id = auth.uid()
|
||||
AND tm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 10. Conditional tables (may not exist on fresh DBs)
|
||||
-- =============================================================================
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'account_balances'
|
||||
) THEN
|
||||
EXECUTE 'DROP POLICY IF EXISTS "account_balances_insert" ON public.account_balances';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "account_balances_update" ON public.account_balances';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "account_balances_delete" ON public.account_balances';
|
||||
EXECUTE 'CREATE POLICY "account_balances_insert" ON public.account_balances FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
EXECUTE 'CREATE POLICY "account_balances_update" ON public.account_balances FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
EXECUTE 'CREATE POLICY "account_balances_delete" ON public.account_balances FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'salary_payments'
|
||||
) THEN
|
||||
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_insert" ON public.salary_payments';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_update" ON public.salary_payments';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_delete" ON public.salary_payments';
|
||||
EXECUTE 'CREATE POLICY "salary_payments_insert" ON public.salary_payments FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
EXECUTE 'CREATE POLICY "salary_payments_update" ON public.salary_payments FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
EXECUTE 'CREATE POLICY "salary_payments_delete" ON public.salary_payments FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'mileage_entries'
|
||||
) THEN
|
||||
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_insert" ON public.mileage_entries';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_update" ON public.mileage_entries';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_delete" ON public.mileage_entries';
|
||||
EXECUTE 'CREATE POLICY "mileage_entries_insert" ON public.mileage_entries FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
EXECUTE 'CREATE POLICY "mileage_entries_update" ON public.mileage_entries FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
EXECUTE 'CREATE POLICY "mileage_entries_delete" ON public.mileage_entries FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
||||
END IF;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user