From 7618e97b7a1752e527ee872a9dac64f1a5d76d53 Mon Sep 17 00:00:00 2001 From: jonthan kabuya Date: Wed, 7 Jan 2026 17:29:38 +0200 Subject: [PATCH 01/23] feat(sso): consolidate SSO SAML schema into single migration Consolidates 12 incremental SSO migrations (20251224022658 through 20260106000000) into a single comprehensive migration. Schema includes: - Tables: org_saml_connections, saml_domain_mappings, sso_audit_logs - Functions: check_org_sso_configured, lookup_sso_provider_*, auto_join_* - Triggers: auto_join_sso_user_trigger, check_sso_domain_on_signup_trigger - RLS policies for all tables - Indexes for performance - Single SSO per org constraint (UNIQUE org_id, entity_id) - auto_join_enabled flag for controlling enrollment This is PR #1 of the SSO feature split (schema foundation only). No backend endpoints, no frontend, no tests included yet. Related: feature/sso-saml-authentication --- .../20260107000000_sso_saml_complete.sql | 1021 +++++++++++++++++ 1 file changed, 1021 insertions(+) create mode 100644 supabase/migrations/20260107000000_sso_saml_complete.sql diff --git a/supabase/migrations/20260107000000_sso_saml_complete.sql b/supabase/migrations/20260107000000_sso_saml_complete.sql new file mode 100644 index 0000000000..af43826536 --- /dev/null +++ b/supabase/migrations/20260107000000_sso_saml_complete.sql @@ -0,0 +1,1021 @@ +-- ============================================================================ +-- CONSOLIDATED SSO SAML Migration +-- Replaces 12 incremental migrations (20251224022658 through 20260106000000) +-- ============================================================================ +-- This migration consolidates all SSO/SAML functionality including: +-- - SAML SSO configuration tables +-- - Domain-to-provider mappings +-- - Auto-enrollment logic with auto_join_enabled flag +-- - Comprehensive audit logging +-- - SSO provider lookup functions with all fixes applied +-- - Auto-join triggers with all domain/metadata checks +-- - Single SSO per organization enforcement +-- - RLS policies for security +-- ============================================================================ + +-- ============================================================================ +-- TABLE: org_saml_connections +-- Stores SAML SSO configuration per organization (ONE per org) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS public.org_saml_connections ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL REFERENCES public.orgs(id) ON DELETE CASCADE, + +-- Supabase SSO Provider Info (from CLI output) +sso_provider_id uuid NOT NULL UNIQUE, +provider_name text NOT NULL, -- "Okta", "Azure AD", "Google Workspace", etc. + +-- SAML Configuration +metadata_url text, -- IdP metadata URL (preferred for auto-refresh) +metadata_xml text, -- Stored XML if URL not available +entity_id text NOT NULL, -- IdP's SAML EntityID + +-- Certificate Management (for rotation detection) +current_certificate text, +certificate_expires_at timestamptz, +certificate_last_checked timestamptz DEFAULT now(), + +-- Status Flags +enabled boolean NOT NULL DEFAULT false, +verified boolean NOT NULL DEFAULT false, +auto_join_enabled boolean NOT NULL DEFAULT false, -- Controls automatic enrollment + +-- Optional Attribute Mapping +-- Maps SAML attributes to user properties +-- Example: {"email": {"name": "mail"}, "first_name": {"name": "givenName"}} +attribute_mapping jsonb DEFAULT '{}'::jsonb, + +-- Audit Fields +created_at timestamptz NOT NULL DEFAULT now(), +updated_at timestamptz NOT NULL DEFAULT now(), +created_by uuid REFERENCES auth.users (id), + +-- Constraints +CONSTRAINT org_saml_connections_org_unique UNIQUE(org_id), + CONSTRAINT org_saml_connections_entity_id_unique UNIQUE(entity_id), + CONSTRAINT org_saml_connections_metadata_check CHECK ( + metadata_url IS NOT NULL OR metadata_xml IS NOT NULL + ) +); + +COMMENT ON +TABLE public.org_saml_connections IS 'Tracks SAML SSO configurations per organization (one per org)'; + +COMMENT ON COLUMN public.org_saml_connections.sso_provider_id IS 'UUID returned by Supabase CLI when adding SSO provider'; + +COMMENT ON COLUMN public.org_saml_connections.metadata_url IS 'IdP metadata URL for automatic refresh'; + +COMMENT ON COLUMN public.org_saml_connections.verified IS 'Whether SSO connection has been successfully tested'; + +COMMENT ON COLUMN public.org_saml_connections.auto_join_enabled IS 'Whether SSO-authenticated users are automatically enrolled in the organization'; + +COMMENT ON CONSTRAINT org_saml_connections_org_unique ON public.org_saml_connections IS 'Ensures each organization can only have one SSO configuration'; + +COMMENT ON CONSTRAINT org_saml_connections_entity_id_unique ON public.org_saml_connections IS 'Ensures each IdP entity ID can only be used by one organization'; + +-- ============================================================================ +-- TABLE: saml_domain_mappings +-- Maps email domains to SSO providers (supports multi-provider setups) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS public.saml_domain_mappings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + +-- Domain Configuration +domain text NOT NULL, +org_id uuid NOT NULL REFERENCES public.orgs (id) ON DELETE CASCADE, +sso_connection_id uuid NOT NULL REFERENCES public.org_saml_connections (id) ON DELETE CASCADE, + +-- Priority for multiple providers (higher = shown first) +priority int NOT NULL DEFAULT 0, + +-- Verification Status (future: DNS TXT validation if needed) +verified boolean NOT NULL DEFAULT true, -- Auto-verified via SSO by default +verification_code text, +verified_at timestamptz, + +-- Audit +created_at timestamptz NOT NULL DEFAULT now(), + +-- Constraints +CONSTRAINT saml_domain_mappings_domain_connection_unique UNIQUE(domain, sso_connection_id) +); + +COMMENT ON +TABLE public.saml_domain_mappings IS 'Maps email domains to SSO providers for auto-join'; + +COMMENT ON COLUMN public.saml_domain_mappings.priority IS 'Display order when multiple providers exist (higher first)'; + +-- ============================================================================ +-- TABLE: sso_audit_logs +-- Comprehensive audit trail for SSO authentication events +-- ============================================================================ +CREATE TABLE IF NOT EXISTS public.sso_audit_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + timestamp timestamptz NOT NULL DEFAULT now(), + +-- User Identity +user_id uuid REFERENCES auth.users (id) ON DELETE SET NULL, +email text, + +-- Event Type +event_type text NOT NULL, +-- Possible values: 'login_success', 'login_failed', 'logout', 'session_expired', +-- 'config_created', 'config_updated', 'config_deleted', +-- 'provider_added', 'provider_removed', 'auto_join_success' + +-- Context +org_id uuid REFERENCES public.orgs (id) ON DELETE SET NULL, +sso_provider_id uuid, +sso_connection_id uuid REFERENCES public.org_saml_connections (id) ON DELETE SET NULL, + +-- Technical Details +ip_address inet, user_agent text, country text, + +-- SAML-Specific Fields +saml_assertion_id text, -- SAML assertion ID for tracing +saml_session_index text, -- Session identifier from IdP + +-- Error Details (for failed events) +error_code text, error_message text, + +-- Additional Metadata +metadata jsonb DEFAULT '{}'::jsonb ); + +COMMENT ON +TABLE public.sso_audit_logs IS 'Audit trail for all SSO authentication and configuration events'; + +COMMENT ON COLUMN public.sso_audit_logs.event_type IS 'Type of SSO event (login, logout, config change, etc.)'; + +-- ============================================================================ +-- INDEXES for Performance +-- ============================================================================ + +-- org_saml_connections indexes +CREATE INDEX IF NOT EXISTS idx_saml_connections_org_enabled ON public.org_saml_connections (org_id) +WHERE + enabled = true; + +CREATE INDEX IF NOT EXISTS idx_saml_connections_provider ON public.org_saml_connections (sso_provider_id); + +CREATE INDEX IF NOT EXISTS idx_saml_connections_cert_expiry ON public.org_saml_connections (certificate_expires_at) +WHERE + certificate_expires_at IS NOT NULL + AND enabled = true; + +-- saml_domain_mappings indexes +CREATE INDEX IF NOT EXISTS idx_saml_domains_domain_verified ON public.saml_domain_mappings (domain) +WHERE + verified = true; + +CREATE INDEX IF NOT EXISTS idx_saml_domains_connection ON public.saml_domain_mappings (sso_connection_id); + +CREATE INDEX IF NOT EXISTS idx_saml_domains_org ON public.saml_domain_mappings (org_id); + +-- sso_audit_logs indexes +CREATE INDEX IF NOT EXISTS idx_sso_audit_user_time ON public.sso_audit_logs (user_id, timestamp DESC) +WHERE + user_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_sso_audit_org_time ON public.sso_audit_logs (org_id, timestamp DESC) +WHERE + org_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_sso_audit_event_time ON public.sso_audit_logs (event_type, timestamp DESC); + +CREATE INDEX IF NOT EXISTS idx_sso_audit_provider ON public.sso_audit_logs ( + sso_provider_id, + timestamp DESC +) +WHERE + sso_provider_id IS NOT NULL; + +-- Failed login monitoring +CREATE INDEX IF NOT EXISTS idx_sso_audit_failures ON public.sso_audit_logs (ip_address, timestamp DESC) +WHERE + event_type = 'login_failed'; + +-- ============================================================================ +-- HELPER FUNCTIONS +-- ============================================================================ + +-- Helper function to check if domain requires SSO +CREATE OR REPLACE FUNCTION public.check_sso_required_for_domain(p_email text) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_domain text; + v_has_sso boolean; +BEGIN + v_domain := lower(split_part(p_email, '@', 2)); + + IF v_domain IS NULL OR v_domain = '' THEN + RETURN false; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.saml_domain_mappings sdm + JOIN public.org_saml_connections osc ON osc.id = sdm.sso_connection_id + WHERE sdm.domain = v_domain + AND sdm.verified = true + AND osc.enabled = true + ) INTO v_has_sso; + + RETURN v_has_sso; +END; +$$; + +COMMENT ON FUNCTION public.check_sso_required_for_domain IS 'Checks if an email domain has SSO configured and enabled'; + +-- Helper function to check if org has SSO configured +CREATE OR REPLACE FUNCTION public.check_org_sso_configured(p_org_id uuid) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM public.org_saml_connections + WHERE org_id = p_org_id + AND enabled = true + ); +END; +$$; + +COMMENT ON FUNCTION public.check_org_sso_configured IS 'Checks if an organization has SSO enabled'; + +-- Helper function to get SSO provider ID for a user +CREATE OR REPLACE FUNCTION public.get_sso_provider_id_for_user(p_user_id uuid) +RETURNS uuid +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_provider_id uuid; +BEGIN + SELECT (raw_app_meta_data->>'sso_provider_id')::uuid + INTO v_provider_id + FROM auth.users + WHERE id = p_user_id; + + IF v_provider_id IS NULL THEN + SELECT (raw_user_meta_data->>'sso_provider_id')::uuid + INTO v_provider_id + FROM auth.users + WHERE id = p_user_id; + END IF; + + RETURN v_provider_id; +END; +$$; + +COMMENT ON FUNCTION public.get_sso_provider_id_for_user IS 'Retrieves SSO provider ID from user metadata'; + +-- Helper function to check if org already has SSO configured +CREATE OR REPLACE FUNCTION public.org_has_sso_configured(p_org_id uuid) +RETURNS boolean +LANGUAGE plpgsql +STABLE +AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM public.org_saml_connections + WHERE org_id = p_org_id + ); +END; +$$; + +COMMENT ON FUNCTION public.org_has_sso_configured (uuid) IS 'Check if an organization already has SSO configured'; + +-- ============================================================================ +-- FUNCTIONS: SSO Provider Lookup (FINAL VERSION WITH ALL FIXES) +-- ============================================================================ + +-- Function to lookup SSO provider by email domain +CREATE OR REPLACE FUNCTION public.lookup_sso_provider_by_domain( + p_email text +) +RETURNS TABLE ( + provider_id uuid, + entity_id text, + org_id uuid, + org_name text, + provider_name text, + metadata_url text, + enabled boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_domain text; +BEGIN + -- Extract domain from email + v_domain := lower(split_part(p_email, '@', 2)); + + IF v_domain IS NULL OR v_domain = '' THEN + RETURN; + END IF; + + -- Return all matching SSO providers ordered by priority + RETURN QUERY + SELECT + osc.sso_provider_id as provider_id, + osc.entity_id, + osc.org_id, + o.name as org_name, + osc.provider_name, + osc.metadata_url, + osc.enabled + FROM public.saml_domain_mappings sdm + JOIN public.org_saml_connections osc ON osc.id = sdm.sso_connection_id + JOIN public.orgs o ON o.id = osc.org_id + WHERE sdm.domain = v_domain + AND sdm.verified = true + AND osc.enabled = true + ORDER BY sdm.priority DESC, osc.created_at DESC; +END; +$$; + +COMMENT ON FUNCTION public.lookup_sso_provider_by_domain IS 'Finds SSO providers configured for an email domain'; + +-- Alternative lookup function that returns the sso_provider_id directly +CREATE OR REPLACE FUNCTION public.lookup_sso_provider_for_email(p_email text) +RETURNS uuid +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_domain text; + v_provider_id uuid; +BEGIN + v_domain := lower(split_part(p_email, '@', 2)); + + IF v_domain IS NULL OR v_domain = '' THEN + RETURN NULL; + END IF; + + SELECT osc.sso_provider_id + INTO v_provider_id + FROM public.saml_domain_mappings sdm + JOIN public.org_saml_connections osc ON osc.id = sdm.sso_connection_id + WHERE sdm.domain = v_domain + AND sdm.verified = true + AND osc.enabled = true + ORDER BY sdm.priority DESC, osc.created_at DESC + LIMIT 1; + + RETURN v_provider_id; +END; +$$; + +COMMENT ON FUNCTION public.lookup_sso_provider_for_email IS 'Returns the SSO provider ID for an email address if one exists'; + +-- ============================================================================ +-- FUNCTIONS: Auto-Enrollment (FINAL VERSION WITH auto_join_enabled CHECK) +-- ============================================================================ + +-- Function to auto-enroll SSO-authenticated user to their organization +CREATE OR REPLACE FUNCTION public.auto_enroll_sso_user( + p_user_id uuid, + p_email text, + p_sso_provider_id uuid +) +RETURNS TABLE ( + enrolled_org_id uuid, + org_name text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_org record; + v_already_member boolean; +BEGIN + -- Find organizations with this SSO provider that have auto-join enabled + FOR v_org IN + SELECT DISTINCT + osc.org_id, + o.name as org_name + FROM public.org_saml_connections osc + JOIN public.orgs o ON o.id = osc.org_id + WHERE osc.sso_provider_id = p_sso_provider_id + AND osc.enabled = true + AND osc.auto_join_enabled = true -- Only enroll if auto-join is enabled + LOOP + -- Check if already a member + SELECT EXISTS ( + SELECT 1 FROM public.org_users + WHERE user_id = p_user_id AND org_id = v_org.org_id + ) INTO v_already_member; + + IF NOT v_already_member THEN + -- Add user to organization with read permission + INSERT INTO public.org_users (user_id, org_id, user_right, created_at) + VALUES (p_user_id, v_org.org_id, 'read', now()); + + -- Log the auto-enrollment + INSERT INTO public.sso_audit_logs ( + user_id, + email, + event_type, + org_id, + sso_provider_id, + metadata + ) VALUES ( + p_user_id, + p_email, + 'auto_join_success', + v_org.org_id, + p_sso_provider_id, + jsonb_build_object( + 'enrollment_method', 'sso_auto_join', + 'timestamp', now() + ) + ); + + -- Return enrolled org + enrolled_org_id := v_org.org_id; + org_name := v_org.org_name; + RETURN NEXT; + END IF; + END LOOP; +END; +$$; + +COMMENT ON FUNCTION public.auto_enroll_sso_user IS 'Automatically enrolls SSO user to their organization ONLY if both SSO enabled AND auto_join_enabled = true'; + +-- Function to auto-join users by email using saml_domain_mappings +CREATE OR REPLACE FUNCTION public.auto_join_user_to_orgs_by_email( + p_user_id uuid, + p_email text, + p_sso_provider_id uuid DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_domain text; + v_org record; +BEGIN + v_domain := lower(split_part(p_email, '@', 2)); + + IF v_domain IS NULL OR v_domain = '' THEN + RETURN; + END IF; + + -- Priority 1: SSO provider-based enrollment (strongest binding) + IF p_sso_provider_id IS NOT NULL THEN + PERFORM public.auto_enroll_sso_user(p_user_id, p_email, p_sso_provider_id); + RETURN; -- SSO enrollment takes precedence + END IF; + + -- Priority 2: SAML domain mappings based enrollment + -- Check saml_domain_mappings table for matching domains + FOR v_org IN + SELECT DISTINCT o.id, o.name + FROM public.orgs o + INNER JOIN public.saml_domain_mappings sdm ON sdm.org_id = o.id + WHERE sdm.domain = v_domain + AND sdm.verified = true + AND NOT EXISTS ( + SELECT 1 FROM public.org_users ou + WHERE ou.user_id = p_user_id AND ou.org_id = o.id + ) + LOOP + -- Add user to org with read permission + -- Use conditional INSERT to avoid conflicts + INSERT INTO public.org_users (user_id, org_id, user_right, created_at) + SELECT p_user_id, v_org.id, 'read', now() + WHERE NOT EXISTS ( + SELECT 1 FROM public.org_users ou + WHERE ou.user_id = p_user_id AND ou.org_id = v_org.id + ); + + -- Log domain-based auto-join + INSERT INTO public.sso_audit_logs ( + user_id, + email, + event_type, + org_id, + metadata + ) VALUES ( + p_user_id, + p_email, + 'auto_join_success', + v_org.id, + jsonb_build_object( + 'enrollment_method', 'saml_domain_mapping', + 'domain', v_domain + ) + ); + END LOOP; +END; +$$; + +COMMENT ON FUNCTION public.auto_join_user_to_orgs_by_email IS 'Auto-enrolls users via SSO provider or SAML domain mappings. Does not use allowed_email_domains column.'; + +-- ============================================================================ +-- TRIGGER FUNCTIONS: Auto-Join Logic (FINAL VERSION WITH ALL FIXES) +-- ============================================================================ + +-- Trigger function for user creation (called on INSERT to auth.users) +CREATE OR REPLACE FUNCTION public.trigger_auto_join_on_user_create() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_email text; + v_sso_provider_id uuid; +BEGIN + v_email := COALESCE(NEW.raw_user_meta_data->>'email', NEW.email); + + IF v_email IS NULL THEN + RETURN NEW; + END IF; + + -- Extract SSO provider ID from metadata + v_sso_provider_id := public.get_sso_provider_id_for_user(NEW.id); + + -- If no SSO provider, try looking it up by domain + IF v_sso_provider_id IS NULL THEN + v_sso_provider_id := public.lookup_sso_provider_for_email(v_email); + END IF; + + -- Perform auto-join with the provider ID (if found) + PERFORM public.auto_join_user_to_orgs_by_email(NEW.id, v_email, v_sso_provider_id); + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.trigger_auto_join_on_user_create IS 'Auto-enrolls new users on account creation'; + +-- Trigger function for user update (called on UPDATE to auth.users) +CREATE OR REPLACE FUNCTION public.trigger_auto_join_on_user_update() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_email text; + v_sso_provider_id uuid; + v_already_enrolled boolean; +BEGIN + -- Only process if email confirmation changed or SSO metadata added + IF OLD.email_confirmed_at IS NOT DISTINCT FROM NEW.email_confirmed_at + AND OLD.raw_app_meta_data IS NOT DISTINCT FROM NEW.raw_app_meta_data + AND OLD.raw_user_meta_data IS NOT DISTINCT FROM NEW.raw_user_meta_data THEN + RETURN NEW; + END IF; + + v_email := COALESCE(NEW.raw_user_meta_data->>'email', NEW.email); + + IF v_email IS NULL THEN + RETURN NEW; + END IF; + + -- Get SSO provider ID from user metadata + v_sso_provider_id := public.get_sso_provider_id_for_user(NEW.id); + + -- Only proceed with SSO auto-join if provider ID exists + IF v_sso_provider_id IS NOT NULL THEN + -- Check if user is already enrolled in an org with this SSO provider + SELECT EXISTS ( + SELECT 1 + FROM public.org_users ou + JOIN public.org_saml_connections osc ON osc.org_id = ou.org_id + WHERE ou.user_id = NEW.id + AND osc.sso_provider_id = v_sso_provider_id + ) INTO v_already_enrolled; + + -- Only auto-enroll if not already in an org with this SSO provider + IF NOT v_already_enrolled THEN + PERFORM public.auto_join_user_to_orgs_by_email(NEW.id, v_email, v_sso_provider_id); + END IF; + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.trigger_auto_join_on_user_update IS 'Auto-enrolls existing users when they log in with SSO'; + +-- ============================================================================ +-- TRIGGER FUNCTION: Enforce SSO for Domains (FINAL VERSION WITH METADATA BYPASS) +-- ============================================================================ + +-- Function to enforce SSO for configured domains (with metadata bypass) +CREATE OR REPLACE FUNCTION public.enforce_sso_for_domains() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_email text; + v_domain text; + v_sso_required boolean; + v_provider_count integer; + v_metadata_provider_id uuid; + v_metadata_allows boolean := false; +BEGIN + IF TG_OP != 'INSERT' THEN + RETURN NEW; + END IF; + + v_email := COALESCE( + NEW.raw_user_meta_data->>'email', + NEW.email + ); + + IF v_email IS NULL THEN + RETURN NEW; + END IF; + + v_domain := lower(split_part(v_email, '@', 2)); + + -- Try to read the SSO provider ID that a trusted SSO flow would set on the + -- user row. If present and it matches the verified domain entry, allow the + -- insert to proceed before blocking emails. + BEGIN + v_metadata_provider_id := NULLIF(NEW.raw_user_meta_data->>'sso_provider_id', '')::uuid; + EXCEPTION WHEN invalid_text_representation THEN + v_metadata_provider_id := NULL; + END; + + IF v_metadata_provider_id IS NULL THEN + BEGIN + v_metadata_provider_id := NULLIF(NEW.raw_app_meta_data->>'sso_provider_id', '')::uuid; + EXCEPTION WHEN invalid_text_representation THEN + v_metadata_provider_id := NULL; + END; + END IF; + + IF v_metadata_provider_id IS NOT NULL THEN + SELECT EXISTS ( + SELECT 1 + FROM public.saml_domain_mappings sdm + JOIN public.org_saml_connections osc ON osc.id = sdm.sso_connection_id + WHERE sdm.domain = v_domain + AND sdm.verified = true + AND osc.enabled = true + AND osc.sso_provider_id = v_metadata_provider_id + ) INTO v_metadata_allows; + + IF v_metadata_allows THEN + RETURN NEW; + END IF; + END IF; + + -- Check if this is an SSO signup (will have provider info in auth.identities) + SELECT COUNT(*) INTO v_provider_count + FROM auth.identities + WHERE user_id = NEW.id + AND provider != 'email'; + + -- If signing up via SSO provider, allow it + IF v_provider_count > 0 THEN + RETURN NEW; + END IF; + + -- Check if domain requires SSO + v_sso_required := public.check_sso_required_for_domain(v_email); + + IF v_sso_required THEN + RAISE EXCEPTION 'SSO authentication required for this email domain. Please use "Sign in with SSO" instead.' + USING ERRCODE = 'CAPCR', + HINT = 'Your organization requires SSO authentication'; + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.enforce_sso_for_domains IS 'Trigger function to enforce SSO for configured email domains'; + +-- ============================================================================ +-- TRIGGER FUNCTION: Validation and Audit +-- ============================================================================ + +-- Validation trigger for SSO configuration +CREATE OR REPLACE FUNCTION public.validate_sso_configuration() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Validate metadata exists + IF NEW.metadata_url IS NULL AND NEW.metadata_xml IS NULL THEN + RAISE EXCEPTION 'Either metadata_url or metadata_xml must be provided'; + END IF; + + -- Validate entity_id format + IF NEW.entity_id IS NULL OR NEW.entity_id = '' THEN + RAISE EXCEPTION 'entity_id is required'; + END IF; + + -- Update timestamp + NEW.updated_at := now(); + + -- Log configuration change + IF TG_OP = 'INSERT' THEN + INSERT INTO public.sso_audit_logs ( + event_type, + org_id, + sso_provider_id, + metadata + ) VALUES ( + 'config_created', + NEW.org_id, + NEW.sso_provider_id, + jsonb_build_object( + 'provider_name', NEW.provider_name, + 'entity_id', NEW.entity_id, + 'created_by', NEW.created_by + ) + ); + ELSIF TG_OP = 'UPDATE' THEN + INSERT INTO public.sso_audit_logs ( + event_type, + org_id, + sso_provider_id, + metadata + ) VALUES ( + 'config_updated', + NEW.org_id, + NEW.sso_provider_id, + jsonb_build_object( + 'provider_name', NEW.provider_name, + 'changes', jsonb_build_object( + 'enabled', jsonb_build_object('old', OLD.enabled, 'new', NEW.enabled), + 'verified', jsonb_build_object('old', OLD.verified, 'new', NEW.verified) + ) + ) + ); + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.validate_sso_configuration IS 'Validates SSO configuration and logs changes'; + +-- ============================================================================ +-- TRIGGERS: Create All Triggers +-- ============================================================================ + +-- Drop existing triggers to ensure clean state +DROP TRIGGER IF EXISTS auto_join_user_to_orgs_on_create ON auth.users; + +DROP TRIGGER IF EXISTS auto_join_user_to_orgs_on_update ON auth.users; + +DROP TRIGGER IF EXISTS sso_user_auto_enroll_on_create ON auth.users; + +DROP TRIGGER IF EXISTS check_sso_domain_on_signup_trigger ON auth.users; + +DROP TRIGGER IF EXISTS trigger_validate_sso_configuration ON public.org_saml_connections; + +-- Create auto-join trigger for user creation +CREATE TRIGGER auto_join_user_to_orgs_on_create + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION public.trigger_auto_join_on_user_create(); + +-- Create auto-join trigger for user updates +CREATE TRIGGER auto_join_user_to_orgs_on_update + AFTER UPDATE ON auth.users + FOR EACH ROW + EXECUTE FUNCTION public.trigger_auto_join_on_user_update(); + +-- Create SSO domain enforcement trigger +CREATE TRIGGER check_sso_domain_on_signup_trigger + BEFORE INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION public.enforce_sso_for_domains(); + +-- Create SSO configuration validation trigger +CREATE TRIGGER trigger_validate_sso_configuration + BEFORE INSERT OR UPDATE ON public.org_saml_connections + FOR EACH ROW + EXECUTE FUNCTION public.validate_sso_configuration(); + +COMMENT ON TRIGGER trigger_validate_sso_configuration ON public.org_saml_connections IS 'Validates SSO config and logs changes'; + +-- ============================================================================ +-- ROW LEVEL SECURITY (RLS) POLICIES +-- ============================================================================ + +-- Enable RLS on all tables +ALTER TABLE public.org_saml_connections ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.saml_domain_mappings ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.sso_audit_logs ENABLE ROW LEVEL SECURITY; + +-- Drop all existing policies first (idempotent) +DROP POLICY IF EXISTS "Super admins can manage SSO connections" ON public.org_saml_connections; + +DROP POLICY IF EXISTS "Org members can read SSO status" ON public.org_saml_connections; + +DROP POLICY IF EXISTS "Anyone can read verified domain mappings" ON public.saml_domain_mappings; + +DROP POLICY IF EXISTS "Super admins can manage domain mappings" ON public.saml_domain_mappings; + +DROP POLICY IF EXISTS "Users can view own SSO audit logs" ON public.sso_audit_logs; + +DROP POLICY IF EXISTS "Org admins can view org SSO audit logs" ON public.sso_audit_logs; + +DROP POLICY IF EXISTS "System can insert audit logs" ON public.sso_audit_logs; + +-- ============================================================================ +-- RLS POLICIES: org_saml_connections +-- ============================================================================ + +-- Super admins can manage SSO connections +CREATE POLICY "Super admins can manage SSO connections" + ON public.org_saml_connections + FOR ALL + TO authenticated + USING ( + public.check_min_rights( + 'super_admin'::public.user_min_right, + public.get_identity_org_allowed('{all,write}'::public.key_mode[], org_id), + org_id, + NULL::character varying, + NULL::bigint + ) + ) + WITH CHECK ( + public.check_min_rights( + 'super_admin'::public.user_min_right, + public.get_identity_org_allowed('{all,write}'::public.key_mode[], org_id), + org_id, + NULL::character varying, + NULL::bigint + ) + ); + +-- Org members can read their org's SSO status (for UI display) +CREATE POLICY "Org members can read SSO status" + ON public.org_saml_connections + FOR SELECT + TO authenticated + USING ( + public.check_min_rights( + 'read'::public.user_min_right, + public.get_identity_org_allowed('{read,write,all}'::public.key_mode[], org_id), + org_id, + NULL::character varying, + NULL::bigint + ) + ); + +-- ============================================================================ +-- RLS POLICIES: saml_domain_mappings +-- ============================================================================ + +-- Anyone (including anon) can read verified domain mappings for SSO detection +CREATE POLICY "Anyone can read verified domain mappings" ON public.saml_domain_mappings FOR +SELECT TO authenticated, anon USING (verified = true); + +-- Super admins can manage domain mappings +CREATE POLICY "Super admins can manage domain mappings" + ON public.saml_domain_mappings + FOR ALL + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.org_saml_connections osc + WHERE osc.id = sso_connection_id + AND public.check_min_rights( + 'super_admin'::public.user_min_right, + public.get_identity_org_allowed('{all,write}'::public.key_mode[], osc.org_id), + osc.org_id, + NULL::character varying, + NULL::bigint + ) + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.org_saml_connections osc + WHERE osc.id = sso_connection_id + AND public.check_min_rights( + 'super_admin'::public.user_min_right, + public.get_identity_org_allowed('{all,write}'::public.key_mode[], osc.org_id), + osc.org_id, + NULL::character varying, + NULL::bigint + ) + ) + ); + +-- ============================================================================ +-- RLS POLICIES: sso_audit_logs +-- ============================================================================ + +-- Users can view their own audit logs +CREATE POLICY "Users can view own SSO audit logs" ON public.sso_audit_logs FOR +SELECT TO authenticated USING (user_id = auth.uid ()); + +-- Org admins can view org audit logs +CREATE POLICY "Org admins can view org SSO audit logs" + ON public.sso_audit_logs + FOR SELECT + TO authenticated + USING ( + org_id IS NOT NULL + AND public.check_min_rights( + 'admin'::public.user_min_right, + public.get_identity_org_allowed('{read,write,all}'::public.key_mode[], org_id), + org_id, + NULL::character varying, + NULL::bigint + ) + ); + +-- System can insert audit logs (SECURITY DEFINER functions) +CREATE POLICY "System can insert audit logs" ON public.sso_audit_logs FOR +INSERT + TO authenticated +WITH + CHECK (true); + +-- ============================================================================ +-- GRANTS: Ensure proper permissions +-- ============================================================================ + +-- Grant usage on public schema +GRANT USAGE ON SCHEMA public TO authenticated, anon; + +-- Grant access to tables +GRANT SELECT ON public.org_saml_connections TO authenticated, anon; + +GRANT SELECT ON public.saml_domain_mappings TO authenticated, anon; + +GRANT SELECT ON public.sso_audit_logs TO authenticated; + +-- Grant function execution to authenticated users and anon for SSO detection +GRANT EXECUTE ON FUNCTION public.check_sso_required_for_domain TO authenticated, anon; + +GRANT +EXECUTE ON FUNCTION public.check_org_sso_configured TO authenticated, +anon; + +GRANT +EXECUTE ON FUNCTION public.get_sso_provider_id_for_user TO authenticated; + +GRANT +EXECUTE ON FUNCTION public.org_has_sso_configured (uuid) TO authenticated; + +GRANT +EXECUTE ON FUNCTION public.lookup_sso_provider_by_domain TO authenticated, +anon; + +GRANT +EXECUTE ON FUNCTION public.lookup_sso_provider_for_email TO authenticated, +anon; + +GRANT +EXECUTE ON FUNCTION public.auto_enroll_sso_user TO authenticated; + +GRANT +EXECUTE ON FUNCTION public.auto_join_user_to_orgs_by_email TO authenticated; + +GRANT +EXECUTE ON FUNCTION public.trigger_auto_join_on_user_create TO authenticated; + +GRANT +EXECUTE ON FUNCTION public.trigger_auto_join_on_user_update TO authenticated; + +-- Grant special permissions to auth admin for trigger functions +GRANT +EXECUTE ON FUNCTION public.get_sso_provider_id_for_user TO postgres, +supabase_auth_admin; + +GRANT +EXECUTE ON FUNCTION public.trigger_auto_join_on_user_create TO postgres, +supabase_auth_admin; + +GRANT +EXECUTE ON FUNCTION public.trigger_auto_join_on_user_update TO postgres, +supabase_auth_admin; \ No newline at end of file From ce2d0ce3bd183b0aea945dec604e55596f3667dc Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Wed, 7 Jan 2026 22:42:20 +0200 Subject: [PATCH 02/23] fix: rename SSO migration to avoid version conflict in CI --- SSO_PR_SPLIT_PLAN.md | 449 ++++++++++++++++++ ...l => 20260107210800_sso_saml_complete.sql} | 0 2 files changed, 449 insertions(+) create mode 100644 SSO_PR_SPLIT_PLAN.md rename supabase/migrations/{20260107000000_sso_saml_complete.sql => 20260107210800_sso_saml_complete.sql} (100%) diff --git a/SSO_PR_SPLIT_PLAN.md b/SSO_PR_SPLIT_PLAN.md new file mode 100644 index 0000000000..6b0fb280e8 --- /dev/null +++ b/SSO_PR_SPLIT_PLAN.md @@ -0,0 +1,449 @@ +# SSO Feature - PR Split Plan + +## Problem Analysis + +Your boss is right: this branch combines ~10k LOC across 61 files into a single "mega-PR" that's impossible to review properly. The branch has: + +- 13 separate migration files (should be 1 editable migration) +- 6 backend endpoints totaling 67KB +- Large frontend pages (1.3k+ lines each) +- Docs, tests, mocks, scripts, and infrastructure changes all mixed together + +## Split Strategy (5 PRs, Sequential Landing) + +### PR #1: Database Schema Foundation + +**Branch:** `feature/sso-01-schema` +**Base:** `main` +**Size:** ~1 file, 600 lines + +**Files to include:** + +``` +supabase/migrations/20260107_sso_saml_complete.sql +``` + +**What to do:** + +1. Create ONE consolidated migration by merging these 13 files in chronological order: + - `20251224022658_add_sso_saml_infrastructure.sql` + - `20251224033604_add_sso_login_trigger.sql` + - `20251226121026_fix_sso_domain_auto_join.sql` + - `20251226121702_enforce_sso_signup.sql` + - `20251226133424_fix_sso_lookup_function.sql` + - `20251226182000_fix_sso_auto_join_trigger.sql` + - `20251227010100_allow_sso_metadata_signup_bypass.sql` + - `20251231000002_add_sso_saml_authentication.sql` + - `20251231175228_add_auto_join_enabled_to_sso.sql` + - `20251231191232_fix_auto_join_check.sql` + - `20260104064028_enforce_single_sso_per_org.sql` + - `20260106000000_fix_auto_join_allowed_domains.sql` + +2. Remove duplicate CREATE TABLE statements (keep only the final evolved version) +3. Keep all indexes, triggers, functions, RLS policies in final form +4. Update `supabase/schemas/prod.sql` if needed +5. Generate types: `bun types` + +**Schema should include:** + +- Tables: `org_saml_connections`, `saml_domain_mappings`, `sso_audit_logs` +- Functions: `check_org_sso_configured`, `lookup_sso_provider_for_email`, `auto_join_user_to_org_via_sso` +- Triggers: `auto_join_sso_user_trigger`, `check_sso_domain_on_signup_trigger` +- RLS policies for all tables +- Indexes for performance + +**Minimal test checklist:** + +```bash +# 1. Migration applies cleanly +supabase db reset +# Should complete without errors + +# 2. Types generate +bun types +# Should update supabase.types.ts + +# 3. Tables exist +psql $POSTGRES_URL -c "\dt org_saml_connections saml_domain_mappings sso_audit_logs" +# All 3 tables should be listed + +# 4. Functions exist +psql $POSTGRES_URL -c "\df check_org_sso_configured" +# Function should be listed + +# 5. Lint passes +bun lint:backend +``` + +--- + +### PR #2: Backend SSO Endpoints + +**Branch:** `feature/sso-02-backend` +**Base:** `feature/sso-01-schema` (after PR #1 merged, rebase to main) +**Size:** ~10 files, 2k lines + +**Files to include:** + +``` +supabase/functions/_backend/private/sso_configure.ts +supabase/functions/_backend/private/sso_management.ts +supabase/functions/_backend/private/sso_remove.ts +supabase/functions/_backend/private/sso_status.ts +supabase/functions/_backend/private/sso_test.ts +supabase/functions/_backend/private/sso_update.ts +supabase/functions/private/index.ts (route additions) +supabase/functions/sso_check/index.ts +supabase/functions/mock-sso-callback/index.ts (mock endpoint) +supabase/functions/_backend/utils/cache.ts (Cache API fixes) +supabase/functions/_backend/utils/postgres_schema.ts (schema updates) +supabase/functions/_backend/utils/supabase.types.ts (type updates) +supabase/functions/_backend/utils/version.ts (version bump if needed) +cloudflare_workers/api/index.ts (SSO routes) +.env.test (SSO test vars if added) +``` + +**Route structure:** + +- `/private/sso/configure` - Create SSO connection +- `/private/sso/update` - Update SSO config +- `/private/sso/remove` - Delete SSO connection +- `/private/sso/test` - Test SSO flow +- `/private/sso/status` - Get SSO status +- `/sso_check` - Public endpoint to check if email has SSO +- `/mock-sso-callback` - Mock IdP callback for testing + +**Minimal test checklist:** + +```bash +# 1. Lint passes +bun lint:backend +bun lint:fix + +# 2. Backend tests pass +bun test:backend + +# 3. SSO management tests pass +bun test tests/sso-management.test.ts + +# 4. SSRF unit tests pass +bun test tests/sso-ssrf-unit.test.ts + +# 5. All routes reachable +curl http://localhost:54321/functions/v1/private/sso/status +curl http://localhost:54321/functions/v1/sso_check +# Should return 401/403 (requires auth) not 404 + +# 6. Cloudflare Workers routing works +./scripts/start-cloudflare-workers.sh +curl http://localhost:8787/private/sso/status +# Should route correctly + +# 7. Mock callback works +curl http://localhost:54321/functions/v1/mock-sso-callback +# Should return HTML page +``` + +**What NOT to include:** + +- Frontend code +- E2E tests +- Documentation +- Helper scripts + +--- + +### PR #3: Frontend SSO UI & Flows + +**Branch:** `feature/sso-03-frontend` +**Base:** `feature/sso-02-backend` (after PR #2 merged, rebase to main) +**Size:** ~8 files, 2k lines + +**Files to include:** + +``` +src/pages/settings/organization/sso.vue (SSO config wizard) +src/pages/sso-login.vue (SSO login flow) +src/pages/login.vue (SSO redirect detection) +src/composables/useSSODetection.ts (SSO detection logic) +src/layouts/settings.vue (layout updates for SSO tab) +src/constants/organizationTabs.ts (add SSO tab) +src/types/supabase.types.ts (frontend types) +src/auto-imports.d.ts (auto-import updates) +messages/en.json (i18n strings) +``` + +**Key features:** + +- SSO configuration wizard in organization settings +- SSO login page with email detection +- Login page SSO redirect handling +- Composable for SSO detection/initiation +- Organization settings tab for SSO + +**Minimal test checklist:** + +```bash +# 1. Lint passes +bun lint +bun lint:fix + +# 2. Type check passes +bun typecheck + +# 3. Frontend builds +bun build +# Should complete without errors + +# 4. Dev server runs +bun serve:local +# Navigate to /settings/organization/sso +# Should load without console errors + +# 5. SSO wizard renders +# - Entity ID display +# - Metadata URL input +# - Domain configuration +# - Test connection button +# All sections should be visible + +# 6. SSO login page works +# Navigate to /sso-login +# Enter email with @example.com +# Should show "Continue with SSO" button + +# 7. Login page detects SSO +# Navigate to /login?from_sso=true +# Should show "Signing you in..." message +``` + +**What NOT to include:** + +- E2E tests (next PR) +- Documentation (next PR) +- Helper scripts (next PR) + +--- + +### PR #4: Testing Infrastructure + +**Branch:** `feature/sso-04-tests` +**Base:** `feature/sso-03-frontend` (after PR #3 merged, rebase to main) +**Size:** ~5 files, 1k lines + +**Files to include:** + +``` +tests/sso-management.test.ts (backend unit tests) +tests/sso-ssrf-unit.test.ts (SSRF protection tests) +tests/test-utils.ts (SSO test helpers) +playwright/e2e/sso.spec.ts (E2E tests) +vitest.config.ts (test config updates) +``` + +**Test coverage:** + +- Backend SSO management API (configure, update, remove, test, status) +- SSRF protection (metadata URL validation) +- Frontend SSO wizard flow (Playwright) +- SSO login flow (Playwright) +- Auto-join trigger behavior +- Audit log creation + +**Minimal test checklist:** + +```bash +# 1. Backend tests pass +bun test tests/sso-management.test.ts +bun test tests/sso-ssrf-unit.test.ts + +# 2. E2E tests pass +bun test:front playwright/e2e/sso.spec.ts + +# 3. All tests pass together +bun test:backend +bun test:front + +# 4. Cloudflare Workers tests pass +bun test:cloudflare:backend + +# 5. Test coverage acceptable +bun test --coverage +# Should show >80% coverage for SSO files +``` + +--- + +### PR #5: Documentation & Utilities + +**Branch:** `feature/sso-05-docs` +**Base:** `feature/sso-04-tests` (after PR #4 merged, rebase to main) +**Size:** ~10 files, 2k lines + +**Files to include:** + +``` +docs/sso-setup.md (setup guide) +docs/sso-production.md (production deployment guide) +docs/MOCK_SSO_TESTING.md (testing guide) +restart-auth-with-saml.sh (reset script) +restart-auth-with-saml-v2.sh (alternate reset script) +verify-sso-routes.sh (route verification script) +temp-sso-trace.ts (debugging utility, can be .gitignore'd) +.gitignore (add temp files) +supabase/config.toml (SSO config if needed) +.github/workflows/build_and_deploy.yml (CI updates if needed) +``` + +**Documentation should cover:** + +- How to configure SSO for an organization +- How to add SAML providers (Okta, Azure AD, Google) +- How to test SSO locally with mock callback +- How to verify SSO routes are working +- How to reset Supabase Auth SSO config +- Production deployment considerations +- Troubleshooting common issues + +**Minimal test checklist:** + +```bash +# 1. Scripts are executable +chmod +x restart-auth-with-saml.sh +chmod +x verify-sso-routes.sh + +# 2. Verify routes script works +./verify-sso-routes.sh +# Should check all SSO endpoints + +# 3. Documentation is complete +# Read through each doc file +# Verify all steps are clear +# Verify all commands work + +# 4. Markdown lint passes (if configured) +markdownlint docs/sso-*.md docs/MOCK_SSO_TESTING.md +``` + +--- + +## Landing Sequence + +### Before Any PR + +1. Create feature branch from main: `git checkout -b feature/sso-01-schema main` +2. Run full test suite: `bun test:all` +3. Ensure main is passing + +### PR #1: Schema + +1. Create consolidated migration +2. Test: `supabase db reset && bun types` +3. Push PR, get review, merge to main +4. **Verify**: Schema deployed to development environment + +### PR #2: Backend + +1. Rebase on main: `git rebase main` +2. Copy backend files from original branch +3. Test: `bun test:backend && bun lint:backend` +4. Push PR, get review, merge to main +5. **Verify**: Backend endpoints work in development + +### PR #3: Frontend + +1. Rebase on main: `git rebase main` +2. Copy frontend files from original branch +3. Test: `bun lint && bun typecheck && bun build` +4. Push PR, get review, merge to main +5. **Verify**: UI renders in development + +### PR #4: Tests + +1. Rebase on main: `git rebase main` +2. Copy test files from original branch +3. Test: `bun test:all` +4. Push PR, get review, merge to main +5. **Verify**: All tests pass in CI + +### PR #5: Docs + +1. Rebase on main: `git rebase main` +2. Copy docs/scripts from original branch +3. Test: Run verification scripts +4. Push PR, get review, merge to main +5. **Verify**: Documentation is accessible + +### Final Integration Test + +After all 5 PRs are merged to main: + +```bash +# 1. Fresh clone +git clone sso-integration-test +cd sso-integration-test + +# 2. Database setup +supabase start +supabase db reset +bun types + +# 3. Start all services +bun backend & +./scripts/start-cloudflare-workers.sh & +bun serve:local & + +# 4. Full SSO flow test +# - Navigate to /settings/organization/sso as admin +# - Configure SSO with mock IdP +# - Test SSO login with test user +# - Verify user is created and enrolled in org +# - Check audit logs + +# 5. Run full test suite +bun test:all +bun test:cloudflare:all +bun test:front +``` + +--- + +## Common Pitfalls to Avoid + +### ❌ DON'T: + +- Mix unrelated changes (formatting, refactoring) into PRs +- Include generated files (`src/typed-router.d.ts`) unless consistent +- Edit previously committed migrations +- Skip lint/type checks before pushing +- Chain PRs without rebasing on main first +- Batch multiple independent features into one PR + +### ✅ DO: + +- Keep each PR focused on one concern (schema, backend, frontend, tests, docs) +- Run `bun lint:fix` before every commit +- Rebase on main after each PR merge +- Update PR descriptions with testing steps +- Mark PRs as draft until CI passes +- Request review only when all checks are green +- Include "Closes #" in final PR + +--- + +## Why This Works + +1. **Reviewable size**: Each PR is 200-1k lines vs 10k lines +2. **Clear dependencies**: Schema → Backend → Frontend → Tests → Docs +3. **Incremental testing**: Each layer is tested before building on it +4. **Rollback safety**: Can revert individual PRs without breaking others +5. **Parallel review**: Multiple reviewers can work on different PRs +6. **Clear scope**: Each PR has one purpose, easy to verify +7. **Migration best practice**: Single consolidated migration, not 13 files + +Your boss will be happy because: + +- Each PR is immediately reviewable (not "contains another PR inside") +- Each PR passes lint/tests before review +- Each PR has clear acceptance criteria +- The feature can be reviewed layer-by-layer instead of all-at-once diff --git a/supabase/migrations/20260107000000_sso_saml_complete.sql b/supabase/migrations/20260107210800_sso_saml_complete.sql similarity index 100% rename from supabase/migrations/20260107000000_sso_saml_complete.sql rename to supabase/migrations/20260107210800_sso_saml_complete.sql From 2882d81f4ee1af4880d7814a8ef751b848e3adc2 Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Wed, 7 Jan 2026 22:57:06 +0200 Subject: [PATCH 03/23] fix: restore test users in seed.sql required by CLI tests --- supabase/seed.sql | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/supabase/seed.sql b/supabase/seed.sql index b05621ba9f..da08e89a15 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -52,7 +52,9 @@ BEGIN ('00000000-0000-0000-0000-000000000000', 'c591b04e-cf29-4945-b9a0-776d0672061a', 'authenticated', 'authenticated', 'admin@capgo.app', '$2a$10$I4wgil64s1Kku/7aUnCOVuc1W5nCAeeKvHMiSKk10jo1J5fSVkK1S', NOW(), NOW(), 'oljikwwipqrkwilfsyto', NOW(), '', NULL, '', '', NULL, NOW(), '{"provider": "email", "providers": ["email"]}', '{"test_identifier": "test_admin"}', 'f', NOW(), NOW(), NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL), ('00000000-0000-0000-0000-000000000000', '6aa76066-55ef-4238-ade6-0b32334a4097', 'authenticated', 'authenticated', 'test@capgo.app', '$2a$10$0CErXxryZPucjJWq3O7qXeTJgN.tnNU5XCZy9pXKDWRi/aS9W7UFi', NOW(), NOW(), 'oljikwwipqrkwilfsyty', NOW(), '', NULL, '', '', NULL, NOW(), '{"provider": "email", "providers": ["email"]}', '{"test_identifier": "test_user"}', 'f', NOW(), NOW(), NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL), ('00000000-0000-0000-0000-000000000000', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', 'authenticated', 'authenticated', 'test2@capgo.app', '$2a$10$0CErXxryZPucjJWq3O7qXeTJgN.tnNU5XCZy9pXKDWRi/aS9W7UFi', NOW(), NOW(), 'oljikwwipqrkwilfsytt', NOW(), '', NULL, '', '', NULL, NOW(), '{"provider": "email", "providers": ["email"]}', '{"test_identifier": "test_user2"}', 'f', NOW(), NOW(), NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL), - ('00000000-0000-0000-0000-000000000000', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', 'authenticated', 'authenticated', 'stats@capgo.app', '$2a$10$0CErXxryZPucjJWq3O7qXeTJgN.tnNU5XCZy9pXKDWRi/aS9W7UFi', NOW(), NOW(), 'oljikwwipqrkwilfsyts', NOW(), '', NULL, '', '', NULL, NOW(), '{"provider": "email", "providers": ["email"]}', '{"test_identifier": "test_stats"}', 'f', NOW(), NOW(), NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL); + ('00000000-0000-0000-0000-000000000000', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', 'authenticated', 'authenticated', 'stats@capgo.app', '$2a$10$0CErXxryZPucjJWq3O7qXeTJgN.tnNU5XCZy9pXKDWRi/aS9W7UFi', NOW(), NOW(), 'oljikwwipqrkwilfsyts', NOW(), '', NULL, '', '', NULL, NOW(), '{"provider": "email", "providers": ["email"]}', '{"test_identifier": "test_stats"}', 'f', NOW(), NOW(), NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL), + ('00000000-0000-0000-0000-000000000000', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', 'authenticated', 'authenticated', 'rls@capgo.app', '$2a$10$0CErXxryZPucjJWq3O7qXeTJgN.tnNU5XCZy9pXKDWRi/aS9W7UFi', NOW(), NOW(), 'oljikwwipqrkwilfsytr', NOW(), '', NULL, '', '', NULL, NOW(), '{"provider": "email", "providers": ["email"]}', '{"test_identifier": "test_rls"}', 'f', NOW(), NOW(), NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL), + ('00000000-0000-0000-0000-000000000000', 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a81', 'authenticated', 'authenticated', 'cli_hashed@capgo.app', '$2a$10$0CErXxryZPucjJWq3O7qXeTJgN.tnNU5XCZy9pXKDWRi/aS9W7UFi', NOW(), NOW(), 'oljikwwipqrkwilfsytc', NOW(), '', NULL, '', '', NULL, NOW(), '{"provider": "email", "providers": ["email"]}', '{"test_identifier": "test_cli_hashed"}', 'f', NOW(), NOW(), NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL); INSERT INTO "public"."deleted_account" ("created_at", "email", "id") VALUES (NOW(), encode(extensions.digest('deleted@capgo.app'::bytea, 'sha256'::text)::bytea, 'hex'::text), '00000000-0000-0000-0000-000000000001'); @@ -229,7 +231,9 @@ BEGIN (NOW(), NOW(), 'sub_2', 'cus_Q38uE91NP8Ufqc', 'succeeded', 'prod_LQIregjtNduh4q', NOW() + interval '15 days', NULL, 't', 2, NOW() - interval '15 days', NOW() + interval '15 days', false, false, false, false), (NOW(), NOW(), 'sub_3', 'cus_Pa0f3M6UCQ8g5Q', 'succeeded', 'prod_LQIregjtNduh4q', NOW() + interval '15 days', NULL, 't', 2, NOW() - interval '15 days', NOW() + interval '15 days', false, false, false, false), (NOW(), NOW(), 'sub_4', 'cus_NonOwner', 'succeeded', 'prod_LQIregjtNduh4q', NOW() + interval '15 days', NULL, 't', 2, NOW() - interval '15 days', NOW() + interval '15 days', false, false, false, false), - (NOW(), NOW(), 'sub_5', 'cus_StatsTest', 'succeeded', 'prod_LQIregjtNduh4q', NOW() + interval '15 days', NULL, 't', 2, NOW() - interval '15 days', NOW() + interval '15 days', false, false, false, false); + (NOW(), NOW(), 'sub_5', 'cus_StatsTest', 'succeeded', 'prod_LQIregjtNduh4q', NOW() + interval '15 days', NULL, 't', 2, NOW() - interval '15 days', NOW() + interval '15 days', false, false, false, false), + (NOW(), NOW(), 'sub_rls', 'cus_RLSTest', 'succeeded', 'prod_LQIregjtNduh4q', NOW() + interval '15 days', NULL, 't', 2, NOW() - interval '15 days', NOW() + interval '15 days', false, false, false, false), + (NOW(), NOW(), 'sub_cli_hashed', 'cus_cli_hashed_test_123', 'succeeded', 'prod_LQIregjtNduh4q', NOW() + interval '15 days', NULL, 't', 2, NOW() - interval '15 days', NOW() + interval '15 days', false, false, false, false); -- Do not insert new orgs ALTER TABLE public.users DISABLE TRIGGER generate_org_on_user_create; @@ -237,7 +241,9 @@ BEGIN ('2022-06-03 05:54:15+00', '', 'admin', 'Capgo', NULL, 'admin@capgo.app', 'c591b04e-cf29-4945-b9a0-776d0672061a', NOW(), 't', 't'), ('2022-06-03 05:54:15+00', '', 'test', 'Capgo', NULL, 'test@capgo.app', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), 't', 't'), ('2022-06-03 05:54:15+00', '', 'test2', 'Capgo', NULL, 'test2@capgo.app', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', NOW(), 't', 't'), - ('2022-06-03 05:54:15+00', '', 'stats', 'Capgo', NULL, 'stats@capgo.app', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', NOW(), 't', 't'); + ('2022-06-03 05:54:15+00', '', 'stats', 'Capgo', NULL, 'stats@capgo.app', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', NOW(), 't', 't'), + ('2022-06-03 05:54:15+00', '', 'rls', 'Capgo', NULL, 'rls@capgo.app', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', NOW(), 't', 't'), + ('2022-06-03 05:54:15+00', '', 'cli_hashed', 'Capgo', NULL, 'cli_hashed@capgo.app', 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a81', NOW(), 't', 't'); ALTER TABLE public.users ENABLE TRIGGER generate_org_on_user_create; ALTER TABLE public.orgs DISABLE TRIGGER generate_org_user_on_org_create; @@ -246,7 +252,9 @@ BEGIN ('046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Demo org', 'test@capgo.app', 'cus_Q38uE91NP8Ufqc'), ('34a8c55d-2d0f-4652-a43f-684c7a9403ac', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', NOW(), NOW(), '', 'Test2 org', 'test2@capgo.app', 'cus_Pa0f3M6UCQ8g5Q'), ('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', NOW(), NOW(), '', 'Non-Owner Org', 'test2@capgo.app', 'cus_NonOwner'), - ('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', NOW(), NOW(), '', 'Stats Test Org', 'stats@capgo.app', 'cus_StatsTest'); + ('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', NOW(), NOW(), '', 'Stats Test Org', 'stats@capgo.app', 'cus_StatsTest'), + ('c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', NOW(), NOW(), '', 'RLS Test Org', 'rls@capgo.app', 'cus_RLSTest'), + ('f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b92', 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a81', NOW(), NOW(), '', 'CLI Hashed Test Org', 'cli_hashed@capgo.app', 'cus_cli_hashed_test_123'); ALTER TABLE public.orgs ENABLE TRIGGER generate_org_user_on_org_create; INSERT INTO public.usage_credit_grants ( @@ -457,7 +465,9 @@ BEGIN ('34a8c55d-2d0f-4652-a43f-684c7a9403ac', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', 'super_admin'::"public"."user_min_right", null, null), ('046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', 'upload'::"public"."user_min_right", null, null), ('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', '6aa76066-55ef-4238-ade6-0b32334a4097', 'read'::"public"."user_min_right", null, null), - ('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', 'super_admin'::"public"."user_min_right", null, null); + ('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', 'super_admin'::"public"."user_min_right", null, null), + ('c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', 'super_admin'::"public"."user_min_right", null, null), + ('f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b92', 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a81', 'super_admin'::"public"."user_min_right", null, null); INSERT INTO "public"."apikeys" ("id", "created_at", "user_id", "key", "mode", "updated_at", "name") VALUES (1, NOW(), 'c591b04e-cf29-4945-b9a0-776d0672061a', 'c591b04e-cf29-4945-b9a0-776d0672061e', 'upload', NOW(), 'admin upload'), @@ -475,12 +485,30 @@ BEGIN (12, NOW(), '6aa76066-55ef-4238-ade6-0b32334a4097', '8b2c3d4e-5f6a-4c7b-8d9e-0f1a2b3c4d5a', 'all', NOW(), 'apikey test update mode'), (13, NOW(), '6aa76066-55ef-4238-ade6-0b32334a4097', '8b2c3d4e-5f6a-4c7b-8d9e-0f1a2b3c4d5d', 'write', NOW(), 'apikey test update apps'), -- Dedicated user and API key for statistics tests - (14, NOW(), '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', '8b2c3d4e-5f6a-4c7b-8d9e-0f1a2b3c4d5e', 'all', NOW(), 'stats test all'); + (14, NOW(), '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', '8b2c3d4e-5f6a-4c7b-8d9e-0f1a2b3c4d5e', 'all', NOW(), 'stats test all'), + -- Dedicated user and API key for RLS hashed apikey tests (isolated to prevent interference) + (15, NOW(), '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', '9c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f', 'all', NOW(), 'rls test all'), + -- Dedicated user and API key for CLI hashed apikey tests (isolated to prevent interference) + (110, NOW(), 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a81', 'a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c03', 'all', NOW(), 'cli hashed test all'); + + -- Hashed API key for testing (hash of 'test-hashed-apikey-for-auth-test') + -- Used by 07_auth_functions.sql tests + INSERT INTO "public"."apikeys" ("id", "created_at", "user_id", "key", "key_hash", "mode", "updated_at", "name") VALUES + (100, NOW(), '6aa76066-55ef-4238-ade6-0b32334a4097', NULL, encode(extensions.digest('test-hashed-apikey-for-auth-test', 'sha256'), 'hex'), 'all', NOW(), 'test hashed all'); + + -- Expired hashed API key for testing (expired 1 day ago) + INSERT INTO "public"."apikeys" ("id", "created_at", "user_id", "key", "key_hash", "mode", "updated_at", "name", "expires_at") VALUES + (101, NOW(), '6aa76066-55ef-4238-ade6-0b32334a4097', NULL, encode(extensions.digest('expired-hashed-key-for-test', 'sha256'), 'hex'), 'all', NOW(), 'test expired hashed', NOW() - INTERVAL '1 day'); + + -- Expired plain API key for testing (expired 1 day ago) + INSERT INTO "public"."apikeys" ("id", "created_at", "user_id", "key", "mode", "updated_at", "name", "expires_at") VALUES + (102, NOW(), '6aa76066-55ef-4238-ade6-0b32334a4097', 'expired-plain-key-for-test', 'all', NOW(), 'test expired plain', NOW() - INTERVAL '1 day'); INSERT INTO "public"."apps" ("created_at", "app_id", "icon_url", "name", "last_version", "updated_at", "owner_org", "user_id") VALUES (NOW(), 'com.demoadmin.app', '', 'Demo Admin app', '1.0.0', NOW(), '22dbad8a-b885-4309-9b3b-a09f8460fb6d', 'c591b04e-cf29-4945-b9a0-776d0672061a'), (NOW(), 'com.demo.app', '', 'Demo app', '1.0.0', NOW(), '046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097'), - (NOW(), 'com.stats.app', '', 'Stats Test App', '1.0.0', NOW(), 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d'); + (NOW(), 'com.stats.app', '', 'Stats Test App', '1.0.0', NOW(), 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d'), + (NOW(), 'com.rls.app', '', 'RLS Test App', '1.0.0', NOW(), 'c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e'); INSERT INTO "public"."app_versions" ("id", "created_at", "app_id", "name", "r2_path", "updated_at", "deleted", "external_url", "checksum", "session_key", "storage_provider", "owner_org", "user_id", "comment", "link") VALUES (1, NOW(), 'com.demo.app', 'builtin', NULL, NOW(), 't', NULL, NULL, NULL, 'supabase', '046a36ac-e03c-4590-9257-bd6c9dba9ee8', NULL, NULL, NULL), @@ -528,7 +556,7 @@ BEGIN -- Drop replicated orgs but keet the the seed ones DELETE from "public"."orgs" where POSITION('organization' in orgs.name)=1; - PERFORM setval('public.apikeys_id_seq', 15, false); + PERFORM setval('public.apikeys_id_seq', 111, false); PERFORM setval('public.app_versions_id_seq', 14, false); PERFORM setval('public.channel_id_seq', 5, false); PERFORM setval('public.deploy_history_id_seq', 5, false); From 1690800b1db7570427c4bcf2da7251638ca8c521 Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Wed, 7 Jan 2026 23:51:28 +0200 Subject: [PATCH 04/23] fix: use LENGTH() instead of empty string comparison in SQL --- supabase/migrations/20260107210800_sso_saml_complete.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/supabase/migrations/20260107210800_sso_saml_complete.sql b/supabase/migrations/20260107210800_sso_saml_complete.sql index af43826536..007f58ba5b 100644 --- a/supabase/migrations/20260107210800_sso_saml_complete.sql +++ b/supabase/migrations/20260107210800_sso_saml_complete.sql @@ -212,7 +212,7 @@ DECLARE BEGIN v_domain := lower(split_part(p_email, '@', 2)); - IF v_domain IS NULL OR v_domain = '' THEN + IF v_domain IS NULL OR LENGTH(v_domain) = 0 THEN RETURN false; END IF; @@ -324,7 +324,7 @@ BEGIN -- Extract domain from email v_domain := lower(split_part(p_email, '@', 2)); - IF v_domain IS NULL OR v_domain = '' THEN + IF v_domain IS NULL OR LENGTH(v_domain) = 0 THEN RETURN; END IF; @@ -364,7 +364,7 @@ DECLARE BEGIN v_domain := lower(split_part(p_email, '@', 2)); - IF v_domain IS NULL OR v_domain = '' THEN + IF v_domain IS NULL OR LENGTH(v_domain) = 0 THEN RETURN NULL; END IF; @@ -476,7 +476,7 @@ DECLARE BEGIN v_domain := lower(split_part(p_email, '@', 2)); - IF v_domain IS NULL OR v_domain = '' THEN + IF v_domain IS NULL OR LENGTH(v_domain) = 0 THEN RETURN; END IF; From c13d07549c83f01c706311e2706b0b40ccd249a1 Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Wed, 7 Jan 2026 23:52:31 +0200 Subject: [PATCH 05/23] fix: replace remaining empty string comparison in validation trigger --- supabase/migrations/20260107210800_sso_saml_complete.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/migrations/20260107210800_sso_saml_complete.sql b/supabase/migrations/20260107210800_sso_saml_complete.sql index 007f58ba5b..b6feec532c 100644 --- a/supabase/migrations/20260107210800_sso_saml_complete.sql +++ b/supabase/migrations/20260107210800_sso_saml_complete.sql @@ -729,7 +729,7 @@ BEGIN END IF; -- Validate entity_id format - IF NEW.entity_id IS NULL OR NEW.entity_id = '' THEN + IF NEW.entity_id IS NULL OR LENGTH(NEW.entity_id) = 0 THEN RAISE EXCEPTION 'entity_id is required'; END IF; From 673342e669e0f0b0f2827922265a602906c666ad Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Thu, 8 Jan 2026 07:12:49 +0200 Subject: [PATCH 06/23] security: add authorization checks and fix auto-join logic - Add auth.uid() validation to auto_enroll_sso_user and auto_join_user_to_orgs_by_email - Add email verification against auth.users to prevent user spoofing - Add auto_join_enabled check to domain-mapping loop (join org_saml_connections) - Remove overly permissive audit log INSERT policy (SECURITY DEFINER functions bypass RLS) --- .../20260107210800_sso_saml_complete.sql | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/supabase/migrations/20260107210800_sso_saml_complete.sql b/supabase/migrations/20260107210800_sso_saml_complete.sql index b6feec532c..c353669ed3 100644 --- a/supabase/migrations/20260107210800_sso_saml_complete.sql +++ b/supabase/migrations/20260107210800_sso_saml_complete.sql @@ -405,7 +405,19 @@ AS $$ DECLARE v_org record; v_already_member boolean; + v_auth_email text; BEGIN + -- Authorization: reject calls where p_user_id does not match the authenticated user + IF p_user_id != auth.uid() THEN + RAISE EXCEPTION 'Unauthorized: cannot enroll other users (user_id mismatch)'; + END IF; + + -- Email validation: ensure p_email matches the email in auth.users for p_user_id + SELECT email INTO v_auth_email FROM auth.users WHERE id = p_user_id; + IF v_auth_email IS NULL OR lower(v_auth_email) != lower(p_email) THEN + RAISE EXCEPTION 'Unauthorized: email mismatch for user'; + END IF; + -- Find organizations with this SSO provider that have auto-join enabled FOR v_org IN SELECT DISTINCT @@ -473,7 +485,19 @@ AS $$ DECLARE v_domain text; v_org record; + v_auth_email text; BEGIN + -- Authorization: reject calls where p_user_id does not match the authenticated user + IF p_user_id != auth.uid() THEN + RAISE EXCEPTION 'Unauthorized: cannot join other users to orgs (user_id mismatch)'; + END IF; + + -- Email validation: ensure p_email matches the email in auth.users for p_user_id + SELECT email INTO v_auth_email FROM auth.users WHERE id = p_user_id; + IF v_auth_email IS NULL OR lower(v_auth_email) != lower(p_email) THEN + RAISE EXCEPTION 'Unauthorized: email mismatch for user'; + END IF; + v_domain := lower(split_part(p_email, '@', 2)); IF v_domain IS NULL OR LENGTH(v_domain) = 0 THEN @@ -492,8 +516,10 @@ BEGIN SELECT DISTINCT o.id, o.name FROM public.orgs o INNER JOIN public.saml_domain_mappings sdm ON sdm.org_id = o.id + INNER JOIN public.org_saml_connections osc ON osc.org_id = o.id WHERE sdm.domain = v_domain AND sdm.verified = true + AND osc.auto_join_enabled = true AND NOT EXISTS ( SELECT 1 FROM public.org_users ou WHERE ou.user_id = p_user_id AND ou.org_id = o.id @@ -953,12 +979,8 @@ CREATE POLICY "Org admins can view org SSO audit logs" ) ); --- System can insert audit logs (SECURITY DEFINER functions) -CREATE POLICY "System can insert audit logs" ON public.sso_audit_logs FOR -INSERT - TO authenticated -WITH - CHECK (true); +-- NOTE: No INSERT policy needed - SECURITY DEFINER functions bypass RLS +-- Removing overly permissive policy that allowed any authenticated user to insert audit logs -- ============================================================================ -- GRANTS: Ensure proper permissions From ae73bc77ee41ea71ce529e7d8b102b45eaf7e68e Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Thu, 8 Jan 2026 07:15:14 +0200 Subject: [PATCH 07/23] docs: fix migration filename in SSO_PR_SPLIT_PLAN.md --- SSO_PR_SPLIT_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SSO_PR_SPLIT_PLAN.md b/SSO_PR_SPLIT_PLAN.md index 6b0fb280e8..66a1a06782 100644 --- a/SSO_PR_SPLIT_PLAN.md +++ b/SSO_PR_SPLIT_PLAN.md @@ -20,7 +20,7 @@ Your boss is right: this branch combines ~10k LOC across 61 files into a single **Files to include:** ``` -supabase/migrations/20260107_sso_saml_complete.sql +supabase/migrations/20260107210800_sso_saml_complete.sql ``` **What to do:** From 95bd3e7cd43f42ca83b243207eb5b561fd5f258f Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Thu, 8 Jan 2026 07:16:58 +0200 Subject: [PATCH 08/23] fix: use correct foreign key for auto_join_enabled check Join org_saml_connections via sso_connection_id foreign key instead of org_id to check auto_join_enabled on the specific SSO connection referenced by the domain mapping, not just any connection for the org. --- supabase/migrations/20260107210800_sso_saml_complete.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/migrations/20260107210800_sso_saml_complete.sql b/supabase/migrations/20260107210800_sso_saml_complete.sql index c353669ed3..d570d8bf8a 100644 --- a/supabase/migrations/20260107210800_sso_saml_complete.sql +++ b/supabase/migrations/20260107210800_sso_saml_complete.sql @@ -516,7 +516,7 @@ BEGIN SELECT DISTINCT o.id, o.name FROM public.orgs o INNER JOIN public.saml_domain_mappings sdm ON sdm.org_id = o.id - INNER JOIN public.org_saml_connections osc ON osc.org_id = o.id + INNER JOIN public.org_saml_connections osc ON osc.id = sdm.sso_connection_id WHERE sdm.domain = v_domain AND sdm.verified = true AND osc.auto_join_enabled = true From 3dd3de7609be5e19d80f436ef6abc81339fa733d Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Thu, 8 Jan 2026 07:20:59 +0200 Subject: [PATCH 09/23] fix: remove ineffective auth.identities check and restrict trigger grants - Remove auth.identities check from BEFORE INSERT trigger (identities don't exist yet at that point, created AFTER user insert) - Rely exclusively on metadata-based SSO validation (sso_provider_id in raw_user_meta_data) - Remove EXECUTE grants to authenticated role for trigger functions (trigger_auto_join_on_user_create, trigger_auto_join_on_user_update) - These should only be callable by postgres/supabase_auth_admin in trigger context, not by regular authenticated users - Remove unused v_provider_count variable --- .../20260107210800_sso_saml_complete.sql | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/supabase/migrations/20260107210800_sso_saml_complete.sql b/supabase/migrations/20260107210800_sso_saml_complete.sql index d570d8bf8a..9b51d0fd20 100644 --- a/supabase/migrations/20260107210800_sso_saml_complete.sql +++ b/supabase/migrations/20260107210800_sso_saml_complete.sql @@ -661,7 +661,6 @@ DECLARE v_email text; v_domain text; v_sso_required boolean; - v_provider_count integer; v_metadata_provider_id uuid; v_metadata_allows boolean := false; BEGIN @@ -713,16 +712,9 @@ BEGIN END IF; END IF; - -- Check if this is an SSO signup (will have provider info in auth.identities) - SELECT COUNT(*) INTO v_provider_count - FROM auth.identities - WHERE user_id = NEW.id - AND provider != 'email'; - - -- If signing up via SSO provider, allow it - IF v_provider_count > 0 THEN - RETURN NEW; - END IF; + -- NOTE: Cannot check auth.identities here - identity records are created AFTER user insert + -- in AFTER INSERT triggers, so NEW.id does not yet exist in auth.identities table. + -- We rely exclusively on the metadata-based validation above (sso_provider_id in raw_user_meta_data). -- Check if domain requires SSO v_sso_required := public.check_sso_required_for_domain(v_email); @@ -1023,11 +1015,8 @@ EXECUTE ON FUNCTION public.auto_enroll_sso_user TO authenticated; GRANT EXECUTE ON FUNCTION public.auto_join_user_to_orgs_by_email TO authenticated; -GRANT -EXECUTE ON FUNCTION public.trigger_auto_join_on_user_create TO authenticated; - -GRANT -EXECUTE ON FUNCTION public.trigger_auto_join_on_user_update TO authenticated; +-- NOTE: Trigger functions should NOT be granted to authenticated - they are only called by DB triggers +-- Only postgres and supabase_auth_admin (trigger context) should have EXECUTE permissions -- Grant special permissions to auth admin for trigger functions GRANT From 57be01fb0b9399ab520b840d75854a7b363c100b Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Thu, 8 Jan 2026 07:28:03 +0200 Subject: [PATCH 10/23] feat: add PII compliance for SSO audit logs - Add cleanup_old_sso_audit_logs() function to delete logs >90 days - Anonymize email addresses for deleted users (user_id IS NULL) - Register daily cron task at 3 AM UTC for automated cleanup - Addresses GDPR/CCPA data retention requirements - Email anonymization format: deleted-user-{uuid}@anonymized.local --- .../20260107210800_sso_saml_complete.sql | 44 ++++++++++++++++++- ...60108052411_add_sso_audit_cleanup_cron.sql | 29 ++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260108052411_add_sso_audit_cleanup_cron.sql diff --git a/supabase/migrations/20260107210800_sso_saml_complete.sql b/supabase/migrations/20260107210800_sso_saml_complete.sql index 9b51d0fd20..85474322f1 100644 --- a/supabase/migrations/20260107210800_sso_saml_complete.sql +++ b/supabase/migrations/20260107210800_sso_saml_complete.sql @@ -1029,4 +1029,46 @@ supabase_auth_admin; GRANT EXECUTE ON FUNCTION public.trigger_auto_join_on_user_update TO postgres, -supabase_auth_admin; \ No newline at end of file +supabase_auth_admin; + +-- ============================================================================ +-- FUNCTION: Cleanup old SSO audit logs (PII compliance) +-- ============================================================================ + +-- Function to cleanup old SSO audit logs for GDPR/CCPA compliance +-- Removes logs older than 90 days and anonymizes PII for deleted users +CREATE OR REPLACE FUNCTION public.cleanup_old_sso_audit_logs() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_deleted_count integer; + v_anonymized_count integer; +BEGIN + -- Delete audit logs older than 90 days (data retention policy) + DELETE FROM public.sso_audit_logs + WHERE timestamp < NOW() - INTERVAL '90 days'; + + GET DIAGNOSTICS v_deleted_count = ROW_COUNT; + + -- Anonymize email addresses for deleted users (user_id IS NULL but email still exists) + -- This handles the case where user was deleted but their email remains in audit logs + UPDATE public.sso_audit_logs + SET email = 'deleted-user-' || id::text || '@anonymized.local' + WHERE user_id IS NULL + AND email IS NOT NULL + AND email NOT LIKE 'deleted-user-%@anonymized.local'; + + GET DIAGNOSTICS v_anonymized_count = ROW_COUNT; + + RAISE NOTICE 'SSO audit log cleanup: deleted % old records, anonymized % emails for deleted users', + v_deleted_count, v_anonymized_count; +END; +$$; + +COMMENT ON FUNCTION public.cleanup_old_sso_audit_logs IS 'Cleans up SSO audit logs older than 90 days and anonymizes PII for deleted users. Run daily via cron for GDPR/CCPA compliance.'; + +-- Grant execute to service_role for cron job +GRANT EXECUTE ON FUNCTION public.cleanup_old_sso_audit_logs TO service_role; \ No newline at end of file diff --git a/supabase/migrations/20260108052411_add_sso_audit_cleanup_cron.sql b/supabase/migrations/20260108052411_add_sso_audit_cleanup_cron.sql new file mode 100644 index 0000000000..20b7c59465 --- /dev/null +++ b/supabase/migrations/20260108052411_add_sso_audit_cleanup_cron.sql @@ -0,0 +1,29 @@ +-- Add SSO audit log cleanup to cron tasks for PII compliance +-- Runs daily at 3:00 AM UTC to delete logs older than 90 days and anonymize deleted users' emails + +INSERT INTO public.cron_tasks ( + name, + description, + task_type, + target, + run_at_hour, + run_at_minute, + enabled +) VALUES ( + 'sso_audit_cleanup', + 'Cleanup old SSO audit logs (90+ days) and anonymize PII for deleted users (GDPR/CCPA compliance)', + 'function', + 'public.cleanup_old_sso_audit_logs()', + 3, -- 3 AM UTC + 0, -- 0 minutes + true +) +ON CONFLICT (name) DO UPDATE SET + description = EXCLUDED.description, + task_type = EXCLUDED.task_type, + target = EXCLUDED.target, + run_at_hour = EXCLUDED.run_at_hour, + run_at_minute = EXCLUDED.run_at_minute, + enabled = EXCLUDED.enabled; + +COMMENT ON TABLE public.sso_audit_logs IS 'Audit trail for all SSO authentication and configuration events. Auto-cleanup: logs older than 90 days are deleted daily at 3 AM UTC. PII (email) is anonymized for deleted users.'; From 6df041d4eb9c851d3508c18afea7d1c81299df89 Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Thu, 8 Jan 2026 07:30:50 +0200 Subject: [PATCH 11/23] chore: remove SSO_PR_SPLIT_PLAN.md planning document --- SSO_PR_SPLIT_PLAN.md | 449 ------------------------------------------- 1 file changed, 449 deletions(-) delete mode 100644 SSO_PR_SPLIT_PLAN.md diff --git a/SSO_PR_SPLIT_PLAN.md b/SSO_PR_SPLIT_PLAN.md deleted file mode 100644 index 66a1a06782..0000000000 --- a/SSO_PR_SPLIT_PLAN.md +++ /dev/null @@ -1,449 +0,0 @@ -# SSO Feature - PR Split Plan - -## Problem Analysis - -Your boss is right: this branch combines ~10k LOC across 61 files into a single "mega-PR" that's impossible to review properly. The branch has: - -- 13 separate migration files (should be 1 editable migration) -- 6 backend endpoints totaling 67KB -- Large frontend pages (1.3k+ lines each) -- Docs, tests, mocks, scripts, and infrastructure changes all mixed together - -## Split Strategy (5 PRs, Sequential Landing) - -### PR #1: Database Schema Foundation - -**Branch:** `feature/sso-01-schema` -**Base:** `main` -**Size:** ~1 file, 600 lines - -**Files to include:** - -``` -supabase/migrations/20260107210800_sso_saml_complete.sql -``` - -**What to do:** - -1. Create ONE consolidated migration by merging these 13 files in chronological order: - - `20251224022658_add_sso_saml_infrastructure.sql` - - `20251224033604_add_sso_login_trigger.sql` - - `20251226121026_fix_sso_domain_auto_join.sql` - - `20251226121702_enforce_sso_signup.sql` - - `20251226133424_fix_sso_lookup_function.sql` - - `20251226182000_fix_sso_auto_join_trigger.sql` - - `20251227010100_allow_sso_metadata_signup_bypass.sql` - - `20251231000002_add_sso_saml_authentication.sql` - - `20251231175228_add_auto_join_enabled_to_sso.sql` - - `20251231191232_fix_auto_join_check.sql` - - `20260104064028_enforce_single_sso_per_org.sql` - - `20260106000000_fix_auto_join_allowed_domains.sql` - -2. Remove duplicate CREATE TABLE statements (keep only the final evolved version) -3. Keep all indexes, triggers, functions, RLS policies in final form -4. Update `supabase/schemas/prod.sql` if needed -5. Generate types: `bun types` - -**Schema should include:** - -- Tables: `org_saml_connections`, `saml_domain_mappings`, `sso_audit_logs` -- Functions: `check_org_sso_configured`, `lookup_sso_provider_for_email`, `auto_join_user_to_org_via_sso` -- Triggers: `auto_join_sso_user_trigger`, `check_sso_domain_on_signup_trigger` -- RLS policies for all tables -- Indexes for performance - -**Minimal test checklist:** - -```bash -# 1. Migration applies cleanly -supabase db reset -# Should complete without errors - -# 2. Types generate -bun types -# Should update supabase.types.ts - -# 3. Tables exist -psql $POSTGRES_URL -c "\dt org_saml_connections saml_domain_mappings sso_audit_logs" -# All 3 tables should be listed - -# 4. Functions exist -psql $POSTGRES_URL -c "\df check_org_sso_configured" -# Function should be listed - -# 5. Lint passes -bun lint:backend -``` - ---- - -### PR #2: Backend SSO Endpoints - -**Branch:** `feature/sso-02-backend` -**Base:** `feature/sso-01-schema` (after PR #1 merged, rebase to main) -**Size:** ~10 files, 2k lines - -**Files to include:** - -``` -supabase/functions/_backend/private/sso_configure.ts -supabase/functions/_backend/private/sso_management.ts -supabase/functions/_backend/private/sso_remove.ts -supabase/functions/_backend/private/sso_status.ts -supabase/functions/_backend/private/sso_test.ts -supabase/functions/_backend/private/sso_update.ts -supabase/functions/private/index.ts (route additions) -supabase/functions/sso_check/index.ts -supabase/functions/mock-sso-callback/index.ts (mock endpoint) -supabase/functions/_backend/utils/cache.ts (Cache API fixes) -supabase/functions/_backend/utils/postgres_schema.ts (schema updates) -supabase/functions/_backend/utils/supabase.types.ts (type updates) -supabase/functions/_backend/utils/version.ts (version bump if needed) -cloudflare_workers/api/index.ts (SSO routes) -.env.test (SSO test vars if added) -``` - -**Route structure:** - -- `/private/sso/configure` - Create SSO connection -- `/private/sso/update` - Update SSO config -- `/private/sso/remove` - Delete SSO connection -- `/private/sso/test` - Test SSO flow -- `/private/sso/status` - Get SSO status -- `/sso_check` - Public endpoint to check if email has SSO -- `/mock-sso-callback` - Mock IdP callback for testing - -**Minimal test checklist:** - -```bash -# 1. Lint passes -bun lint:backend -bun lint:fix - -# 2. Backend tests pass -bun test:backend - -# 3. SSO management tests pass -bun test tests/sso-management.test.ts - -# 4. SSRF unit tests pass -bun test tests/sso-ssrf-unit.test.ts - -# 5. All routes reachable -curl http://localhost:54321/functions/v1/private/sso/status -curl http://localhost:54321/functions/v1/sso_check -# Should return 401/403 (requires auth) not 404 - -# 6. Cloudflare Workers routing works -./scripts/start-cloudflare-workers.sh -curl http://localhost:8787/private/sso/status -# Should route correctly - -# 7. Mock callback works -curl http://localhost:54321/functions/v1/mock-sso-callback -# Should return HTML page -``` - -**What NOT to include:** - -- Frontend code -- E2E tests -- Documentation -- Helper scripts - ---- - -### PR #3: Frontend SSO UI & Flows - -**Branch:** `feature/sso-03-frontend` -**Base:** `feature/sso-02-backend` (after PR #2 merged, rebase to main) -**Size:** ~8 files, 2k lines - -**Files to include:** - -``` -src/pages/settings/organization/sso.vue (SSO config wizard) -src/pages/sso-login.vue (SSO login flow) -src/pages/login.vue (SSO redirect detection) -src/composables/useSSODetection.ts (SSO detection logic) -src/layouts/settings.vue (layout updates for SSO tab) -src/constants/organizationTabs.ts (add SSO tab) -src/types/supabase.types.ts (frontend types) -src/auto-imports.d.ts (auto-import updates) -messages/en.json (i18n strings) -``` - -**Key features:** - -- SSO configuration wizard in organization settings -- SSO login page with email detection -- Login page SSO redirect handling -- Composable for SSO detection/initiation -- Organization settings tab for SSO - -**Minimal test checklist:** - -```bash -# 1. Lint passes -bun lint -bun lint:fix - -# 2. Type check passes -bun typecheck - -# 3. Frontend builds -bun build -# Should complete without errors - -# 4. Dev server runs -bun serve:local -# Navigate to /settings/organization/sso -# Should load without console errors - -# 5. SSO wizard renders -# - Entity ID display -# - Metadata URL input -# - Domain configuration -# - Test connection button -# All sections should be visible - -# 6. SSO login page works -# Navigate to /sso-login -# Enter email with @example.com -# Should show "Continue with SSO" button - -# 7. Login page detects SSO -# Navigate to /login?from_sso=true -# Should show "Signing you in..." message -``` - -**What NOT to include:** - -- E2E tests (next PR) -- Documentation (next PR) -- Helper scripts (next PR) - ---- - -### PR #4: Testing Infrastructure - -**Branch:** `feature/sso-04-tests` -**Base:** `feature/sso-03-frontend` (after PR #3 merged, rebase to main) -**Size:** ~5 files, 1k lines - -**Files to include:** - -``` -tests/sso-management.test.ts (backend unit tests) -tests/sso-ssrf-unit.test.ts (SSRF protection tests) -tests/test-utils.ts (SSO test helpers) -playwright/e2e/sso.spec.ts (E2E tests) -vitest.config.ts (test config updates) -``` - -**Test coverage:** - -- Backend SSO management API (configure, update, remove, test, status) -- SSRF protection (metadata URL validation) -- Frontend SSO wizard flow (Playwright) -- SSO login flow (Playwright) -- Auto-join trigger behavior -- Audit log creation - -**Minimal test checklist:** - -```bash -# 1. Backend tests pass -bun test tests/sso-management.test.ts -bun test tests/sso-ssrf-unit.test.ts - -# 2. E2E tests pass -bun test:front playwright/e2e/sso.spec.ts - -# 3. All tests pass together -bun test:backend -bun test:front - -# 4. Cloudflare Workers tests pass -bun test:cloudflare:backend - -# 5. Test coverage acceptable -bun test --coverage -# Should show >80% coverage for SSO files -``` - ---- - -### PR #5: Documentation & Utilities - -**Branch:** `feature/sso-05-docs` -**Base:** `feature/sso-04-tests` (after PR #4 merged, rebase to main) -**Size:** ~10 files, 2k lines - -**Files to include:** - -``` -docs/sso-setup.md (setup guide) -docs/sso-production.md (production deployment guide) -docs/MOCK_SSO_TESTING.md (testing guide) -restart-auth-with-saml.sh (reset script) -restart-auth-with-saml-v2.sh (alternate reset script) -verify-sso-routes.sh (route verification script) -temp-sso-trace.ts (debugging utility, can be .gitignore'd) -.gitignore (add temp files) -supabase/config.toml (SSO config if needed) -.github/workflows/build_and_deploy.yml (CI updates if needed) -``` - -**Documentation should cover:** - -- How to configure SSO for an organization -- How to add SAML providers (Okta, Azure AD, Google) -- How to test SSO locally with mock callback -- How to verify SSO routes are working -- How to reset Supabase Auth SSO config -- Production deployment considerations -- Troubleshooting common issues - -**Minimal test checklist:** - -```bash -# 1. Scripts are executable -chmod +x restart-auth-with-saml.sh -chmod +x verify-sso-routes.sh - -# 2. Verify routes script works -./verify-sso-routes.sh -# Should check all SSO endpoints - -# 3. Documentation is complete -# Read through each doc file -# Verify all steps are clear -# Verify all commands work - -# 4. Markdown lint passes (if configured) -markdownlint docs/sso-*.md docs/MOCK_SSO_TESTING.md -``` - ---- - -## Landing Sequence - -### Before Any PR - -1. Create feature branch from main: `git checkout -b feature/sso-01-schema main` -2. Run full test suite: `bun test:all` -3. Ensure main is passing - -### PR #1: Schema - -1. Create consolidated migration -2. Test: `supabase db reset && bun types` -3. Push PR, get review, merge to main -4. **Verify**: Schema deployed to development environment - -### PR #2: Backend - -1. Rebase on main: `git rebase main` -2. Copy backend files from original branch -3. Test: `bun test:backend && bun lint:backend` -4. Push PR, get review, merge to main -5. **Verify**: Backend endpoints work in development - -### PR #3: Frontend - -1. Rebase on main: `git rebase main` -2. Copy frontend files from original branch -3. Test: `bun lint && bun typecheck && bun build` -4. Push PR, get review, merge to main -5. **Verify**: UI renders in development - -### PR #4: Tests - -1. Rebase on main: `git rebase main` -2. Copy test files from original branch -3. Test: `bun test:all` -4. Push PR, get review, merge to main -5. **Verify**: All tests pass in CI - -### PR #5: Docs - -1. Rebase on main: `git rebase main` -2. Copy docs/scripts from original branch -3. Test: Run verification scripts -4. Push PR, get review, merge to main -5. **Verify**: Documentation is accessible - -### Final Integration Test - -After all 5 PRs are merged to main: - -```bash -# 1. Fresh clone -git clone sso-integration-test -cd sso-integration-test - -# 2. Database setup -supabase start -supabase db reset -bun types - -# 3. Start all services -bun backend & -./scripts/start-cloudflare-workers.sh & -bun serve:local & - -# 4. Full SSO flow test -# - Navigate to /settings/organization/sso as admin -# - Configure SSO with mock IdP -# - Test SSO login with test user -# - Verify user is created and enrolled in org -# - Check audit logs - -# 5. Run full test suite -bun test:all -bun test:cloudflare:all -bun test:front -``` - ---- - -## Common Pitfalls to Avoid - -### ❌ DON'T: - -- Mix unrelated changes (formatting, refactoring) into PRs -- Include generated files (`src/typed-router.d.ts`) unless consistent -- Edit previously committed migrations -- Skip lint/type checks before pushing -- Chain PRs without rebasing on main first -- Batch multiple independent features into one PR - -### ✅ DO: - -- Keep each PR focused on one concern (schema, backend, frontend, tests, docs) -- Run `bun lint:fix` before every commit -- Rebase on main after each PR merge -- Update PR descriptions with testing steps -- Mark PRs as draft until CI passes -- Request review only when all checks are green -- Include "Closes #" in final PR - ---- - -## Why This Works - -1. **Reviewable size**: Each PR is 200-1k lines vs 10k lines -2. **Clear dependencies**: Schema → Backend → Frontend → Tests → Docs -3. **Incremental testing**: Each layer is tested before building on it -4. **Rollback safety**: Can revert individual PRs without breaking others -5. **Parallel review**: Multiple reviewers can work on different PRs -6. **Clear scope**: Each PR has one purpose, easy to verify -7. **Migration best practice**: Single consolidated migration, not 13 files - -Your boss will be happy because: - -- Each PR is immediately reviewable (not "contains another PR inside") -- Each PR passes lint/tests before review -- Each PR has clear acceptance criteria -- The feature can be reviewed layer-by-layer instead of all-at-once From ab598c236d4626320f253f9eea61b8988cc56f1d Mon Sep 17 00:00:00 2001 From: Jonthan Kabuya Date: Thu, 8 Jan 2026 20:18:09 +0200 Subject: [PATCH 12/23] fix: resolve TypeScript errors in UsageCard, BundlePreviewFrame, and scan pages - Move getDaysInCurrentMonth call from prop default to computed property to avoid initialization issues - Add @ts-expect-error for packages without TypeScript definitions (qrcode, @capacitor/barcode-scanner) - Remove unused MeteredData import from stripe_event.ts --- src/components/BundlePreviewFrame.vue | 1 + src/components/dashboard/UsageCard.vue | 14 ++++++++++---- src/pages/scan.vue | 1 + supabase/functions/_backend/utils/stripe_event.ts | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/BundlePreviewFrame.vue b/src/components/BundlePreviewFrame.vue index 68b21cc14c..8195afea9c 100644 --- a/src/components/BundlePreviewFrame.vue +++ b/src/components/BundlePreviewFrame.vue @@ -1,4 +1,5 @@