diff --git a/messages/en.json b/messages/en.json index 3f03435b12..ce575f8f27 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1250,6 +1250,60 @@ "no-manifest-bundle": "No manifest", "no-zip-bundle": "No zip bundle", "of": "of", + "sso": "SSO", + "sso-description": "Configure Single Sign-On (SSO) with SAML to allow users from your organization to sign in with their corporate credentials and automatically join your organization.", + "sso-enterprise-required": "Enterprise Plan Required", + "sso-enterprise-required-description": "SSO with automatic user provisioning is available exclusively on the Enterprise plan. Upgrade to enable SAML authentication and domain-based auto-join for your organization.", + "sso-provider-config": "SSO Provider Configuration", + "sso-provider-config-description": "Configure your SAML identity provider to enable SSO for your organization.", + "sso-display-name": "Display Name", + "sso-display-name-placeholder": "e.g., Okta, Azure AD, Google Workspace", + "sso-metadata-url": "SAML Metadata URL", + "sso-metadata-url-placeholder": "https://your-idp.com/saml/metadata", + "sso-metadata-url-hint": "The URL where your identity provider publishes its SAML metadata XML.", + "sso-enable-provider": "Enable SSO Provider", + "sso-enabled": "SSO Enabled", + "sso-disabled": "SSO Disabled", + "sso-not-configured": "SSO not configured", + "sso-domains": "Email Domains", + "sso-domains-description": "Claim email domains to automatically add users with matching emails to your organization.", + "sso-add-domain": "Add Domain", + "sso-domain-placeholder": "example.com", + "sso-add": "Add", + "sso-no-domains": "No domains configured. Add a domain to enable automatic user provisioning.", + "sso-verified": "Verified", + "sso-pending-verification": "Pending verification", + "sso-auto-join": "Auto-join", + "sso-verify-domain": "Verify Domain", + "sso-delete-domain": "Delete Domain", + "sso-delete-domain-confirm": "Are you sure you want to delete the domain \"{domain}\"? Users will no longer be automatically added to your organization from this domain.", + "sso-verification-instructions": "DNS Verification Required", + "sso-verification-instructions-detail": "Add a TXT record to your DNS with the following values:", + "sso-auto-join-enabled": "Enable Auto-Join", + "sso-auto-join-description": "Automatically add new users with this email domain to your organization.", + "sso-default-role": "Default Role", + "sso-load-error": "Failed to load SSO configuration", + "sso-add-domain-error": "Failed to add domain", + "sso-domain-already-claimed": "This domain has already been claimed by another organization", + "sso-requires-enterprise": "SSO requires an Enterprise plan", + "sso-domain-added": "Domain added successfully", + "sso-verify-error": "Failed to verify domain", + "sso-domain-already-verified": "Domain is already verified", + "sso-domain-verified": "Domain verified successfully! Existing users have been added to your organization.", + "sso-verify-failed": "Domain verification failed. Please check your DNS configuration.", + "sso-delete-domain-error": "Failed to delete domain", + "sso-domain-deleted": "Domain deleted successfully", + "sso-update-domain-error": "Failed to update domain settings", + "sso-domain-updated": "Domain settings updated", + "sso-save-config-error": "Failed to save SSO configuration", + "sso-config-saved": "SSO configuration saved successfully", + "configure": "Configure", + "hide-config": "Hide Configuration", + "view-plans": "View Plans", + "role-read": "Read", + "role-upload": "Upload", + "role-write": "Write", + "role-admin": "Admin", "dependencies": "Dependencies", "native-dependencies": "Native Dependencies", "native-dependencies-description": "Native packages and their versions included in this bundle", diff --git a/src/constants/organizationTabs.ts b/src/constants/organizationTabs.ts index 79d6d33ab0..1661f3f853 100644 --- a/src/constants/organizationTabs.ts +++ b/src/constants/organizationTabs.ts @@ -5,12 +5,14 @@ import IconPlan from '~icons/heroicons/credit-card' import IconCredits from '~icons/heroicons/currency-dollar' import IconWebhook from '~icons/heroicons/globe-alt' import IconInfo from '~icons/heroicons/information-circle' +import IconSSO from '~icons/heroicons/key' import IconSecurity from '~icons/heroicons/shield-check' import IconUsers from '~icons/heroicons/users' export const organizationTabs: Tab[] = [ { label: 'general', key: '/settings/organization', icon: IconInfo }, { label: 'members', key: '/settings/organization/members', icon: IconUsers }, + { label: 'sso', key: '/settings/organization/sso', icon: IconSSO }, // Security tab is added dynamically in settings.vue for super_admins only { label: 'security', key: '/settings/organization/security', icon: IconSecurity }, { label: 'audit-logs', key: '/settings/organization/auditlogs', icon: IconAudit }, diff --git a/src/pages/settings/organization/SSO.vue b/src/pages/settings/organization/SSO.vue new file mode 100644 index 0000000000..36d2899bac --- /dev/null +++ b/src/pages/settings/organization/SSO.vue @@ -0,0 +1,678 @@ + + + + + +meta: + layout: settings + diff --git a/supabase/functions/_backend/private/sso.ts b/supabase/functions/_backend/private/sso.ts new file mode 100644 index 0000000000..dfe074d19b --- /dev/null +++ b/supabase/functions/_backend/private/sso.ts @@ -0,0 +1,539 @@ +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import { Hono } from 'hono/tiny' +import { z } from 'zod/mini' +import { parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' +import { middlewareV2 } from '../utils/hono_middleware.ts' +import { getCurrentPlanNameOrg, supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts' + +// Schema definitions +const orgIdSchema = z.object({ + org_id: z.uuid(), +}) + +const addDomainSchema = z.object({ + org_id: z.uuid(), + domain: z.string().min(3).max(253), +}) + +const domainIdSchema = z.object({ + domain_id: z.uuid(), +}) + +const updateDomainSettingsSchema = z.object({ + domain_id: z.uuid(), + auto_join_enabled: z.optional(z.boolean()), + auto_join_role: z.optional(z.enum(['read', 'upload', 'write', 'admin'])), +}) + +const ssoProviderSchema = z.object({ + org_id: z.uuid(), + supabase_sso_provider_id: z.optional(z.nullable(z.uuid())), + provider_type: z.optional(z.string()), + display_name: z.optional(z.nullable(z.string())), + metadata_url: z.optional(z.nullable(z.string())), + enabled: z.optional(z.boolean()), +}) + +export const app = new Hono() + +app.use('/*', useCors) + +// Helper function to check Enterprise plan +async function checkEnterprisePlan(c: any, orgId: string): Promise { + const planName = await getCurrentPlanNameOrg(c, orgId) + return planName === 'Enterprise' +} + +// ============================================================================ +// SSO PROVIDER ENDPOINTS +// ============================================================================ + +// GET /sso/config - Get SSO config for an org +app.get('/config', middlewareV2(['all', 'write', 'read']), async (c) => { + const auth = c.get('auth')! + const orgId = c.req.query('org_id') + + if (!orgId) { + return simpleError('missing_org_id', 'org_id query parameter is required') + } + + const supabaseAdmin = await useSupabaseAdmin(c) + + // Check user has at least admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'admin', + org_id: orgId, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Not authorized to view SSO config') + } + + // Get SSO config + const { data, error } = await supabaseAdmin + .from('org_sso_providers') + .select('*') + .eq('org_id', orgId) + .maybeSingle() + + if (error) { + return simpleError('get_sso_config_error', 'Failed to get SSO config', { error }) + } + + // Check if org is Enterprise + const isEnterprise = await checkEnterprisePlan(c, orgId) + + return c.json({ + config: data, + is_enterprise: isEnterprise, + }) +}) + +// POST /sso/config - Create or update SSO config +app.post('/config', middlewareV2(['all', 'write']), async (c) => { + const auth = c.get('auth')! + const body = await parseBody(c) + + const parsedBody = ssoProviderSchema.safeParse(body) + if (!parsedBody.success) { + return simpleError('invalid_body', 'Invalid request body', { errors: parsedBody.error }) + } + + const { org_id, ...ssoConfig } = parsedBody.data + const supabaseAdmin = await useSupabaseAdmin(c) + + // Check user has super_admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'super_admin', + org_id, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Super admin rights required') + } + + // Check Enterprise plan + if (!await checkEnterprisePlan(c, org_id)) { + return quickError(403, 'requires_enterprise', 'SSO requires Enterprise plan') + } + + // Upsert SSO config + const { data, error } = await supabaseAdmin.rpc('upsert_org_sso_provider', { + p_org_id: org_id, + p_supabase_sso_provider_id: ssoConfig.supabase_sso_provider_id, + p_provider_type: ssoConfig.provider_type || 'saml', + p_display_name: ssoConfig.display_name, + p_metadata_url: ssoConfig.metadata_url, + p_enabled: ssoConfig.enabled ?? false, + }) + + if (error) { + return simpleError('upsert_sso_config_error', 'Failed to save SSO config', { error }) + } + + const result = data?.[0] + if (result?.error_code) { + return simpleError(result.error_code, `Failed: ${result.error_code}`) + } + + return c.json({ id: result?.id, success: true }) +}) + +// DELETE /sso/config - Delete SSO config +app.delete('/config', middlewareV2(['all', 'write']), async (c) => { + const auth = c.get('auth')! + const orgId = c.req.query('org_id') + + if (!orgId) { + return simpleError('missing_org_id', 'org_id query parameter is required') + } + + const supabaseAdmin = await useSupabaseAdmin(c) + + // Check user has super_admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'super_admin', + org_id: orgId, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Super admin rights required') + } + + // Delete SSO config + const { data, error } = await supabaseAdmin.rpc('delete_org_sso_provider', { + p_org_id: orgId, + }) + + if (error) { + return simpleError('delete_sso_config_error', 'Failed to delete SSO config', { error }) + } + + if (data !== 'OK') { + return simpleError(data, `Failed: ${data}`) + } + + return c.json({ success: true }) +}) + +// ============================================================================ +// DOMAIN ENDPOINTS +// ============================================================================ + +// GET /sso/domains - Get domains for an org +app.get('/domains', middlewareV2(['all', 'write', 'read']), async (c) => { + const auth = c.get('auth')! + const orgId = c.req.query('org_id') + + if (!orgId) { + return simpleError('missing_org_id', 'org_id query parameter is required') + } + + const supabaseAdmin = await useSupabaseAdmin(c) + + // Check user has at least admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'admin', + org_id: orgId, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Not authorized to view domains') + } + + // Get domains + const { data, error } = await supabaseAdmin + .from('org_domains') + .select('*') + .eq('org_id', orgId) + .order('created_at', { ascending: false }) + + if (error) { + return simpleError('get_domains_error', 'Failed to get domains', { error }) + } + + // Check if org is Enterprise + const isEnterprise = await checkEnterprisePlan(c, orgId) + + return c.json({ + domains: data || [], + is_enterprise: isEnterprise, + }) +}) + +// POST /sso/domains - Add a domain +app.post('/domains', middlewareV2(['all', 'write']), async (c) => { + const auth = c.get('auth')! + const body = await parseBody(c) + + const parsedBody = addDomainSchema.safeParse(body) + if (!parsedBody.success) { + return simpleError('invalid_body', 'Invalid request body', { errors: parsedBody.error }) + } + + const { org_id, domain } = parsedBody.data + const supabaseAdmin = await useSupabaseAdmin(c) + + // Check user has super_admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'super_admin', + org_id, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Super admin rights required') + } + + // Add domain via RPC + const { data, error } = await supabaseAdmin.rpc('add_org_domain', { + p_org_id: org_id, + p_domain: domain.toLowerCase().trim(), + }) + + if (error) { + return simpleError('add_domain_error', 'Failed to add domain', { error }) + } + + const result = data?.[0] + if (result?.error_code) { + return simpleError(result.error_code, `Failed: ${result.error_code}`) + } + + return c.json({ + id: result?.id, + verification_token: result?.verification_token, + dns_record: `_capgo-verification.${domain.toLowerCase().trim()}`, + success: true, + }) +}) + +// DELETE /sso/domains - Remove a domain +app.delete('/domains', middlewareV2(['all', 'write']), async (c) => { + const auth = c.get('auth')! + const domainId = c.req.query('domain_id') + + if (!domainId) { + return simpleError('missing_domain_id', 'domain_id query parameter is required') + } + + const supabaseAdmin = await useSupabaseAdmin(c) + + // Get domain to check org + const { data: domain, error: domainError } = await supabaseAdmin + .from('org_domains') + .select('org_id') + .eq('id', domainId) + .single() + + if (domainError || !domain) { + return simpleError('domain_not_found', 'Domain not found') + } + + // Check user has super_admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'super_admin', + org_id: domain.org_id, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Super admin rights required') + } + + // Delete domain + const { data, error } = await supabaseAdmin.rpc('remove_org_domain', { + p_domain_id: domainId, + }) + + if (error) { + return simpleError('delete_domain_error', 'Failed to delete domain', { error }) + } + + if (data !== 'OK') { + return simpleError(data, `Failed: ${data}`) + } + + return c.json({ success: true }) +}) + +// POST /sso/domains/verify - Verify a domain via DNS +app.post('/domains/verify', middlewareV2(['all', 'write']), async (c) => { + const auth = c.get('auth')! + const body = await parseBody(c) + + const parsedBody = domainIdSchema.safeParse(body) + if (!parsedBody.success) { + return simpleError('invalid_body', 'Invalid request body', { errors: parsedBody.error }) + } + + const { domain_id } = parsedBody.data + const supabaseAdmin = await useSupabaseAdmin(c) + + // Get domain info + const { data: domainInfo, error: domainError } = await supabaseAdmin + .from('org_domains') + .select('*') + .eq('id', domain_id) + .single() + + if (domainError || !domainInfo) { + return simpleError('domain_not_found', 'Domain not found') + } + + // Check user has super_admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'super_admin', + org_id: domainInfo.org_id, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Super admin rights required') + } + + // Check Enterprise plan + if (!await checkEnterprisePlan(c, domainInfo.org_id)) { + return quickError(403, 'requires_enterprise', 'SSO requires Enterprise plan') + } + + // Already verified? + if (domainInfo.verified) { + return c.json({ success: true, already_verified: true }) + } + + // Perform DNS lookup to verify the domain + const dnsRecord = `_capgo-verification.${domainInfo.domain}` + let verified = false + + try { + // Use Deno's DNS resolver to check TXT records + const records = await Deno.resolveDns(dnsRecord, 'TXT') + + // Check if any TXT record matches our verification token + for (const record of records) { + const txtValue = Array.isArray(record) ? record.join('') : record + if (txtValue === domainInfo.verification_token) { + verified = true + break + } + } + } + catch (dnsError) { + // DNS lookup failed - domain not configured + // Note: We don't expose the verification token in error responses for security + return c.json({ + success: false, + verified: false, + error: 'dns_lookup_failed', + message: `Could not find DNS TXT record at ${dnsRecord}. Please add the verification record and try again.`, + expected_record: dnsRecord, + }) + } + + if (!verified) { + // Note: We don't expose the expected token value in error responses for security + return c.json({ + success: false, + verified: false, + error: 'verification_token_mismatch', + message: 'DNS TXT record found but value does not match the expected verification token.', + expected_record: dnsRecord, + }) + } + + // Mark domain as verified (this triggers backfill) + // Pass user_id for service-role client compatibility + const { data, error } = await supabaseAdmin.rpc('verify_org_domain', { + p_domain_id: domain_id, + p_user_id: auth.userId, + }) + + if (error) { + return simpleError('verify_domain_error', 'Failed to verify domain', { error }) + } + + if (data !== 'OK') { + return simpleError(data, `Failed: ${data}`) + } + + return c.json({ + success: true, + verified: true, + message: 'Domain verified successfully. Existing users with this domain have been added to your organization.', + }) +}) + +// PUT /sso/domains/settings - Update domain settings +app.put('/domains/settings', middlewareV2(['all', 'write']), async (c) => { + const auth = c.get('auth')! + const body = await parseBody(c) + + const parsedBody = updateDomainSettingsSchema.safeParse(body) + if (!parsedBody.success) { + return simpleError('invalid_body', 'Invalid request body', { errors: parsedBody.error }) + } + + const { domain_id, auto_join_enabled, auto_join_role } = parsedBody.data + const supabaseAdmin = await useSupabaseAdmin(c) + + // Get domain to check org + const { data: domain, error: domainError } = await supabaseAdmin + .from('org_domains') + .select('org_id') + .eq('id', domain_id) + .single() + + if (domainError || !domain) { + return simpleError('domain_not_found', 'Domain not found') + } + + // Check user has super_admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'super_admin', + org_id: domain.org_id, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Super admin rights required') + } + + // Update settings + const { data, error } = await supabaseAdmin.rpc('update_org_domain_settings', { + p_domain_id: domain_id, + p_auto_join_enabled: auto_join_enabled, + p_auto_join_role: auto_join_role, + }) + + if (error) { + return simpleError('update_domain_error', 'Failed to update domain settings', { error }) + } + + if (data !== 'OK') { + return simpleError(data, `Failed: ${data}`) + } + + return c.json({ success: true }) +}) + +// GET /sso/domains/preview - Preview how many users would be added +app.get('/domains/preview', middlewareV2(['all', 'write', 'read']), async (c) => { + const auth = c.get('auth')! + const orgId = c.req.query('org_id') + const domain = c.req.query('domain') + + if (!orgId || !domain) { + return simpleError('missing_params', 'org_id and domain query parameters are required') + } + + const supabaseAdmin = await useSupabaseAdmin(c) + + // Check user has at least admin rights + const userRight = await supabaseAdmin.rpc('check_min_rights', { + min_right: 'admin', + org_id: orgId, + user_id: auth.userId, + channel_id: null as any, + app_id: null as any, + }) + + if (userRight.error || !userRight.data) { + return quickError(401, 'not_authorized', 'Not authorized') + } + + // Count users + const { data, error } = await supabaseAdmin.rpc('count_domain_users', { + p_domain: domain.toLowerCase().trim(), + p_org_id: orgId, + }) + + if (error) { + return simpleError('count_users_error', 'Failed to count users', { error }) + } + + return c.json({ + domain: domain.toLowerCase().trim(), + user_count: data ?? 0, + }) +}) diff --git a/supabase/functions/private/index.ts b/supabase/functions/private/index.ts index 552d50dffd..03110da83f 100644 --- a/supabase/functions/private/index.ts +++ b/supabase/functions/private/index.ts @@ -14,6 +14,7 @@ import { app as log_as } from '../_backend/private/log_as.ts' import { app as plans } from '../_backend/private/plans.ts' import { app as publicStats } from '../_backend/private/public_stats.ts' import { app as set_org_email } from '../_backend/private/set_org_email.ts' +import { app as sso } from '../_backend/private/sso.ts' import { app as stats_priv } from '../_backend/private/stats.ts' import { app as storeTop } from '../_backend/private/store_top.ts' import { app as stripe_checkout } from '../_backend/private/stripe_checkout.ts' @@ -48,6 +49,7 @@ appGlobal.route('/latency', latency) appGlobal.route('/events', events) appGlobal.route('/invite_new_user_to_org', invite_new_user_to_org) appGlobal.route('/accept_invitation', accept_invitation) +appGlobal.route('/sso', sso) appGlobal.route('/validate_password_compliance', validate_password_compliance) createAllCatch(appGlobal, functionName) diff --git a/supabase/migrations/20251228080040_sso_auto_join.sql b/supabase/migrations/20251228080040_sso_auto_join.sql new file mode 100644 index 0000000000..b917f59fe5 --- /dev/null +++ b/supabase/migrations/20251228080040_sso_auto_join.sql @@ -0,0 +1,523 @@ +-- SSO Auto-Join Feature +-- Allows Enterprise organizations to configure SAML SSO and auto-join users by email domain + +-- ============================================================================ +-- TABLES +-- ============================================================================ + +-- Store SSO provider configuration per org +CREATE TABLE IF NOT EXISTS public.org_sso_providers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL REFERENCES public.orgs(id) ON DELETE CASCADE, + supabase_sso_provider_id uuid, -- ID from Supabase SSO system + provider_type varchar NOT NULL DEFAULT 'saml', + display_name varchar, + metadata_url text, + enabled boolean DEFAULT false, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE(org_id) +); + +-- Store claimed domains per org +CREATE TABLE IF NOT EXISTS public.org_domains ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL REFERENCES public.orgs(id) ON DELETE CASCADE, + domain varchar NOT NULL, + verified boolean DEFAULT false, + verification_token varchar, + verified_at timestamptz, + auto_join_enabled boolean DEFAULT true, + auto_join_role public.user_min_right DEFAULT 'read'::public.user_min_right, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE(domain) -- Each domain can only be claimed once globally +); + +-- Index for domain lookup during auto-join +CREATE INDEX IF NOT EXISTS idx_org_domains_domain ON public.org_domains(domain) WHERE verified = true; +CREATE INDEX IF NOT EXISTS idx_org_domains_org_id ON public.org_domains(org_id); +CREATE INDEX IF NOT EXISTS idx_org_sso_providers_org_id ON public.org_sso_providers(org_id); + +-- ============================================================================ +-- RLS POLICIES +-- ============================================================================ + +ALTER TABLE public.org_sso_providers ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.org_domains ENABLE ROW LEVEL SECURITY; + +-- SSO Providers: super_admins can manage, admins can read +CREATE POLICY "Super admins manage SSO providers" ON public.org_sso_providers + FOR ALL TO authenticated + USING (public.check_min_rights('super_admin'::public.user_min_right, auth.uid(), org_id, NULL::varchar, NULL::bigint)); + +CREATE POLICY "Admins can read SSO providers" ON public.org_sso_providers + FOR SELECT TO authenticated + USING (public.check_min_rights('admin'::public.user_min_right, auth.uid(), org_id, NULL::varchar, NULL::bigint)); + +-- Domains: super_admins can manage, admins can read +CREATE POLICY "Super admins manage domains" ON public.org_domains + FOR ALL TO authenticated + USING (public.check_min_rights('super_admin'::public.user_min_right, auth.uid(), org_id, NULL::varchar, NULL::bigint)); + +CREATE POLICY "Admins can read domains" ON public.org_domains + FOR SELECT TO authenticated + USING (public.check_min_rights('admin'::public.user_min_right, auth.uid(), org_id, NULL::varchar, NULL::bigint)); + +-- ============================================================================ +-- HELPER FUNCTION: Check if org has Enterprise plan +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.is_enterprise_org(p_org_id uuid) +RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_plan_name text; +BEGIN + SELECT p.name INTO v_plan_name + FROM public.orgs o + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + LEFT JOIN public.plans p ON si.product_id = p.stripe_id + WHERE o.id = p_org_id; + + RETURN v_plan_name = 'Enterprise'; +END; +$$; + +-- ============================================================================ +-- AUTO-JOIN TRIGGER: Add new users to org based on email domain +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.handle_sso_auto_join() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + user_domain text; + domain_record record; +BEGIN + -- Extract domain from email + user_domain := split_part(NEW.email, '@', 2); + + -- Find verified domain with auto-join enabled for Enterprise org + FOR domain_record IN + SELECT od.org_id, od.auto_join_role + FROM public.org_domains od + WHERE od.domain = user_domain + AND od.verified = true + AND od.auto_join_enabled = true + AND public.is_enterprise_org(od.org_id) + LOOP + -- Check if user already in org + IF NOT EXISTS ( + SELECT 1 FROM public.org_users + WHERE user_id = NEW.id AND org_id = domain_record.org_id + ) THEN + INSERT INTO public.org_users (user_id, org_id, user_right) + VALUES (NEW.id, domain_record.org_id, domain_record.auto_join_role); + END IF; + END LOOP; + + RETURN NEW; +END; +$$; + +-- Trigger on users table for new user creation +DROP TRIGGER IF EXISTS on_user_created_sso_auto_join ON public.users; +CREATE TRIGGER on_user_created_sso_auto_join + AFTER INSERT ON public.users + FOR EACH ROW + EXECUTE FUNCTION public.handle_sso_auto_join(); + +-- ============================================================================ +-- AUTO-BACKFILL TRIGGER: Add existing users when domain is verified +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.backfill_domain_users() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Only run when verified changes from false to true + IF NEW.verified = true AND (OLD.verified = false OR OLD.verified IS NULL) THEN + -- Only backfill if org is Enterprise + IF public.is_enterprise_org(NEW.org_id) THEN + INSERT INTO public.org_users (user_id, org_id, user_right) + SELECT u.id, NEW.org_id, NEW.auto_join_role + FROM public.users u + WHERE split_part(u.email, '@', 2) = NEW.domain + AND NOT EXISTS ( + SELECT 1 FROM public.org_users ou + WHERE ou.user_id = u.id AND ou.org_id = NEW.org_id + ); + END IF; + END IF; + RETURN NEW; +END; +$$; + +-- Trigger on domain verification +DROP TRIGGER IF EXISTS on_domain_verified_backfill ON public.org_domains; +CREATE TRIGGER on_domain_verified_backfill + AFTER UPDATE ON public.org_domains + FOR EACH ROW + EXECUTE FUNCTION public.backfill_domain_users(); + +-- ============================================================================ +-- HELPER FUNCTIONS FOR SSO MANAGEMENT +-- ============================================================================ + +-- Get SSO config for an org +CREATE OR REPLACE FUNCTION public.get_org_sso_config(p_org_id uuid) +RETURNS TABLE ( + id uuid, + org_id uuid, + supabase_sso_provider_id uuid, + provider_type varchar, + display_name varchar, + metadata_url text, + enabled boolean, + created_at timestamptz, + updated_at timestamptz +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Check if user has at least admin rights + IF NOT public.check_min_rights('admin'::public.user_min_right, auth.uid(), p_org_id, NULL::varchar, NULL::bigint) THEN + RAISE EXCEPTION 'Insufficient permissions'; + END IF; + + RETURN QUERY + SELECT + osp.id, + osp.org_id, + osp.supabase_sso_provider_id, + osp.provider_type, + osp.display_name, + osp.metadata_url, + osp.enabled, + osp.created_at, + osp.updated_at + FROM public.org_sso_providers osp + WHERE osp.org_id = p_org_id; +END; +$$; + +-- Get domains for an org +CREATE OR REPLACE FUNCTION public.get_org_domains(p_org_id uuid) +RETURNS TABLE ( + id uuid, + org_id uuid, + domain varchar, + verified boolean, + verification_token varchar, + verified_at timestamptz, + auto_join_enabled boolean, + auto_join_role public.user_min_right, + created_at timestamptz, + updated_at timestamptz +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Check if user has at least admin rights + IF NOT public.check_min_rights('admin'::public.user_min_right, auth.uid(), p_org_id, NULL::varchar, NULL::bigint) THEN + RAISE EXCEPTION 'Insufficient permissions'; + END IF; + + RETURN QUERY + SELECT + od.id, + od.org_id, + od.domain, + od.verified, + od.verification_token, + od.verified_at, + od.auto_join_enabled, + od.auto_join_role, + od.created_at, + od.updated_at + FROM public.org_domains od + WHERE od.org_id = p_org_id + ORDER BY od.created_at DESC; +END; +$$; + +-- Add a domain claim +CREATE OR REPLACE FUNCTION public.add_org_domain( + p_org_id uuid, + p_domain varchar +) +RETURNS TABLE ( + id uuid, + verification_token varchar, + error_code text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_token varchar; + v_id uuid; +BEGIN + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, auth.uid(), p_org_id, NULL::varchar, NULL::bigint) THEN + RETURN QUERY SELECT NULL::uuid, NULL::varchar, 'NO_RIGHTS'::text; + RETURN; + END IF; + + -- Check if org is Enterprise + IF NOT public.is_enterprise_org(p_org_id) THEN + RETURN QUERY SELECT NULL::uuid, NULL::varchar, 'REQUIRES_ENTERPRISE'::text; + RETURN; + END IF; + + -- Check if domain is already claimed + IF EXISTS (SELECT 1 FROM public.org_domains WHERE domain = lower(p_domain)) THEN + RETURN QUERY SELECT NULL::uuid, NULL::varchar, 'DOMAIN_ALREADY_CLAIMED'::text; + RETURN; + END IF; + + -- Generate verification token + v_token := encode(gen_random_bytes(32), 'hex'); + + -- Insert domain + INSERT INTO public.org_domains (org_id, domain, verification_token) + VALUES (p_org_id, lower(p_domain), v_token) + RETURNING org_domains.id INTO v_id; + + RETURN QUERY SELECT v_id, v_token, NULL::text; +END; +$$; + +-- Remove a domain +CREATE OR REPLACE FUNCTION public.remove_org_domain( + p_domain_id uuid +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_org_id uuid; +BEGIN + -- Get org_id for the domain + SELECT org_id INTO v_org_id FROM public.org_domains WHERE id = p_domain_id; + + IF v_org_id IS NULL THEN + RETURN 'DOMAIN_NOT_FOUND'; + END IF; + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, auth.uid(), v_org_id, NULL::varchar, NULL::bigint) THEN + RETURN 'NO_RIGHTS'; + END IF; + + -- Delete domain + DELETE FROM public.org_domains WHERE id = p_domain_id; + + RETURN 'OK'; +END; +$$; + +-- Verify a domain (called after DNS check passes) +CREATE OR REPLACE FUNCTION public.verify_org_domain( + p_domain_id uuid +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_org_id uuid; +BEGIN + -- Get org_id for the domain + SELECT org_id INTO v_org_id FROM public.org_domains WHERE id = p_domain_id; + + IF v_org_id IS NULL THEN + RETURN 'DOMAIN_NOT_FOUND'; + END IF; + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, auth.uid(), v_org_id, NULL::varchar, NULL::bigint) THEN + RETURN 'NO_RIGHTS'; + END IF; + + -- Check if org is Enterprise + IF NOT public.is_enterprise_org(v_org_id) THEN + RETURN 'REQUIRES_ENTERPRISE'; + END IF; + + -- Update domain as verified (this will trigger backfill) + UPDATE public.org_domains + SET verified = true, verified_at = now(), updated_at = now() + WHERE id = p_domain_id; + + RETURN 'OK'; +END; +$$; + +-- Update domain auto-join settings +CREATE OR REPLACE FUNCTION public.update_org_domain_settings( + p_domain_id uuid, + p_auto_join_enabled boolean DEFAULT NULL, + p_auto_join_role public.user_min_right DEFAULT NULL +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_org_id uuid; +BEGIN + -- Get org_id for the domain + SELECT org_id INTO v_org_id FROM public.org_domains WHERE id = p_domain_id; + + IF v_org_id IS NULL THEN + RETURN 'DOMAIN_NOT_FOUND'; + END IF; + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, auth.uid(), v_org_id, NULL::varchar, NULL::bigint) THEN + RETURN 'NO_RIGHTS'; + END IF; + + -- Update settings + UPDATE public.org_domains + SET + auto_join_enabled = COALESCE(p_auto_join_enabled, auto_join_enabled), + auto_join_role = COALESCE(p_auto_join_role, auto_join_role), + updated_at = now() + WHERE id = p_domain_id; + + RETURN 'OK'; +END; +$$; + +-- ============================================================================ +-- SSO PROVIDER MANAGEMENT FUNCTIONS +-- ============================================================================ + +-- Create or update SSO provider config +CREATE OR REPLACE FUNCTION public.upsert_org_sso_provider( + p_org_id uuid, + p_supabase_sso_provider_id uuid DEFAULT NULL, + p_provider_type varchar DEFAULT 'saml', + p_display_name varchar DEFAULT NULL, + p_metadata_url text DEFAULT NULL, + p_enabled boolean DEFAULT false +) +RETURNS TABLE ( + id uuid, + error_code text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_id uuid; +BEGIN + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, auth.uid(), p_org_id, NULL::varchar, NULL::bigint) THEN + RETURN QUERY SELECT NULL::uuid, 'NO_RIGHTS'::text; + RETURN; + END IF; + + -- Check if org is Enterprise + IF NOT public.is_enterprise_org(p_org_id) THEN + RETURN QUERY SELECT NULL::uuid, 'REQUIRES_ENTERPRISE'::text; + RETURN; + END IF; + + -- Upsert SSO provider + INSERT INTO public.org_sso_providers ( + org_id, supabase_sso_provider_id, provider_type, display_name, metadata_url, enabled + ) + VALUES ( + p_org_id, p_supabase_sso_provider_id, p_provider_type, p_display_name, p_metadata_url, p_enabled + ) + ON CONFLICT (org_id) DO UPDATE SET + supabase_sso_provider_id = COALESCE(EXCLUDED.supabase_sso_provider_id, org_sso_providers.supabase_sso_provider_id), + provider_type = EXCLUDED.provider_type, + display_name = EXCLUDED.display_name, + metadata_url = EXCLUDED.metadata_url, + enabled = EXCLUDED.enabled, + updated_at = now() + RETURNING org_sso_providers.id INTO v_id; + + RETURN QUERY SELECT v_id, NULL::text; +END; +$$; + +-- Delete SSO provider config +CREATE OR REPLACE FUNCTION public.delete_org_sso_provider( + p_org_id uuid +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, auth.uid(), p_org_id, NULL::varchar, NULL::bigint) THEN + RETURN 'NO_RIGHTS'; + END IF; + + -- Delete SSO provider + DELETE FROM public.org_sso_providers WHERE org_id = p_org_id; + + RETURN 'OK'; +END; +$$; + +-- Count users that would be backfilled for a domain +CREATE OR REPLACE FUNCTION public.count_domain_users( + p_domain varchar, + p_org_id uuid +) +RETURNS integer +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_count integer; +BEGIN + -- Check if user has admin rights + IF NOT public.check_min_rights('admin'::public.user_min_right, auth.uid(), p_org_id, NULL::varchar, NULL::bigint) THEN + RETURN -1; + END IF; + + SELECT COUNT(*)::integer INTO v_count + FROM public.users u + WHERE split_part(u.email, '@', 2) = lower(p_domain) + AND NOT EXISTS ( + SELECT 1 FROM public.org_users ou + WHERE ou.user_id = u.id AND ou.org_id = p_org_id + ); + + RETURN v_count; +END; +$$; diff --git a/supabase/migrations/20251229000001_sso_auto_join_fixes.sql b/supabase/migrations/20251229000001_sso_auto_join_fixes.sql new file mode 100644 index 0000000000..c663c0197e --- /dev/null +++ b/supabase/migrations/20251229000001_sso_auto_join_fixes.sql @@ -0,0 +1,393 @@ +-- SSO Auto-Join Fixes +-- Addresses review comments for improved reliability and security + +-- ============================================================================ +-- FIX #1: verify_org_domain - Accept user_id parameter for service-role calls +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.verify_org_domain( + p_domain_id uuid, + p_user_id uuid DEFAULT NULL +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_org_id uuid; + v_effective_user_id uuid; +BEGIN + -- Use provided user_id or fall back to auth.uid() + v_effective_user_id := COALESCE(p_user_id, auth.uid()); + + -- Get org_id for the domain + SELECT org_id INTO v_org_id FROM public.org_domains WHERE id = p_domain_id; + + IF v_org_id IS NULL THEN + RETURN 'DOMAIN_NOT_FOUND'; + END IF; + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, v_effective_user_id, v_org_id, NULL::varchar, NULL::bigint) THEN + RETURN 'NO_RIGHTS'; + END IF; + + -- Check if org is Enterprise + IF NOT public.is_enterprise_org(v_org_id) THEN + RETURN 'REQUIRES_ENTERPRISE'; + END IF; + + -- Update domain as verified (this will trigger backfill) + UPDATE public.org_domains + SET verified = true, verified_at = now(), updated_at = now() + WHERE id = p_domain_id; + + RETURN 'OK'; +END; +$$; + +-- ============================================================================ +-- FIX #2: Normalize email domains with lower() in auto-join trigger +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.handle_sso_auto_join() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + user_domain text; + domain_record record; +BEGIN + -- Extract domain from email and normalize to lowercase + user_domain := lower(split_part(NEW.email, '@', 2)); + + -- Find verified domain with auto-join enabled for Enterprise org + FOR domain_record IN + SELECT od.org_id, od.auto_join_role + FROM public.org_domains od + WHERE od.domain = user_domain + AND od.verified = true + AND od.auto_join_enabled = true + AND public.is_enterprise_org(od.org_id) + LOOP + -- Check if user already in org + IF NOT EXISTS ( + SELECT 1 FROM public.org_users + WHERE user_id = NEW.id AND org_id = domain_record.org_id + ) THEN + -- FIX #8: Handle insert failures gracefully + BEGIN + INSERT INTO public.org_users (user_id, org_id, user_right) + VALUES (NEW.id, domain_record.org_id, domain_record.auto_join_role); + EXCEPTION WHEN unique_violation THEN + -- Race condition: user was added by another process, ignore + NULL; + END; + END IF; + END LOOP; + + RETURN NEW; +END; +$$; + +-- ============================================================================ +-- FIX #2 & #8 & #9: Backfill trigger with lowercased domain comparison, +-- error handling, and batching for large domains +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.backfill_domain_users() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + batch_size integer := 1000; + affected_count integer; +BEGIN + -- Only run when verified changes from false to true + IF NEW.verified = true AND (OLD.verified = false OR OLD.verified IS NULL) THEN + -- Only backfill if org is Enterprise + IF public.is_enterprise_org(NEW.org_id) THEN + -- Process in batches to avoid long-running transactions + LOOP + -- Insert batch of users, using lower() for case-insensitive matching + WITH batch AS ( + SELECT u.id as user_id + FROM public.users u + WHERE lower(split_part(u.email, '@', 2)) = lower(NEW.domain) + AND NOT EXISTS ( + SELECT 1 FROM public.org_users ou + WHERE ou.user_id = u.id AND ou.org_id = NEW.org_id + ) + LIMIT batch_size + ) + INSERT INTO public.org_users (user_id, org_id, user_right) + SELECT batch.user_id, NEW.org_id, NEW.auto_join_role + FROM batch + ON CONFLICT (user_id, org_id) DO NOTHING; + + GET DIAGNOSTICS affected_count = ROW_COUNT; + + -- Exit loop when no more users to process + EXIT WHEN affected_count < batch_size; + END LOOP; + END IF; + END IF; + RETURN NEW; +END; +$$; + +-- ============================================================================ +-- FIX #1: Also update other RPC functions that use auth.uid() to accept user_id +-- ============================================================================ + +-- Update remove_org_domain to accept optional user_id +CREATE OR REPLACE FUNCTION public.remove_org_domain( + p_domain_id uuid, + p_user_id uuid DEFAULT NULL +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_org_id uuid; + v_effective_user_id uuid; +BEGIN + v_effective_user_id := COALESCE(p_user_id, auth.uid()); + + -- Get org_id for the domain + SELECT org_id INTO v_org_id FROM public.org_domains WHERE id = p_domain_id; + + IF v_org_id IS NULL THEN + RETURN 'DOMAIN_NOT_FOUND'; + END IF; + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, v_effective_user_id, v_org_id, NULL::varchar, NULL::bigint) THEN + RETURN 'NO_RIGHTS'; + END IF; + + -- Delete domain + DELETE FROM public.org_domains WHERE id = p_domain_id; + + RETURN 'OK'; +END; +$$; + +-- Update update_org_domain_settings to accept optional user_id +CREATE OR REPLACE FUNCTION public.update_org_domain_settings( + p_domain_id uuid, + p_auto_join_enabled boolean DEFAULT NULL, + p_auto_join_role public.user_min_right DEFAULT NULL, + p_user_id uuid DEFAULT NULL +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_org_id uuid; + v_effective_user_id uuid; +BEGIN + v_effective_user_id := COALESCE(p_user_id, auth.uid()); + + -- Get org_id for the domain + SELECT org_id INTO v_org_id FROM public.org_domains WHERE id = p_domain_id; + + IF v_org_id IS NULL THEN + RETURN 'DOMAIN_NOT_FOUND'; + END IF; + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, v_effective_user_id, v_org_id, NULL::varchar, NULL::bigint) THEN + RETURN 'NO_RIGHTS'; + END IF; + + -- Update settings + UPDATE public.org_domains + SET + auto_join_enabled = COALESCE(p_auto_join_enabled, auto_join_enabled), + auto_join_role = COALESCE(p_auto_join_role, auto_join_role), + updated_at = now() + WHERE id = p_domain_id; + + RETURN 'OK'; +END; +$$; + +-- Update add_org_domain to accept optional user_id +CREATE OR REPLACE FUNCTION public.add_org_domain( + p_org_id uuid, + p_domain varchar, + p_user_id uuid DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + verification_token varchar, + error_code text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_token varchar; + v_id uuid; + v_effective_user_id uuid; +BEGIN + v_effective_user_id := COALESCE(p_user_id, auth.uid()); + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, v_effective_user_id, p_org_id, NULL::varchar, NULL::bigint) THEN + RETURN QUERY SELECT NULL::uuid, NULL::varchar, 'NO_RIGHTS'::text; + RETURN; + END IF; + + -- Check if org is Enterprise + IF NOT public.is_enterprise_org(p_org_id) THEN + RETURN QUERY SELECT NULL::uuid, NULL::varchar, 'REQUIRES_ENTERPRISE'::text; + RETURN; + END IF; + + -- Check if domain is already claimed + IF EXISTS (SELECT 1 FROM public.org_domains WHERE domain = lower(p_domain)) THEN + RETURN QUERY SELECT NULL::uuid, NULL::varchar, 'DOMAIN_ALREADY_CLAIMED'::text; + RETURN; + END IF; + + -- Generate verification token + v_token := encode(gen_random_bytes(32), 'hex'); + + -- Insert domain + INSERT INTO public.org_domains (org_id, domain, verification_token) + VALUES (p_org_id, lower(p_domain), v_token) + RETURNING org_domains.id INTO v_id; + + RETURN QUERY SELECT v_id, v_token, NULL::text; +END; +$$; + +-- Update upsert_org_sso_provider to accept optional user_id +CREATE OR REPLACE FUNCTION public.upsert_org_sso_provider( + p_org_id uuid, + p_supabase_sso_provider_id uuid DEFAULT NULL, + p_provider_type varchar DEFAULT 'saml', + p_display_name varchar DEFAULT NULL, + p_metadata_url text DEFAULT NULL, + p_enabled boolean DEFAULT false, + p_user_id uuid DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + error_code text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_id uuid; + v_effective_user_id uuid; +BEGIN + v_effective_user_id := COALESCE(p_user_id, auth.uid()); + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, v_effective_user_id, p_org_id, NULL::varchar, NULL::bigint) THEN + RETURN QUERY SELECT NULL::uuid, 'NO_RIGHTS'::text; + RETURN; + END IF; + + -- Check if org is Enterprise + IF NOT public.is_enterprise_org(p_org_id) THEN + RETURN QUERY SELECT NULL::uuid, 'REQUIRES_ENTERPRISE'::text; + RETURN; + END IF; + + -- Upsert SSO provider + INSERT INTO public.org_sso_providers ( + org_id, supabase_sso_provider_id, provider_type, display_name, metadata_url, enabled + ) + VALUES ( + p_org_id, p_supabase_sso_provider_id, p_provider_type, p_display_name, p_metadata_url, p_enabled + ) + ON CONFLICT (org_id) DO UPDATE SET + supabase_sso_provider_id = COALESCE(EXCLUDED.supabase_sso_provider_id, org_sso_providers.supabase_sso_provider_id), + provider_type = EXCLUDED.provider_type, + display_name = EXCLUDED.display_name, + metadata_url = EXCLUDED.metadata_url, + enabled = EXCLUDED.enabled, + updated_at = now() + RETURNING org_sso_providers.id INTO v_id; + + RETURN QUERY SELECT v_id, NULL::text; +END; +$$; + +-- Update delete_org_sso_provider to accept optional user_id +CREATE OR REPLACE FUNCTION public.delete_org_sso_provider( + p_org_id uuid, + p_user_id uuid DEFAULT NULL +) +RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_effective_user_id uuid; +BEGIN + v_effective_user_id := COALESCE(p_user_id, auth.uid()); + + -- Check if user has super_admin rights + IF NOT public.check_min_rights('super_admin'::public.user_min_right, v_effective_user_id, p_org_id, NULL::varchar, NULL::bigint) THEN + RETURN 'NO_RIGHTS'; + END IF; + + -- Delete SSO provider + DELETE FROM public.org_sso_providers WHERE org_id = p_org_id; + + RETURN 'OK'; +END; +$$; + +-- Update count_domain_users to use lower() for case-insensitive matching +CREATE OR REPLACE FUNCTION public.count_domain_users( + p_domain varchar, + p_org_id uuid, + p_user_id uuid DEFAULT NULL +) +RETURNS integer +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_count integer; + v_effective_user_id uuid; +BEGIN + v_effective_user_id := COALESCE(p_user_id, auth.uid()); + + -- Check if user has admin rights + IF NOT public.check_min_rights('admin'::public.user_min_right, v_effective_user_id, p_org_id, NULL::varchar, NULL::bigint) THEN + RETURN -1; + END IF; + + SELECT COUNT(*)::integer INTO v_count + FROM public.users u + WHERE lower(split_part(u.email, '@', 2)) = lower(p_domain) + AND NOT EXISTS ( + SELECT 1 FROM public.org_users ou + WHERE ou.user_id = u.id AND ou.org_id = p_org_id + ); + + RETURN v_count; +END; +$$; diff --git a/supabase/tests/41_test_sso_auto_join.sql b/supabase/tests/41_test_sso_auto_join.sql new file mode 100644 index 0000000000..cafdf75426 --- /dev/null +++ b/supabase/tests/41_test_sso_auto_join.sql @@ -0,0 +1,377 @@ +-- Tests for SSO Auto-Join functionality +-- Tests: org_sso_providers, org_domains tables, RPC functions, triggers + +BEGIN; + +SELECT plan(28); + +-- ============================================================================ +-- SETUP: Create test data +-- ============================================================================ + +-- Create test organization with Enterprise plan +SELECT tests.authenticate_as_service_role(); + +-- Create a stripe_info entry for Enterprise plan +INSERT INTO public.stripe_info (customer_id, product_id, status, is_good_plan) +VALUES ('cus_test_sso_enterprise', 'prod_LQIs1Yucml9ChU', 'succeeded', true); + +-- Create test organization +INSERT INTO public.orgs (id, name, management_email, created_by, customer_id) +VALUES ( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + 'SSO Test Enterprise Org', + 'admin@sso-test.com', + tests.get_supabase_uid('test_admin'), + 'cus_test_sso_enterprise' +); + +-- Add test_admin as super_admin +INSERT INTO public.org_users (user_id, org_id, user_right) +VALUES ( + tests.get_supabase_uid('test_admin'), + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + 'super_admin' +); + +-- Create non-Enterprise org for testing gating +INSERT INTO public.stripe_info (customer_id, product_id, status, is_good_plan) +VALUES ('cus_test_sso_basic', 'prod_LQIregjtNduh4q', 'succeeded', true); -- Solo plan + +INSERT INTO public.orgs (id, name, management_email, created_by, customer_id) +VALUES ( + 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + 'SSO Test Basic Org', + 'basic@sso-test.com', + tests.get_supabase_uid('test_user'), + 'cus_test_sso_basic' +); + +INSERT INTO public.org_users (user_id, org_id, user_right) +VALUES ( + tests.get_supabase_uid('test_user'), + 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + 'super_admin' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 1: is_enterprise_org function +-- ============================================================================ + +SELECT tests.authenticate_as('test_admin'); + +SELECT is( + public.is_enterprise_org('a1b2c3d4-e5f6-7890-abcd-ef1234567890'), + true, + 'is_enterprise_org should return true for Enterprise plan org' +); + +SELECT is( + public.is_enterprise_org('b2c3d4e5-f6a7-8901-bcde-f12345678901'), + false, + 'is_enterprise_org should return false for non-Enterprise plan org' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 2: add_org_domain function +-- ============================================================================ + +SELECT tests.authenticate_as('test_admin'); + +-- Test adding domain to Enterprise org (should succeed) +SELECT ok( + (SELECT id FROM (SELECT * FROM public.add_org_domain('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'enterprise-test.com')) AS t WHERE error_code IS NULL) IS NOT NULL, + 'add_org_domain should succeed for Enterprise org' +); + +SELECT ok( + (SELECT verification_token FROM (SELECT * FROM public.add_org_domain('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'enterprise-test2.com')) AS t) IS NOT NULL, + 'add_org_domain should return verification token' +); + +SELECT tests.clear_authentication(); + +-- Test adding domain to non-Enterprise org (should fail) +SELECT tests.authenticate_as('test_user'); + +SELECT is( + (SELECT error_code FROM (SELECT * FROM public.add_org_domain('b2c3d4e5-f6a7-8901-bcde-f12345678901', 'basic-test.com')) AS t), + 'REQUIRES_ENTERPRISE', + 'add_org_domain should fail for non-Enterprise org' +); + +SELECT tests.clear_authentication(); + +-- Test duplicate domain claim (should fail) +SELECT tests.authenticate_as('test_admin'); + +SELECT is( + (SELECT error_code FROM (SELECT * FROM public.add_org_domain('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'enterprise-test.com')) AS t), + 'DOMAIN_ALREADY_CLAIMED', + 'add_org_domain should fail for already claimed domain' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 3: get_org_domains function +-- ============================================================================ + +SELECT tests.authenticate_as('test_admin'); + +SELECT ok( + (SELECT COUNT(*) FROM public.get_org_domains('a1b2c3d4-e5f6-7890-abcd-ef1234567890')) >= 2, + 'get_org_domains should return at least 2 domains for Enterprise org' +); + +SELECT ok( + EXISTS(SELECT 1 FROM public.get_org_domains('a1b2c3d4-e5f6-7890-abcd-ef1234567890') WHERE domain = 'enterprise-test.com'), + 'get_org_domains should include enterprise-test.com domain' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 4: verify_org_domain function +-- ============================================================================ + +SELECT tests.authenticate_as_service_role(); + +-- Get a domain ID to verify +SELECT ok( + (SELECT id FROM public.org_domains WHERE domain = 'enterprise-test.com' AND org_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890') IS NOT NULL, + 'Domain enterprise-test.com should exist' +); + +-- Manually mark domain as verified (simulating DNS verification success) +UPDATE public.org_domains +SET verified = true, verified_at = now() +WHERE domain = 'enterprise-test.com' AND org_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +SELECT is( + (SELECT verified FROM public.org_domains WHERE domain = 'enterprise-test.com'), + true, + 'Domain should be marked as verified' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 5: update_org_domain_settings function +-- ============================================================================ + +SELECT tests.authenticate_as('test_admin'); + +-- Get domain ID +SELECT ok( + (SELECT id FROM public.org_domains WHERE domain = 'enterprise-test.com') IS NOT NULL, + 'Domain should exist for settings update test' +); + +-- Update auto_join_enabled +SELECT is( + public.update_org_domain_settings( + (SELECT id FROM public.org_domains WHERE domain = 'enterprise-test.com'), + false, + NULL + ), + 'OK', + 'update_org_domain_settings should succeed for valid domain' +); + +-- Verify the update +SELECT is( + (SELECT auto_join_enabled FROM public.org_domains WHERE domain = 'enterprise-test.com'), + false, + 'auto_join_enabled should be updated to false' +); + +-- Update auto_join_role +SELECT is( + public.update_org_domain_settings( + (SELECT id FROM public.org_domains WHERE domain = 'enterprise-test.com'), + true, + 'write' + ), + 'OK', + 'update_org_domain_settings should update role' +); + +SELECT is( + (SELECT auto_join_role FROM public.org_domains WHERE domain = 'enterprise-test.com'), + 'write'::public.user_min_right, + 'auto_join_role should be updated to write' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 6: remove_org_domain function +-- ============================================================================ + +SELECT tests.authenticate_as('test_admin'); + +-- Remove a domain +SELECT is( + public.remove_org_domain( + (SELECT id FROM public.org_domains WHERE domain = 'enterprise-test2.com') + ), + 'OK', + 'remove_org_domain should succeed for valid domain' +); + +SELECT ok( + NOT EXISTS(SELECT 1 FROM public.org_domains WHERE domain = 'enterprise-test2.com'), + 'Domain should be deleted after remove_org_domain' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 7: upsert_org_sso_provider function +-- ============================================================================ + +SELECT tests.authenticate_as('test_admin'); + +-- Create SSO provider for Enterprise org +SELECT ok( + (SELECT id FROM ( + SELECT * FROM public.upsert_org_sso_provider( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + NULL, + 'saml', + 'Test Okta', + 'https://test.okta.com/metadata', + true + ) + ) AS t WHERE error_code IS NULL) IS NOT NULL, + 'upsert_org_sso_provider should succeed for Enterprise org' +); + +SELECT tests.clear_authentication(); + +-- Test non-Enterprise org (should fail) +SELECT tests.authenticate_as('test_user'); + +SELECT is( + (SELECT error_code FROM ( + SELECT * FROM public.upsert_org_sso_provider( + 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + NULL, + 'saml', + 'Test Okta', + 'https://test.okta.com/metadata', + true + ) + ) AS t), + 'REQUIRES_ENTERPRISE', + 'upsert_org_sso_provider should fail for non-Enterprise org' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 8: get_org_sso_config function +-- ============================================================================ + +SELECT tests.authenticate_as('test_admin'); + +SELECT ok( + (SELECT COUNT(*) FROM public.get_org_sso_config('a1b2c3d4-e5f6-7890-abcd-ef1234567890')) = 1, + 'get_org_sso_config should return 1 SSO config' +); + +SELECT is( + (SELECT display_name FROM public.get_org_sso_config('a1b2c3d4-e5f6-7890-abcd-ef1234567890') LIMIT 1), + 'Test Okta', + 'SSO config should have correct display_name' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 9: Auto-join trigger on user creation +-- ============================================================================ + +SELECT tests.authenticate_as_service_role(); + +-- Ensure domain has auto_join enabled +UPDATE public.org_domains +SET auto_join_enabled = true, auto_join_role = 'read'::public.user_min_right +WHERE domain = 'enterprise-test.com'; + +-- Create a new user with matching domain email +SELECT tests.create_supabase_user('sso_test_user', 'newuser@enterprise-test.com'); + +-- Create user profile +INSERT INTO public.users (id, email, first_name, last_name) +VALUES ( + tests.get_supabase_uid('sso_test_user'), + 'newuser@enterprise-test.com', + 'SSO', + 'User' +); + +-- Check if user was auto-added to org +SELECT ok( + EXISTS( + SELECT 1 FROM public.org_users + WHERE user_id = tests.get_supabase_uid('sso_test_user') + AND org_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ), + 'Auto-join trigger should add user to org based on email domain' +); + +SELECT is( + (SELECT user_right FROM public.org_users + WHERE user_id = tests.get_supabase_uid('sso_test_user') + AND org_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'), + 'read'::public.user_min_right, + 'Auto-joined user should have the configured role (read)' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- TEST 10: count_domain_users function +-- ============================================================================ + +SELECT tests.authenticate_as('test_admin'); + +SELECT ok( + public.count_domain_users('enterprise-test.com', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890') >= 0, + 'count_domain_users should return a non-negative number' +); + +SELECT tests.clear_authentication(); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +SELECT tests.authenticate_as_service_role(); + +-- Delete test data +DELETE FROM public.org_users WHERE org_id IN ( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + 'b2c3d4e5-f6a7-8901-bcde-f12345678901' +); +DELETE FROM public.org_sso_providers WHERE org_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; +DELETE FROM public.org_domains WHERE org_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; +DELETE FROM public.orgs WHERE id IN ( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + 'b2c3d4e5-f6a7-8901-bcde-f12345678901' +); +DELETE FROM public.stripe_info WHERE customer_id IN ('cus_test_sso_enterprise', 'cus_test_sso_basic'); +DELETE FROM public.users WHERE id = tests.get_supabase_uid('sso_test_user'); +DELETE FROM auth.users WHERE raw_user_meta_data ->> 'test_identifier' = 'sso_test_user'; + +SELECT tests.clear_authentication(); + +SELECT * FROM finish(); + +ROLLBACK; diff --git a/tests/sso.test.ts b/tests/sso.test.ts new file mode 100644 index 0000000000..97a00cdae0 --- /dev/null +++ b/tests/sso.test.ts @@ -0,0 +1,532 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { BASE_URL, getSupabaseClient, headers, TEST_EMAIL, USER_ID } from './test-utils.ts' + +// Test org and domain IDs +const SSO_TEST_ORG_ID = randomUUID() +const SSO_TEST_ORG_ID_BASIC = randomUUID() +const globalId = randomUUID() +const enterpriseCustomerId = `cus_test_sso_enterprise_${globalId}` +const basicCustomerId = `cus_test_sso_basic_${globalId}` +const testDomain = `sso-test-${globalId}.com` +const testDomain2 = `sso-test2-${globalId}.com` + +let createdDomainId: string | null = null +let createdSsoConfigId: string | null = null + +beforeAll(async () => { + const supabase = getSupabaseClient() + + // Create Enterprise stripe_info + const { error: stripeError } = await supabase.from('stripe_info').insert({ + customer_id: enterpriseCustomerId, + status: 'succeeded', + product_id: 'prod_LQIs1Yucml9ChU', // Enterprise plan + subscription_id: `sub_enterprise_${globalId}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + // Create test organization with Enterprise plan + const { error: orgError } = await supabase.from('orgs').insert({ + id: SSO_TEST_ORG_ID, + name: `SSO Test Enterprise Org ${globalId}`, + management_email: TEST_EMAIL, + created_by: USER_ID, + customer_id: enterpriseCustomerId, + }) + if (orgError) + throw orgError + + // Create Basic/Solo stripe_info + const { error: stripeBasicError } = await supabase.from('stripe_info').insert({ + customer_id: basicCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', // Solo plan + subscription_id: `sub_basic_${globalId}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeBasicError) + throw stripeBasicError + + // Create test organization with Basic plan (for gating tests) + const { error: orgBasicError } = await supabase.from('orgs').insert({ + id: SSO_TEST_ORG_ID_BASIC, + name: `SSO Test Basic Org ${globalId}`, + management_email: TEST_EMAIL, + created_by: USER_ID, + customer_id: basicCustomerId, + }) + if (orgBasicError) + throw orgBasicError +}) + +afterAll(async () => { + const supabase = getSupabaseClient() + + // Clean up SSO providers + await (supabase as any).from('org_sso_providers').delete().eq('org_id', SSO_TEST_ORG_ID) + + // Clean up domains + await (supabase as any).from('org_domains').delete().eq('org_id', SSO_TEST_ORG_ID) + + // Clean up orgs (will cascade to org_users) + await supabase.from('orgs').delete().eq('id', SSO_TEST_ORG_ID) + await supabase.from('orgs').delete().eq('id', SSO_TEST_ORG_ID_BASIC) + + // Clean up stripe_info + await supabase.from('stripe_info').delete().eq('customer_id', enterpriseCustomerId) + await supabase.from('stripe_info').delete().eq('customer_id', basicCustomerId) +}) + +// ============================================================================ +// SSO CONFIG ENDPOINTS +// ============================================================================ + +describe('[GET] /sso/config', () => { + it('get SSO config for Enterprise org', async () => { + const response = await fetch(`${BASE_URL}/sso/config?org_id=${SSO_TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { config: object | null, is_enterprise: boolean } + expect(data.is_enterprise).toBe(true) + expect(data.config).toBeNull() // No config yet + }) + + it('get SSO config for non-Enterprise org', async () => { + const response = await fetch(`${BASE_URL}/sso/config?org_id=${SSO_TEST_ORG_ID_BASIC}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { config: object | null, is_enterprise: boolean } + expect(data.is_enterprise).toBe(false) + }) + + it('get SSO config with missing org_id', async () => { + const response = await fetch(`${BASE_URL}/sso/config`, { + headers, + }) + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('missing_org_id') + }) + + it('get SSO config with invalid org_id', async () => { + const invalidOrgId = randomUUID() + const response = await fetch(`${BASE_URL}/sso/config?org_id=${invalidOrgId}`, { + headers, + }) + expect(response.status).toBe(401) + }) +}) + +describe('[POST] /sso/config', () => { + it('create SSO config for Enterprise org', async () => { + const response = await fetch(`${BASE_URL}/sso/config`, { + method: 'POST', + headers, + body: JSON.stringify({ + org_id: SSO_TEST_ORG_ID, + provider_type: 'saml', + display_name: 'Test Okta', + metadata_url: 'https://test.okta.com/metadata', + enabled: false, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { id: string, success: boolean } + expect(data.success).toBe(true) + expect(data.id).toBeDefined() + createdSsoConfigId = data.id + }) + + it('update existing SSO config', async () => { + const response = await fetch(`${BASE_URL}/sso/config`, { + method: 'POST', + headers, + body: JSON.stringify({ + org_id: SSO_TEST_ORG_ID, + display_name: 'Updated Okta', + enabled: true, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { success: boolean } + expect(data.success).toBe(true) + }) + + it('create SSO config for non-Enterprise org (should fail)', async () => { + const response = await fetch(`${BASE_URL}/sso/config`, { + method: 'POST', + headers, + body: JSON.stringify({ + org_id: SSO_TEST_ORG_ID_BASIC, + provider_type: 'saml', + display_name: 'Test Okta', + metadata_url: 'https://test.okta.com/metadata', + enabled: false, + }), + }) + + expect(response.status).toBe(403) + const data = await response.json() as { error: string } + expect(data.error).toBe('requires_enterprise') + }) + + it('create SSO config with invalid body', async () => { + const response = await fetch(`${BASE_URL}/sso/config`, { + method: 'POST', + headers, + body: JSON.stringify({}), + }) + expect(response.status).toBe(400) + }) + + it('verify SSO config was created', async () => { + const response = await fetch(`${BASE_URL}/sso/config?org_id=${SSO_TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { config: { display_name: string, enabled: boolean } } + expect(data.config).not.toBeNull() + expect(data.config.display_name).toBe('Updated Okta') + expect(data.config.enabled).toBe(true) + }) +}) + +// ============================================================================ +// DOMAIN ENDPOINTS +// ============================================================================ + +describe('[GET] /sso/domains', () => { + it('get domains for Enterprise org (empty)', async () => { + const response = await fetch(`${BASE_URL}/sso/domains?org_id=${SSO_TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { domains: any[], is_enterprise: boolean } + expect(data.is_enterprise).toBe(true) + expect(Array.isArray(data.domains)).toBe(true) + }) + + it('get domains with missing org_id', async () => { + const response = await fetch(`${BASE_URL}/sso/domains`, { + headers, + }) + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('missing_org_id') + }) + + it('get domains with invalid org_id', async () => { + const invalidOrgId = randomUUID() + const response = await fetch(`${BASE_URL}/sso/domains?org_id=${invalidOrgId}`, { + headers, + }) + expect(response.status).toBe(401) + }) +}) + +describe('[POST] /sso/domains', () => { + it('add domain to Enterprise org', async () => { + const response = await fetch(`${BASE_URL}/sso/domains`, { + method: 'POST', + headers, + body: JSON.stringify({ + org_id: SSO_TEST_ORG_ID, + domain: testDomain, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { id: string, verification_token: string, dns_record: string, success: boolean } + expect(data.success).toBe(true) + expect(data.id).toBeDefined() + expect(data.verification_token).toBeDefined() + expect(data.dns_record).toBe(`_capgo-verification.${testDomain}`) + createdDomainId = data.id + }) + + it('add second domain to Enterprise org', async () => { + const response = await fetch(`${BASE_URL}/sso/domains`, { + method: 'POST', + headers, + body: JSON.stringify({ + org_id: SSO_TEST_ORG_ID, + domain: testDomain2, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { success: boolean } + expect(data.success).toBe(true) + }) + + it('add duplicate domain (should fail)', async () => { + const response = await fetch(`${BASE_URL}/sso/domains`, { + method: 'POST', + headers, + body: JSON.stringify({ + org_id: SSO_TEST_ORG_ID, + domain: testDomain, + }), + }) + + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('DOMAIN_ALREADY_CLAIMED') + }) + + it('add domain to non-Enterprise org (should fail due to plan gating in RPC)', async () => { + const response = await fetch(`${BASE_URL}/sso/domains`, { + method: 'POST', + headers, + body: JSON.stringify({ + org_id: SSO_TEST_ORG_ID_BASIC, + domain: `basic-test-${globalId}.com`, + }), + }) + + // The RPC function checks Enterprise plan and returns error code + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('REQUIRES_ENTERPRISE') + }) + + it('add domain with invalid body', async () => { + const response = await fetch(`${BASE_URL}/sso/domains`, { + method: 'POST', + headers, + body: JSON.stringify({}), + }) + expect(response.status).toBe(400) + }) + + it('verify domains were added', async () => { + const response = await fetch(`${BASE_URL}/sso/domains?org_id=${SSO_TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { domains: any[] } + expect(data.domains.length).toBeGreaterThanOrEqual(2) + + const domain1 = data.domains.find((d: any) => d.domain === testDomain) + expect(domain1).toBeDefined() + expect(domain1.verified).toBe(false) + expect(domain1.auto_join_enabled).toBe(true) + }) +}) + +describe('[PUT] /sso/domains/settings', () => { + it('update domain settings', async () => { + if (!createdDomainId) + throw new Error('Domain was not created in previous test') + + const response = await fetch(`${BASE_URL}/sso/domains/settings`, { + method: 'PUT', + headers, + body: JSON.stringify({ + domain_id: createdDomainId, + auto_join_enabled: false, + auto_join_role: 'write', + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { success: boolean } + expect(data.success).toBe(true) + }) + + it('verify settings were updated', async () => { + const response = await fetch(`${BASE_URL}/sso/domains?org_id=${SSO_TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { domains: any[] } + + const domain = data.domains.find((d: any) => d.id === createdDomainId) + expect(domain).toBeDefined() + expect(domain.auto_join_enabled).toBe(false) + expect(domain.auto_join_role).toBe('write') + }) + + it('update domain settings with invalid domain_id', async () => { + const response = await fetch(`${BASE_URL}/sso/domains/settings`, { + method: 'PUT', + headers, + body: JSON.stringify({ + domain_id: randomUUID(), + auto_join_enabled: true, + }), + }) + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('domain_not_found') + }) + + it('update domain settings with invalid body', async () => { + const response = await fetch(`${BASE_URL}/sso/domains/settings`, { + method: 'PUT', + headers, + body: JSON.stringify({}), + }) + expect(response.status).toBe(400) + }) +}) + +describe('[POST] /sso/domains/verify', () => { + it('verify domain with missing DNS record (should fail)', async () => { + if (!createdDomainId) + throw new Error('Domain was not created in previous test') + + const response = await fetch(`${BASE_URL}/sso/domains/verify`, { + method: 'POST', + headers, + body: JSON.stringify({ + domain_id: createdDomainId, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { success: boolean, verified: boolean, error?: string } + expect(data.success).toBe(false) + expect(data.verified).toBe(false) + expect(data.error).toBe('dns_lookup_failed') + }) + + it('verify domain with invalid domain_id', async () => { + const response = await fetch(`${BASE_URL}/sso/domains/verify`, { + method: 'POST', + headers, + body: JSON.stringify({ + domain_id: randomUUID(), + }), + }) + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('domain_not_found') + }) + + it('verify domain with invalid body', async () => { + const response = await fetch(`${BASE_URL}/sso/domains/verify`, { + method: 'POST', + headers, + body: JSON.stringify({}), + }) + expect(response.status).toBe(400) + }) +}) + +describe('[GET] /sso/domains/preview', () => { + it('preview domain user count', async () => { + const response = await fetch(`${BASE_URL}/sso/domains/preview?org_id=${SSO_TEST_ORG_ID}&domain=${testDomain}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { domain: string, user_count: number } + expect(data.domain).toBe(testDomain) + expect(typeof data.user_count).toBe('number') + expect(data.user_count).toBeGreaterThanOrEqual(0) + }) + + it('preview with missing params', async () => { + const response = await fetch(`${BASE_URL}/sso/domains/preview?org_id=${SSO_TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('missing_params') + }) + + it('preview with invalid org_id', async () => { + const invalidOrgId = randomUUID() + const response = await fetch(`${BASE_URL}/sso/domains/preview?org_id=${invalidOrgId}&domain=test.com`, { + headers, + }) + expect(response.status).toBe(401) + }) +}) + +describe('[DELETE] /sso/domains', () => { + it('delete domain with invalid domain_id', async () => { + const response = await fetch(`${BASE_URL}/sso/domains?domain_id=${randomUUID()}`, { + method: 'DELETE', + headers, + }) + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('domain_not_found') + }) + + it('delete domain with missing domain_id', async () => { + const response = await fetch(`${BASE_URL}/sso/domains`, { + method: 'DELETE', + headers, + }) + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('missing_domain_id') + }) + + it('delete domain', async () => { + if (!createdDomainId) + throw new Error('Domain was not created in previous test') + + const response = await fetch(`${BASE_URL}/sso/domains?domain_id=${createdDomainId}`, { + method: 'DELETE', + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { success: boolean } + expect(data.success).toBe(true) + }) + + it('verify domain was deleted', async () => { + const response = await fetch(`${BASE_URL}/sso/domains?org_id=${SSO_TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { domains: any[] } + + const domain = data.domains.find((d: any) => d.id === createdDomainId) + expect(domain).toBeUndefined() + }) +}) + +describe('[DELETE] /sso/config', () => { + it('delete SSO config with missing org_id', async () => { + const response = await fetch(`${BASE_URL}/sso/config`, { + method: 'DELETE', + headers, + }) + expect(response.status).toBe(400) + const data = await response.json() as { error: string } + expect(data.error).toBe('missing_org_id') + }) + + it('delete SSO config', async () => { + const response = await fetch(`${BASE_URL}/sso/config?org_id=${SSO_TEST_ORG_ID}`, { + method: 'DELETE', + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { success: boolean } + expect(data.success).toBe(true) + }) + + it('verify SSO config was deleted', async () => { + const response = await fetch(`${BASE_URL}/sso/config?org_id=${SSO_TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as { config: object | null } + expect(data.config).toBeNull() + }) +})