diff --git a/.gitignore b/.gitignore index 759a767b3c..01821a2466 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ cloudflare_workers_deno/.denoflare .dev.vars .vars src/types/supabase.types.ts +src/typed-router.d.ts +src/components.d.ts supabase/functions/_backend/utils/supabase.types.ts .env.alpha @@ -85,3 +87,19 @@ internal/Certificates_p12.p12 internal/AuthKey_8P7Y3V99PJ.p8 internal/CICD.mobileprovision internal/Certificates.p12 + +# PR helper files +PR_CHECKLIST.md +PR_DESCRIPTION_AUTO_JOIN.md +PR_DESCRIPTION_FINAL.md +PR_DESCRIPTION_SIMPLE.md +PR_DESCRIPTION.md +SSO_TEST_STATUS.md +PR_DESCRIPTION_DOMAIN_AUTO_JOIN.md +PR_DESCRIPTION_DOMAIN_FINAL.md +PR_DESCRIPTION_DOMAIN_SIMPLE.md +PR_TEMPLATE.md +PR_TEMPLATE_DOMAIN.md +PR_TEMPLATE_SSO.md +PR_TEMPLATE_SSO_DOMAIN.md +PR_TEMPLATE_SSO_DOMAIN_AUTO_JOIN.md diff --git a/.typos.toml b/.typos.toml index 4517d6a334..85e8a8baee 100644 --- a/.typos.toml +++ b/.typos.toml @@ -29,7 +29,7 @@ extend-exclude = [ # Database and Supabase "supabase/.branches/", "supabase/.temp/", - "supabase/schemas/prod.sql", + "supabase/schemas/", # Auto-generated by `supabase db dump` # Assets and data files "*.json", @@ -63,4 +63,5 @@ extend-exclude = [ capgo = "capgo" forgr = "forgr" supabase = "supabase" +pn = "pn" # PostgreSQL alias for pg_namespace in auto-generated schemas # Add more project-specific terms as needed diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index fd99974844..a9978c1950 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -1,5 +1,6 @@ import { env } from 'node:process' import { app as admin_stats } from '../../supabase/functions/_backend/private/admin_stats.ts' +import { app as check_auto_join_orgs } from '../../supabase/functions/_backend/private/check_auto_join_orgs.ts' import { app as config } from '../../supabase/functions/_backend/private/config.ts' import { app as create_device } from '../../supabase/functions/_backend/private/create_device.ts' import { app as credits } from '../../supabase/functions/_backend/private/credits.ts' @@ -7,6 +8,8 @@ import { app as deleted_failed_version } from '../../supabase/functions/_backend import { app as devices_priv } from '../../supabase/functions/_backend/private/devices.ts' import { app as events } from '../../supabase/functions/_backend/private/events.ts' import { app as log_as } from '../../supabase/functions/_backend/private/log_as.ts' +import { app as organization_domains_get } from '../../supabase/functions/_backend/private/organization_domains_get.ts' +import { app as organization_domains_put } from '../../supabase/functions/_backend/private/organization_domains_put.ts' import { app as plans } from '../../supabase/functions/_backend/private/plans.ts' import { app as publicStats } from '../../supabase/functions/_backend/private/public_stats.ts' import { app as stats_priv } from '../../supabase/functions/_backend/private/stats.ts' @@ -77,6 +80,9 @@ appPrivate.route('/stripe_portal', stripe_portal) appPrivate.route('/delete_failed_version', deleted_failed_version) appPrivate.route('/create_device', create_device) appPrivate.route('/events', events) +appPrivate.route('/check_auto_join_orgs', check_auto_join_orgs) +appPrivate.route('/organization_domains_get', organization_domains_get) +appPrivate.route('/organization_domains_put', organization_domains_put) // Triggers const functionNameTriggers = 'triggers' diff --git a/cloudflare_workers/email/classifier.ts b/cloudflare_workers/email/classifier.ts index 47aef6efd1..643df489da 100644 --- a/cloudflare_workers/email/classifier.ts +++ b/cloudflare_workers/email/classifier.ts @@ -149,8 +149,8 @@ Examples: */ function parseClassificationResponse(response: string): ClassificationResult { try { - // Try to extract JSON from the response - const jsonMatch = response.match(/\{[\s\S]*\}/) + // Try to extract JSON from the response (non-greedy to prevent ReDoS) + const jsonMatch = response.match(/\{[\s\S]*?\}/) if (!jsonMatch) { throw new Error('No JSON found in response') } diff --git a/cloudflare_workers/email/discord.ts b/cloudflare_workers/email/discord.ts index 744458f078..b099e8b8a1 100644 --- a/cloudflare_workers/email/discord.ts +++ b/cloudflare_workers/email/discord.ts @@ -192,13 +192,20 @@ function truncateText(text: string, maxLength: number): string { /** * Basic HTML stripping (for simple cases) + * + * Removes all angle brackets to prevent any HTML injection, then normalizes whitespace. + * This is safe for Discord forum posts where we only need plain text content. */ function stripHtml(html: string): string { + if (!html) + return '' + + // Remove all angle brackets immediately to prevent HTML tag reconstruction + // (e.g., "ipt>" could become " diff --git a/src/pages/settings/organization/Security.vue b/src/pages/settings/organization/Security.vue index a5a33d3687..8e97c6850b 100644 --- a/src/pages/settings/organization/Security.vue +++ b/src/pages/settings/organization/Security.vue @@ -143,9 +143,28 @@ function loadPolicyFromOrg() { } // Load API key expiration policy settings -function loadApikeyPolicyFromOrg() { - requireApikeyExpiration.value = currentOrganization.value?.require_apikey_expiration ?? false - maxApikeyExpirationDays.value = currentOrganization.value?.max_apikey_expiration_days ?? null +async function loadApikeyPolicyFromOrg() { + if (!currentOrganization.value?.gid) + return + + try { + const { data: policyData, error: policyError } = await supabase + .from('orgs') + .select('require_apikey_expiration, max_apikey_expiration_days') + .eq('id', currentOrganization.value.gid) + .single() + + if (policyError) { + console.error('Error loading API key policy:', policyError) + return + } + + requireApikeyExpiration.value = policyData?.require_apikey_expiration ?? false + maxApikeyExpirationDays.value = policyData?.max_apikey_expiration_days ?? null + } + catch (error) { + console.error('Error loading API key policy:', error) + } } async function loadData() { @@ -178,7 +197,7 @@ async function loadData() { loadPolicyFromOrg() // Load API key expiration policy settings - loadApikeyPolicyFromOrg() + await loadApikeyPolicyFromOrg() // Load members with their password policy compliance status await loadMembersWithPasswordPolicyStatus() diff --git a/src/pages/settings/organization/autojoin.vue b/src/pages/settings/organization/autojoin.vue new file mode 100644 index 0000000000..deb76b13b6 --- /dev/null +++ b/src/pages/settings/organization/autojoin.vue @@ -0,0 +1,504 @@ + + + + + +meta: + layout: settings + diff --git a/src/typed-router.d.ts b/src/typed-router.d.ts deleted file mode 100644 index 70c7c3e024..0000000000 --- a/src/typed-router.d.ts +++ /dev/null @@ -1,779 +0,0 @@ -/* eslint-disable */ -/* prettier-ignore */ -// @ts-nocheck -// noinspection ES6UnusedImports -// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️ -// It's recommended to commit this file. -// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry. - -declare module 'vue-router/auto-resolver' { - export type ParamParserCustom = never -} - -declare module 'vue-router/auto-routes' { - import type { - RouteRecordInfo, - ParamValue, - ParamValueOneOrMore, - ParamValueZeroOrMore, - ParamValueZeroOrOne, - } from 'vue-router' - - /** - * Route name map generated by unplugin-vue-router - */ - export interface RouteNamedMap { - '/[...all]': RouteRecordInfo< - '/[...all]', - '/:all(.*)', - { all: ParamValue }, - { all: ParamValue }, - | never - >, - '/accountDisabled': RouteRecordInfo< - '/accountDisabled', - '/accountDisabled', - Record, - Record, - | never - >, - '/admin/dashboard/': RouteRecordInfo< - '/admin/dashboard/', - '/admin/dashboard', - Record, - Record, - | never - >, - '/admin/dashboard/performance': RouteRecordInfo< - '/admin/dashboard/performance', - '/admin/dashboard/performance', - Record, - Record, - | never - >, - '/admin/dashboard/replication': RouteRecordInfo< - '/admin/dashboard/replication', - '/admin/dashboard/replication', - Record, - Record, - | never - >, - '/admin/dashboard/revenue': RouteRecordInfo< - '/admin/dashboard/revenue', - '/admin/dashboard/revenue', - Record, - Record, - | never - >, - '/admin/dashboard/updates': RouteRecordInfo< - '/admin/dashboard/updates', - '/admin/dashboard/updates', - Record, - Record, - | never - >, - '/admin/dashboard/users': RouteRecordInfo< - '/admin/dashboard/users', - '/admin/dashboard/users', - Record, - Record, - | never - >, - '/ApiKeys': RouteRecordInfo< - '/ApiKeys', - '/ApiKeys', - Record, - Record, - | never - >, - '/app/': RouteRecordInfo< - '/app/', - '/app', - Record, - Record, - | never - >, - '/app/[package]': RouteRecordInfo< - '/app/[package]', - '/app/:package', - { package: ParamValue }, - { package: ParamValue }, - | never - >, - '/app/[package].builds': RouteRecordInfo< - '/app/[package].builds', - '/app/:package/builds', - { package: ParamValue }, - { package: ParamValue }, - | never - >, - '/app/[package].bundle.[bundle]': RouteRecordInfo< - '/app/[package].bundle.[bundle]', - '/app/:package/bundle/:bundle', - { package: ParamValue, bundle: ParamValue }, - { package: ParamValue, bundle: ParamValue }, - | never - >, - '/app/[package].bundle.[bundle].dependencies': RouteRecordInfo< - '/app/[package].bundle.[bundle].dependencies', - '/app/:package/bundle/:bundle/dependencies', - { package: ParamValue, bundle: ParamValue }, - { package: ParamValue, bundle: ParamValue }, - | never - >, - '/app/[package].bundle.[bundle].history': RouteRecordInfo< - '/app/[package].bundle.[bundle].history', - '/app/:package/bundle/:bundle/history', - { package: ParamValue, bundle: ParamValue }, - { package: ParamValue, bundle: ParamValue }, - | never - >, - '/app/[package].bundle.[bundle].preview': RouteRecordInfo< - '/app/[package].bundle.[bundle].preview', - '/app/:package/bundle/:bundle/preview', - { package: ParamValue, bundle: ParamValue }, - { package: ParamValue, bundle: ParamValue }, - | never - >, - '/app/[package].bundles': RouteRecordInfo< - '/app/[package].bundles', - '/app/:package/bundles', - { package: ParamValue }, - { package: ParamValue }, - | never - >, - '/app/[package].channel.[channel]': RouteRecordInfo< - '/app/[package].channel.[channel]', - '/app/:package/channel/:channel', - { package: ParamValue, channel: ParamValue }, - { package: ParamValue, channel: ParamValue }, - | never - >, - '/app/[package].channel.[channel].devices': RouteRecordInfo< - '/app/[package].channel.[channel].devices', - '/app/:package/channel/:channel/devices', - { package: ParamValue, channel: ParamValue }, - { package: ParamValue, channel: ParamValue }, - | never - >, - '/app/[package].channel.[channel].history': RouteRecordInfo< - '/app/[package].channel.[channel].history', - '/app/:package/channel/:channel/history', - { package: ParamValue, channel: ParamValue }, - { package: ParamValue, channel: ParamValue }, - | never - >, - '/app/[package].channels': RouteRecordInfo< - '/app/[package].channels', - '/app/:package/channels', - { package: ParamValue }, - { package: ParamValue }, - | never - >, - '/app/[package].device.[device]': RouteRecordInfo< - '/app/[package].device.[device]', - '/app/:package/device/:device', - { package: ParamValue, device: ParamValue }, - { package: ParamValue, device: ParamValue }, - | never - >, - '/app/[package].device.[device].deployments': RouteRecordInfo< - '/app/[package].device.[device].deployments', - '/app/:package/device/:device/deployments', - { package: ParamValue, device: ParamValue }, - { package: ParamValue, device: ParamValue }, - | never - >, - '/app/[package].device.[device].logs': RouteRecordInfo< - '/app/[package].device.[device].logs', - '/app/:package/device/:device/logs', - { package: ParamValue, device: ParamValue }, - { package: ParamValue, device: ParamValue }, - | never - >, - '/app/[package].devices': RouteRecordInfo< - '/app/[package].devices', - '/app/:package/devices', - { package: ParamValue }, - { package: ParamValue }, - | never - >, - '/app/[package].info': RouteRecordInfo< - '/app/[package].info', - '/app/:package/info', - { package: ParamValue }, - { package: ParamValue }, - | never - >, - '/app/[package].logs': RouteRecordInfo< - '/app/[package].logs', - '/app/:package/logs', - { package: ParamValue }, - { package: ParamValue }, - | never - >, - '/app/modules': RouteRecordInfo< - '/app/modules', - '/app/modules', - Record, - Record, - | never - >, - '/app/modules_test': RouteRecordInfo< - '/app/modules_test', - '/app/modules_test', - Record, - Record, - | never - >, - '/confirm-signup': RouteRecordInfo< - '/confirm-signup', - '/confirm-signup', - Record, - Record, - | never - >, - '/dashboard': RouteRecordInfo< - '/dashboard', - '/dashboard', - Record, - Record, - | never - >, - '/delete_account': RouteRecordInfo< - '/delete_account', - '/delete_account', - Record, - Record, - | never - >, - '/demo_dialog': RouteRecordInfo< - '/demo_dialog', - '/demo_dialog', - Record, - Record, - | never - >, - '/forgot_password': RouteRecordInfo< - '/forgot_password', - '/forgot_password', - Record, - Record, - | never - >, - '/invitation': RouteRecordInfo< - '/invitation', - '/invitation', - Record, - Record, - | never - >, - '/log-as/[userId]': RouteRecordInfo< - '/log-as/[userId]', - '/log-as/:userId', - { userId: ParamValue }, - { userId: ParamValue }, - | never - >, - '/login': RouteRecordInfo< - '/login', - '/login', - Record, - Record, - | never - >, - '/onboarding/confirm_email': RouteRecordInfo< - '/onboarding/confirm_email', - '/onboarding/confirm_email', - Record, - Record, - | never - >, - '/onboarding/set_password': RouteRecordInfo< - '/onboarding/set_password', - '/onboarding/set_password', - Record, - Record, - | never - >, - '/register': RouteRecordInfo< - '/register', - '/register', - Record, - Record, - | never - >, - '/resend_email': RouteRecordInfo< - '/resend_email', - '/resend_email', - Record, - Record, - | never - >, - '/scan': RouteRecordInfo< - '/scan', - '/scan', - Record, - Record, - | never - >, - '/settings/account/': RouteRecordInfo< - '/settings/account/', - '/settings/account', - Record, - Record, - | never - >, - '/settings/account/ChangePassword': RouteRecordInfo< - '/settings/account/ChangePassword', - '/settings/account/change-password', - Record, - Record, - | never - >, - '/settings/account/Notifications': RouteRecordInfo< - '/settings/account/Notifications', - '/settings/account/Notifications', - Record, - Record, - | never - >, - '/settings/organization/': RouteRecordInfo< - '/settings/organization/', - '/settings/organization', - Record, - Record, - | never - >, - '/settings/organization/Security': RouteRecordInfo< - '/settings/organization/Security', - '/settings/organization/security', - Record, - Record, - | never - >, - '/settings/organization/AuditLogs': RouteRecordInfo< - '/settings/organization/AuditLogs', - '/settings/organization/AuditLogs', - Record, - Record, - | never - >, - '/settings/organization/Credits': RouteRecordInfo< - '/settings/organization/Credits', - '/settings/organization/Credits', - Record, - Record, - | never - >, - '/settings/organization/DeleteOrgDialog': RouteRecordInfo< - '/settings/organization/DeleteOrgDialog', - '/settings/organization/DeleteOrgDialog', - Record, - Record, - | never - >, - '/settings/organization/Members': RouteRecordInfo< - '/settings/organization/Members', - '/settings/organization/Members', - Record, - Record, - | never - >, - '/settings/organization/Notifications': RouteRecordInfo< - '/settings/organization/Notifications', - '/settings/organization/Notifications', - Record, - Record, - | never - >, - '/settings/organization/Plans': RouteRecordInfo< - '/settings/organization/Plans', - '/settings/organization/Plans', - Record, - Record, - | never - >, - '/settings/organization/Usage': RouteRecordInfo< - '/settings/organization/Usage', - '/settings/organization/Usage', - Record, - Record, - | never - >, - '/settings/organization/Webhooks': RouteRecordInfo< - '/settings/organization/Webhooks', - '/settings/organization/Webhooks', - Record, - Record, - | never - >, - '/Webhooks': RouteRecordInfo< - '/Webhooks', - '/Webhooks', - Record, - Record, - | never - >, - } - - /** - * Route file to route info map by unplugin-vue-router. - * Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`. - * - * Each key is a file path relative to the project root with 2 properties: - * - routes: union of route names of the possible routes when in this page (passed to useRoute<...>()) - * - views: names of nested views (can be passed to ) - * - * @internal - */ - export interface _RouteFileInfoMap { - 'src/pages/[...all].vue': { - routes: - | '/[...all]' - views: - | never - } - 'src/pages/accountDisabled.vue': { - routes: - | '/accountDisabled' - views: - | never - } - 'src/pages/admin/dashboard/index.vue': { - routes: - | '/admin/dashboard/' - views: - | never - } - 'src/pages/admin/dashboard/performance.vue': { - routes: - | '/admin/dashboard/performance' - views: - | never - } - 'src/pages/admin/dashboard/replication.vue': { - routes: - | '/admin/dashboard/replication' - views: - | never - } - 'src/pages/admin/dashboard/revenue.vue': { - routes: - | '/admin/dashboard/revenue' - views: - | never - } - 'src/pages/admin/dashboard/updates.vue': { - routes: - | '/admin/dashboard/updates' - views: - | never - } - 'src/pages/admin/dashboard/users.vue': { - routes: - | '/admin/dashboard/users' - views: - | never - } - 'src/pages/ApiKeys.vue': { - routes: - | '/ApiKeys' - views: - | never - } - 'src/pages/app/index.vue': { - routes: - | '/app/' - views: - | never - } - 'src/pages/app/[package].vue': { - routes: - | '/app/[package]' - views: - | never - } - 'src/pages/app/[package].builds.vue': { - routes: - | '/app/[package].builds' - views: - | never - } - 'src/pages/app/[package].bundle.[bundle].vue': { - routes: - | '/app/[package].bundle.[bundle]' - views: - | never - } - 'src/pages/app/[package].bundle.[bundle].dependencies.vue': { - routes: - | '/app/[package].bundle.[bundle].dependencies' - views: - | never - } - 'src/pages/app/[package].bundle.[bundle].history.vue': { - routes: - | '/app/[package].bundle.[bundle].history' - views: - | never - } - 'src/pages/app/[package].bundle.[bundle].preview.vue': { - routes: - | '/app/[package].bundle.[bundle].preview' - views: - | never - } - 'src/pages/app/[package].bundles.vue': { - routes: - | '/app/[package].bundles' - views: - | never - } - 'src/pages/app/[package].channel.[channel].vue': { - routes: - | '/app/[package].channel.[channel]' - views: - | never - } - 'src/pages/app/[package].channel.[channel].devices.vue': { - routes: - | '/app/[package].channel.[channel].devices' - views: - | never - } - 'src/pages/app/[package].channel.[channel].history.vue': { - routes: - | '/app/[package].channel.[channel].history' - views: - | never - } - 'src/pages/app/[package].channels.vue': { - routes: - | '/app/[package].channels' - views: - | never - } - 'src/pages/app/[package].device.[device].vue': { - routes: - | '/app/[package].device.[device]' - views: - | never - } - 'src/pages/app/[package].device.[device].deployments.vue': { - routes: - | '/app/[package].device.[device].deployments' - views: - | never - } - 'src/pages/app/[package].device.[device].logs.vue': { - routes: - | '/app/[package].device.[device].logs' - views: - | never - } - 'src/pages/app/[package].devices.vue': { - routes: - | '/app/[package].devices' - views: - | never - } - 'src/pages/app/[package].info.vue': { - routes: - | '/app/[package].info' - views: - | never - } - 'src/pages/app/[package].logs.vue': { - routes: - | '/app/[package].logs' - views: - | never - } - 'src/pages/app/modules.vue': { - routes: - | '/app/modules' - views: - | never - } - 'src/pages/app/modules_test.vue': { - routes: - | '/app/modules_test' - views: - | never - } - 'src/pages/confirm-signup.vue': { - routes: - | '/confirm-signup' - views: - | never - } - 'src/pages/dashboard.vue': { - routes: - | '/dashboard' - views: - | never - } - 'src/pages/delete_account.vue': { - routes: - | '/delete_account' - views: - | never - } - 'src/pages/demo_dialog.vue': { - routes: - | '/demo_dialog' - views: - | never - } - 'src/pages/forgot_password.vue': { - routes: - | '/forgot_password' - views: - | never - } - 'src/pages/invitation.vue': { - routes: - | '/invitation' - views: - | never - } - 'src/pages/log-as/[userId].vue': { - routes: - | '/log-as/[userId]' - views: - | never - } - 'src/pages/login.vue': { - routes: - | '/login' - views: - | never - } - 'src/pages/onboarding/confirm_email.vue': { - routes: - | '/onboarding/confirm_email' - views: - | never - } - 'src/pages/onboarding/set_password.vue': { - routes: - | '/onboarding/set_password' - views: - | never - } - 'src/pages/register.vue': { - routes: - | '/register' - views: - | never - } - 'src/pages/resend_email.vue': { - routes: - | '/resend_email' - views: - | never - } - 'src/pages/scan.vue': { - routes: - | '/scan' - views: - | never - } - 'src/pages/settings/account/index.vue': { - routes: - | '/settings/account/' - views: - | never - } - 'src/pages/settings/account/ChangePassword.vue': { - routes: - | '/settings/account/ChangePassword' - views: - | never - } - 'src/pages/settings/account/Notifications.vue': { - routes: - | '/settings/account/Notifications' - views: - | never - } - 'src/pages/settings/organization/index.vue': { - routes: - | '/settings/organization/' - views: - | never - } - 'src/pages/settings/organization/Security.vue': { - routes: - | '/settings/organization/Security' - views: - | never - } - 'src/pages/settings/organization/AuditLogs.vue': { - routes: - | '/settings/organization/AuditLogs' - views: - | never - } - 'src/pages/settings/organization/Credits.vue': { - routes: - | '/settings/organization/Credits' - views: - | never - } - 'src/pages/settings/organization/DeleteOrgDialog.vue': { - routes: - | '/settings/organization/DeleteOrgDialog' - views: - | never - } - 'src/pages/settings/organization/Members.vue': { - routes: - | '/settings/organization/Members' - views: - | never - } - 'src/pages/settings/organization/Notifications.vue': { - routes: - | '/settings/organization/Notifications' - views: - | never - } - 'src/pages/settings/organization/Plans.vue': { - routes: - | '/settings/organization/Plans' - views: - | never - } - 'src/pages/settings/organization/Usage.vue': { - routes: - | '/settings/organization/Usage' - views: - | never - } - 'src/pages/settings/organization/Webhooks.vue': { - routes: - | '/settings/organization/Webhooks' - views: - | never - } - 'src/pages/Webhooks.vue': { - routes: - | '/Webhooks' - views: - | never - } - } - - /** - * Get a union of possible route names in a certain route component file. - * Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`. - * - * @internal - */ - export type _RouteNamesForFilePath = - _RouteFileInfoMap extends Record - ? Info['routes'] - : keyof RouteNamedMap -} diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index bfa4dda66d..77993db54d 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -1336,6 +1336,7 @@ export type Database = { } orgs: { Row: { + allowed_email_domains: string[] | null created_at: string | null created_by: string customer_id: string | null @@ -1350,10 +1351,13 @@ export type Database = { name: string password_policy_config: Json | null require_apikey_expiration: boolean + sso_domain_keys: string[] | null + sso_enabled: boolean | null stats_updated_at: string | null updated_at: string | null } Insert: { + allowed_email_domains?: string[] | null created_at?: string | null created_by: string customer_id?: string | null @@ -1368,10 +1372,13 @@ export type Database = { name: string password_policy_config?: Json | null require_apikey_expiration?: boolean + sso_domain_keys?: string[] | null + sso_enabled?: boolean | null stats_updated_at?: string | null updated_at?: string | null } Update: { + allowed_email_domains?: string[] | null created_at?: string | null created_by?: string customer_id?: string | null @@ -1386,6 +1393,8 @@ export type Database = { name?: string password_policy_config?: Json | null require_apikey_expiration?: boolean + sso_domain_keys?: string[] | null + sso_enabled?: boolean | null stats_updated_at?: string | null updated_at?: string | null } @@ -2189,6 +2198,10 @@ export type Database = { overage_unpaid: number }[] } + auto_join_user_to_orgs_by_email: { + Args: { p_email: string; p_user_id: string } + Returns: undefined + } calculate_credit_cost: { Args: { p_metric: Database["public"]["Enums"]["credit_metric_type"] @@ -2290,6 +2303,7 @@ export type Database = { Returns: boolean } expire_usage_credits: { Args: never; Returns: number } + extract_email_domain: { Args: { email: string }; Returns: string } find_apikey_by_value: { Args: { key_value: string } Returns: { @@ -2332,6 +2346,13 @@ export type Database = { name: string }[] } + find_orgs_by_email_domain: { + Args: { user_email: string } + Returns: { + org_id: string + org_name: string + }[] + } get_account_removal_date: { Args: { user_id: string }; Returns: string } get_apikey: { Args: never; Returns: string } get_apikey_header: { Args: never; Returns: string } @@ -2584,7 +2605,6 @@ export type Database = { password_has_access: boolean password_policy_config: Json paying: boolean - require_apikey_expiration: boolean role: string stats_updated_at: string subscription_end: string @@ -2615,7 +2635,6 @@ export type Database = { password_has_access: boolean password_policy_config: Json paying: boolean - require_apikey_expiration: boolean role: string stats_updated_at: string subscription_end: string @@ -2817,6 +2836,7 @@ export type Database = { Args: { org_id: string } Returns: boolean } + is_blocked_email_domain: { Args: { domain: string }; Returns: boolean } is_build_time_exceeded_by_org: { Args: { org_id: string } Returns: boolean diff --git a/supabase/functions/_backend/private/check_auto_join_orgs.ts b/supabase/functions/_backend/private/check_auto_join_orgs.ts new file mode 100644 index 0000000000..723e723fe2 --- /dev/null +++ b/supabase/functions/_backend/private/check_auto_join_orgs.ts @@ -0,0 +1,91 @@ +/** + * Auto-Join Organizations on Login - Check Endpoint + * + * This endpoint is called during user login to check if the user should be automatically + * added to any organizations based on their email domain. This handles the case where: + * 1. A user created their account before a domain was configured for auto-join + * 2. An organization enabled auto-join after the user signed up + * 3. Multiple organizations added the same domain after the user joined + * + * @endpoint POST /private/check_auto_join_orgs + * @authentication JWT (user must be logged in) + * @param {uuid} user_id - User UUID to check for auto-join eligibility + * @returns {object} Result containing number of organizations joined + * - status: 'ok' if successful + * - orgs_joined: Number of organizations the user was added to + * + * Example Flow: + * 1. User logs in with email: john@company.com + * 2. System checks if any orgs have 'company.com' in allowed_email_domains + * 3. If found and sso_enabled=true, adds user to those orgs with 'read' permission + * 4. Returns count of organizations joined + * + * Note: This does NOT block login if it fails - errors are logged but ignored + */ + +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import { Hono } from 'hono' +import { z } from 'zod/mini' +import { middlewareAuth, parseBody, simpleError, useCors } from '../utils/hono.ts' +import { cloudlog } from '../utils/logging.ts' +import { supabaseClient as useSupabaseClient } from '../utils/supabase.ts' + +/** Request body validation schema */ +const bodySchema = z.object({ + user_id: z.uuid(), +}) + +export const app = new Hono() + +app.use('/', useCors) + +/** + * Check and execute auto-join for existing users + * + * Called from src/modules/auth.ts during login flow + * Uses the same database function as signup trigger for consistency + */ +app.post('/', middlewareAuth, async (c) => { + const body = await parseBody(c) + const parsedBodyResult = bodySchema.safeParse(body) + + if (!parsedBodyResult.success) { + return simpleError('invalid_body', 'Invalid body', { error: parsedBodyResult.error }) + } + + const { user_id } = parsedBodyResult.data + const requestId = c.get('requestId') + const authToken = c.req.header('authorization') + + if (!authToken) + return simpleError('not_authorize', 'Not authorize') + + const supabaseClient = useSupabaseClient(c, authToken) + + // Get user's email + const { data: user, error: userError } = await supabaseClient + .from('users') + .select('email') + .eq('id', user_id) + .single() + + if (userError || !user) { + cloudlog({ requestId, message: 'User not found', error: userError }) + return c.json({ error: 'user_not_found' }, 404) + } + + // Call the auto-join function + const { data, error } = await supabaseClient + .rpc('auto_join_user_to_orgs_by_email', { + p_user_id: user_id, + p_email: user.email, + }) + + if (error) { + cloudlog({ requestId, message: 'Error auto-joining user to orgs', error }) + return c.json({ error: 'auto_join_failed' }, 500) + } + + cloudlog({ requestId, message: 'Auto-join check completed', user_id, orgs_joined: data }) + return c.json({ status: 'ok', orgs_joined: data }) +}) diff --git a/supabase/functions/_backend/private/organization_domains_get.ts b/supabase/functions/_backend/private/organization_domains_get.ts new file mode 100644 index 0000000000..62a9e7630c --- /dev/null +++ b/supabase/functions/_backend/private/organization_domains_get.ts @@ -0,0 +1,100 @@ +/** + * Organization Email Domain Auto-Join - GET Endpoint + * + * Retrieves the allowed email domains and auto-join enabled status for an organization. + * This endpoint is used by organization admins to view current auto-join configuration. + * + * @endpoint POST /private/organization_domains_get + * @authentication JWT (requires read, write, or all permissions) + * @returns {object} Organization domain configuration + * - allowed_email_domains: Array of allowed domains (e.g., ['company.com']) + * - sso_enabled: Boolean indicating if auto-join is enabled + */ + +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import { Hono } from 'hono' +import { z } from 'zod/mini' +import { parseBody, simpleError, useCors } from '../utils/hono.ts' +import { middlewareV2 } from '../utils/hono_middleware.ts' +import { cloudlog } from '../utils/logging.ts' +import { supabaseAdmin } from '../utils/supabase.ts' + +/** Request body validation schema */ +const bodySchema = z.object({ + orgId: z.string(), +}) + +export const app = new Hono() + +app.use('/', useCors) + +/** + * GET organization email domains and auto-join status + * + * Flow: + * 1. Validate request body (orgId) + * 2. Check user has org-level permissions (not just app/channel-level) + * 3. Query organization's allowed_email_domains and sso_enabled fields + * 4. Return configuration to frontend + * + * Security: + * - Uses composite index on (org_id, user_id) for fast permission checks + * - Only returns data if user has org-level access (app_id and channel_id are null) + */ +app.post('/', middlewareV2(['all', 'write', 'read']), async (c) => { + const auth = c.get('auth') + const requestId = c.get('requestId') + + if (!auth?.userId) { + return simpleError('unauthorized', 'Authentication required') + } + + const body = await parseBody(c) + const parsedBodyResult = bodySchema.safeParse(body) + if (!parsedBodyResult.success) { + return simpleError('invalid_json_body', 'Invalid json body', { body, parsedBodyResult }) + } + + const safeBody = parsedBodyResult.data + + // Check if user has access to this org (query org-level permissions only) + // Uses composite index idx_org_users_org_user_covering for optimal performance + const supabase = supabaseAdmin(c) + const { data: orgUsers, error: orgUserError } = await supabase + .from('org_users') + .select('user_right, app_id, channel_id') + .eq('org_id', safeBody.orgId) + .eq('user_id', auth.userId) + + if (orgUserError) { + cloudlog({ requestId, message: '[organization_domains_get] Error fetching org permissions', error: orgUserError }) + return simpleError('cannot_access_organization', 'Error checking organization access', { orgId: safeBody.orgId }) + } + + if (!orgUsers || orgUsers.length === 0) { + return simpleError('cannot_access_organization', 'You can\'t access this organization', { orgId: safeBody.orgId }) + } + + // Find org-level permission (where app_id and channel_id are null) + // Users with only app or channel-level access cannot view/modify org settings + const orgLevelPerm = orgUsers.find(u => u.app_id === null && u.channel_id === null) + if (!orgLevelPerm) { + return simpleError('cannot_access_organization', 'You don\'t have org-level access', { orgId: safeBody.orgId }) + } + + const { error, data } = await supabase + .from('orgs') + .select('allowed_email_domains, sso_enabled') + .eq('id', safeBody.orgId) + .single() + + if (error) { + cloudlog({ requestId, message: '[organization_domains_get] Error fetching org domains', error }) + return simpleError('cannot_get_org_domains', 'Cannot get organization allowed email domains', { error: error.message }) + } + + return c.json({ + allowed_email_domains: data?.allowed_email_domains || [], + sso_enabled: data?.sso_enabled || false, + }, 200) +}) diff --git a/supabase/functions/_backend/private/organization_domains_put.ts b/supabase/functions/_backend/private/organization_domains_put.ts new file mode 100644 index 0000000000..31b953b7bc --- /dev/null +++ b/supabase/functions/_backend/private/organization_domains_put.ts @@ -0,0 +1,160 @@ +/** + * Organization Email Domain Auto-Join - PUT Endpoint + * + * Updates the allowed email domains and auto-join enabled status for an organization. + * This endpoint is restricted to organization admins and super_admins only. + * + * @endpoint POST /private/organization_domains_put + * @authentication JWT (requires admin or super_admin permissions) + * @param {string} orgId - Organization UUID + * @param {string[]} domains - Array of email domains (e.g., ['company.com']) + * @param {boolean} enabled - Whether auto-join is enabled (default: false) + * @returns {object} Updated organization domain configuration + * + * Security Constraints: + * - Blocks public email domains (gmail.com, yahoo.com, etc.) via CHECK constraint + * - Enforces unique SSO domain constraint (one domain can only belong to one SSO-enabled org) + * - Requires admin or super_admin role to modify + */ + +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import { and, eq } from 'drizzle-orm' +import { Hono } from 'hono' +import { z } from 'zod/mini' +import { parseBody, simpleError, useCors } from '../utils/hono.ts' +import { middlewareV2 } from '../utils/hono_middleware.ts' +import { cloudlog } from '../utils/logging.ts' +import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' +import { org_users, orgs } from '../utils/postgres_schema.ts' + +/** Request body validation schema */ +const bodySchema = z.object({ + orgId: z.string(), + domains: z.array(z.string()), +}) + +export const app = new Hono() + +app.use('/', useCors) + +/** + * UPDATE organization email domains and auto-join status + * + * Flow: + * 1. Validate request body (orgId, domains, enabled) + * 2. Check user has admin or super_admin permissions for the organization + * 3. Update orgs table with new domains and enabled state + * 4. Handle constraint violations (blocked domains, SSO conflicts) + * 5. Return updated configuration + * + * Error Handling: + * - Returns specific error codes for constraint violations + * - Provides user-friendly messages for blocked domains + * - Handles SSO domain conflicts gracefully + */ +app.post('/', middlewareV2(['all', 'write']), async (c) => { + const auth = c.get('auth') + const requestId = c.get('requestId') + + if (!auth?.userId) { + return simpleError('unauthorized', 'Authentication required') + } + + const body = await parseBody(c) + + // Read enabled from bodyRaw directly (not in zod schema since zod/mini doesn't support optional/nullable) + const enabled = body.enabled === true || body.enabled === false ? body.enabled : false + + const parsedBodyResult = bodySchema.safeParse(body) + if (!parsedBodyResult.success) { + return simpleError('invalid_json_body', 'Invalid json body', { body, parsedBodyResult }) + } + + const safeBody = parsedBodyResult.data + + // Initialize Drizzle client once for all database operations + const pgClient = getPgClient(c, true) + const drizzleClient = getDrizzleClient(pgClient) + + try { + // Check if user has admin rights for this org (query org-level permissions only) + let orgUsersResult + try { + orgUsersResult = await drizzleClient + .select({ + user_right: org_users.user_right, + app_id: org_users.app_id, + channel_id: org_users.channel_id, + }) + .from(org_users) + .where(and( + eq(org_users.org_id, safeBody.orgId), + eq(org_users.user_id, auth.userId), + )) + } + catch (error: any) { + await closeClient(c, pgClient) + cloudlog({ requestId, message: '[organization_domains_put] Error fetching org permissions', error }) + return simpleError('cannot_access_organization', 'Error checking organization access', { orgId: safeBody.orgId }) + } + + if (!orgUsersResult || orgUsersResult.length === 0) { + await closeClient(c, pgClient) + return simpleError('cannot_access_organization', 'You can\'t access this organization', { orgId: safeBody.orgId }) + } + + // Find org-level permission (where app_id and channel_id are null) + const orgLevelPerm = orgUsersResult.find(u => u.app_id === null && u.channel_id === null) + if (!orgLevelPerm) { + await closeClient(c, pgClient) + return simpleError('cannot_access_organization', 'You don\'t have org-level access', { orgId: safeBody.orgId }) + } + + // Check if user has admin or super_admin rights + if (orgLevelPerm.user_right !== 'admin' && orgLevelPerm.user_right !== 'super_admin') { + await closeClient(c, pgClient) + return simpleError('insufficient_permissions', 'You need admin rights to modify organization domains', { orgId: safeBody.orgId, userRight: orgLevelPerm.user_right }) + } + + // Update the allowed domains and enabled state using Drizzle ORM + const updatedOrgs = await drizzleClient + .update(orgs) + .set({ + allowed_email_domains: safeBody.domains, + sso_enabled: enabled, + }) + .where(eq(orgs.id, safeBody.orgId)) + .returning({ + allowed_email_domains: orgs.allowed_email_domains, + sso_enabled: orgs.sso_enabled, + }) + + await closeClient(c, pgClient) + + // Verify the update affected a row + if (!updatedOrgs || updatedOrgs.length === 0) { + cloudlog({ requestId, message: '[organization_domains_put] No organization found to update', orgId: safeBody.orgId }) + return c.json({ status: 'Organization not found', orgId: safeBody.orgId }, 404) + } + + const data = updatedOrgs[0] + return c.json({ + allowed_email_domains: data?.allowed_email_domains || [], + sso_enabled: data?.sso_enabled || false, + }, 200) + } + catch (error: any) { + await closeClient(c, pgClient) + cloudlog({ requestId, message: '[organization_domains_put] Error updating org domains', error }) + + // Check for PostgreSQL constraint violations + // Drizzle returns error.code for PostgreSQL error codes + if (error.code === '23514' || error.message?.includes('blocked_domain') || error.message?.includes('public email provider')) { + return simpleError('blocked_domain', 'This domain is a public email provider and cannot be used', { domains: safeBody.domains }) + } + if (error.code === '23505' || error.message?.includes('unique_sso_domain') || error.message?.includes('already claimed')) { + return simpleError('domain_already_used', 'This domain is already in use by another organization', { domains: safeBody.domains }) + } + return simpleError('cannot_update_org_domains', 'Cannot update organization allowed email domains', { error: error.message }) + } +}) diff --git a/supabase/functions/_backend/public/organization/domains/get.ts b/supabase/functions/_backend/public/organization/domains/get.ts new file mode 100644 index 0000000000..746f0165fc --- /dev/null +++ b/supabase/functions/_backend/public/organization/domains/get.ts @@ -0,0 +1,52 @@ +import type { Context } from 'hono' +import type { Database } from '../../../utils/supabase.types.ts' +import { z } from 'zod/mini' +import { simpleError } from '../../../utils/hono.ts' +import { apikeyHasOrgRight, hasOrgRightApikey, supabaseApikey } from '../../../utils/supabase.ts' + +const bodySchema = z.object({ + orgId: z.string(), +}) + +/** + * Retrieves allowed email domains and SSO status for the specified organization. + * + * Validates the request body, enforces read access for the provided API key, queries the org record, and returns the organization's allowed email domains and SSO enabled flag. + * + * @param c - Hono context object + * @param bodyRaw - Request body expected to contain `{ orgId: string }` + * @param apikey - The API key row used to authorize and scope the query + * @returns A JSON object with `status: 'ok'`, `orgId`, `allowed_email_domains` (array), and `sso_enabled` (boolean) + * @throws `invalid_body` when the request body fails validation + * @throws `cannot_access_organization` when the API key does not have read rights for the organization + * @throws `cannot_get_org_domains` when the database query for the organization fails + */ +export async function getDomains(c: Context, bodyRaw: any, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { + const bodyParsed = bodySchema.safeParse(bodyRaw) + if (!bodyParsed.success) { + throw simpleError('invalid_body', 'Invalid body', { error: bodyParsed.error }) + } + const body = bodyParsed.data + + // Check if user has read rights for this org + if (!(await hasOrgRightApikey(c, body.orgId, apikey.user_id, 'read', apikey.key)) || !(apikeyHasOrgRight(apikey, body.orgId))) { + throw simpleError('cannot_access_organization', 'You can\'t access this organization', { orgId: body.orgId }) + } + + const { error, data } = await supabaseApikey(c, apikey.key) + .from('orgs') + .select('allowed_email_domains, sso_enabled') + .eq('id', body.orgId) + .single() + + if (error) { + throw simpleError('cannot_get_org_domains', 'Cannot get organization allowed email domains', { error: error.message }) + } + + return c.json({ + status: 'ok', + orgId: body.orgId, + allowed_email_domains: data.allowed_email_domains || [], + sso_enabled: data.sso_enabled || false, + }, 200) +} diff --git a/supabase/functions/_backend/public/organization/domains/put.ts b/supabase/functions/_backend/public/organization/domains/put.ts new file mode 100644 index 0000000000..f63ebd9817 --- /dev/null +++ b/supabase/functions/_backend/public/organization/domains/put.ts @@ -0,0 +1,110 @@ +import type { Context } from 'hono' +import type { Database } from '../../../utils/supabase.types.ts' +import { z } from 'zod/mini' +import { simpleError } from '../../../utils/hono.ts' +import { cloudlog } from '../../../utils/logging.ts' +import { apikeyHasOrgRight, hasOrgRightApikey, supabaseApikey } from '../../../utils/supabase.ts' + +const bodySchema = z.object({ + orgId: z.string(), + domains: z.array(z.string().check(z.minLength(1))), +}) + +/** + * Update an organization's allowed email domains and optionally its SSO enabled flag. + * + * Normalizes and validates the provided domains, rejects public email providers, and requires admin rights for the target organization. + * + * @param c - Hono context object + * @param bodyRaw - Request body containing `orgId: string`, `domains: string[]`, and optional `enabled: boolean` to set `sso_enabled` + * @param apikey - The API key row used to authorize and scope the query + * @returns A JSON Response with `status`, `orgId`, `allowed_email_domains` (array of strings), and `sso_enabled` (boolean) + * @throws simpleError with code `invalid_body` when the request body fails validation + * @throws simpleError with code `cannot_access_organization` when the caller lacks admin rights for the organization + * @throws simpleError with code `invalid_domain` when any provided domain is syntactically invalid + * @throws simpleError with code `blocked_domain` when any provided domain is a blocked/public email provider + * @throws simpleError with code `domain_conflict` when a domain conflict prevents the update + * @throws simpleError with code `cannot_update_org_domains` for other update failures + */ +export async function putDomains(c: Context, bodyRaw: any, apikey: Database['public']['Tables']['apikeys']['Row']): Promise { + const bodyParsed = bodySchema.safeParse(bodyRaw) + if (!bodyParsed.success) { + throw simpleError('invalid_body', 'Invalid body', { error: bodyParsed.error }) + } + const body = bodyParsed.data + const enabled = typeof bodyRaw.enabled === 'boolean' ? bodyRaw.enabled : undefined + + // Check if user has admin rights for this org + if (!(await hasOrgRightApikey(c, body.orgId, apikey.user_id, 'admin', apikey.key)) || !(apikeyHasOrgRight(apikey, body.orgId))) { + throw simpleError('cannot_access_organization', 'You can\'t access this organization (requires admin rights)', { orgId: body.orgId }) + } + + // Validate and normalize domains + const normalizedDomains = body.domains.map((domain) => { + const trimmed = domain.trim().toLowerCase() + // Remove any @ symbols if present + const cleaned = trimmed.replace(/^@+/, '') + + // Basic domain validation (must have at least one dot) + if (!cleaned.includes('.') || cleaned.length < 3) { + throw simpleError('invalid_domain', `Invalid domain: ${domain}`, { domain }) + } + + return cleaned + }) + + // Check for blocked domains using the database function + const supabase = supabaseApikey(c, apikey.key) + for (const domain of normalizedDomains) { + const { data: isBlocked } = await supabase.rpc('is_blocked_email_domain', { domain }) + if (isBlocked) { + throw simpleError('blocked_domain', `Domain ${domain} is a public email provider and cannot be used for organization auto-join. Please use a custom domain owned by your organization.`, { domain }) + } + } + + cloudlog({ + requestId: c.get('requestId'), + context: 'Updating allowed_email_domains', + orgId: body.orgId, + domains: normalizedDomains, + enabled, + }) + + const updateData: any = { + allowed_email_domains: normalizedDomains, + } + + // Only update sso_enabled if it's explicitly provided + if (enabled !== undefined) { + updateData.sso_enabled = enabled + } + + const { error: errorOrg, data: dataOrg } = await supabase + .from('orgs') + .update(updateData) + .eq('id', body.orgId) + .select() + + if (errorOrg) { + // Handle specific PostgreSQL errors + if (errorOrg.code === 'P0001' && errorOrg.message?.includes('public email provider')) { + throw simpleError('blocked_domain', errorOrg.message, { error: errorOrg.message }) + } + if (errorOrg.code === '23505' || (errorOrg.message?.includes('already claimed') && errorOrg.message?.includes('SSO enabled'))) { + throw simpleError('domain_conflict', errorOrg.message, { error: errorOrg.message }) + } + throw simpleError('cannot_update_org_domains', 'Cannot update organization allowed email domains', { error: errorOrg.message }) + } + + // Verify the update affected a row + if (!dataOrg || dataOrg.length === 0) { + return c.json({ status: 'Organization not found', orgId: body.orgId }, 404) + } + + return c.json({ + status: 'Organization allowed email domains updated', + orgId: body.orgId, + allowed_email_domains: dataOrg[0]?.allowed_email_domains || [], + sso_enabled: dataOrg[0]?.sso_enabled || false, + }, 200) +} diff --git a/supabase/functions/_backend/public/organization/index.ts b/supabase/functions/_backend/public/organization/index.ts index 6cf3a75c28..9b2a38bc7a 100644 --- a/supabase/functions/_backend/public/organization/index.ts +++ b/supabase/functions/_backend/public/organization/index.ts @@ -3,6 +3,8 @@ import { getBodyOrQuery, honoFactory } from '../../utils/hono.ts' import { middlewareKey } from '../../utils/hono_middleware.ts' import { getAuditLogs } from './audit.ts' import { deleteOrg } from './delete.ts' +import { getDomains } from './domains/get.ts' +import { putDomains } from './domains/put.ts' import { get } from './get.ts' import { deleteMember } from './members/delete.ts' import { get as getMembers } from './members/get.ts' @@ -59,3 +61,15 @@ app.get('/audit', middlewareKey(['all', 'write', 'read', 'upload']), async (c) = const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] return getAuditLogs(c, body, apikey) }) + +app.get('/domains', middlewareKey(['all', 'write', 'read', 'upload']), async (c) => { + const body = await getBodyOrQuery(c) + const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] + return getDomains(c, body, apikey) +}) + +app.put('/domains', middlewareKey(['all', 'write', 'read', 'upload']), async (c) => { + const body = await getBodyOrQuery(c) + const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] + return putDomains(c, body, apikey) +}) diff --git a/supabase/functions/_backend/public/sso_check.ts b/supabase/functions/_backend/public/sso_check.ts new file mode 100644 index 0000000000..db84a2ff21 --- /dev/null +++ b/supabase/functions/_backend/public/sso_check.ts @@ -0,0 +1,84 @@ +import type { Context } from 'hono' +import { honoFactory } from '../utils/hono.ts' +import { cloudlogErr, serializeError } from '../utils/logging.ts' +import { getPgClient } from '../utils/pg.ts' + +export const app = honoFactory.createApp() + +/** + * Public endpoint to check if SSO is available for an email domain + * No authentication required - accessible to unauthenticated users on login page + */ +app.post('/', async (c: Context) => { + try { + const body = await c.req.json() + const { email } = body + + if (!email || typeof email !== 'string' || !email.includes('@')) { + return c.json({ + error: 'invalid_email', + message: 'Valid email address required', + }, 400) + } + + // Extract domain from email + const domain = email.split('@')[1]?.toLowerCase() + if (!domain) { + return c.json({ + error: 'invalid_email', + message: 'Could not extract domain from email', + }, 400) + } + + // Skip public email providers + const publicDomains = ['gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com'] + if (publicDomains.includes(domain)) { + return c.json({ + available: false, + provider_id: null, + }) + } + + // Query database for SSO configuration + const pgClient = getPgClient(c) + + const result = await pgClient.query(` + SELECT + osc.provider_id, + osc.entity_id, + osc.org_id, + o.name as org_name + FROM saml_domain_mappings sdm + INNER JOIN org_saml_connections osc ON osc.org_id = sdm.org_id + INNER JOIN orgs o ON o.id = osc.org_id + WHERE + sdm.domain = $1 + AND osc.enabled = true + AND osc.deleted_at IS NULL + LIMIT 1 + `, [domain]) + + if (result.rows.length > 0) { + const row = result.rows[0] + return c.json({ + available: true, + provider_id: row.provider_id, + entity_id: row.entity_id, + org_id: row.org_id, + org_name: row.org_name, + }) + } + + return c.json({ + available: false, + provider_id: null, + }) + } + catch (error) { + cloudlogErr({ message: 'Error checking SSO availability:', error: serializeError(error) }) + return c.json({ + error: 'internal_error', + message: 'Failed to check SSO availability', + }, 500) + } +}) diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index 0f400d8afe..b679d64741 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -100,6 +100,8 @@ export const orgs = pgTable('orgs', { customer_id: text('customer_id'), require_apikey_expiration: boolean('require_apikey_expiration').notNull().default(false), max_apikey_expiration_days: integer('max_apikey_expiration_days'), + allowed_email_domains: text('allowed_email_domains').array().default([]), + sso_enabled: boolean('sso_enabled').default(false), }) export const stripe_info = pgTable('stripe_info', { diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index bfa4dda66d..77993db54d 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -1336,6 +1336,7 @@ export type Database = { } orgs: { Row: { + allowed_email_domains: string[] | null created_at: string | null created_by: string customer_id: string | null @@ -1350,10 +1351,13 @@ export type Database = { name: string password_policy_config: Json | null require_apikey_expiration: boolean + sso_domain_keys: string[] | null + sso_enabled: boolean | null stats_updated_at: string | null updated_at: string | null } Insert: { + allowed_email_domains?: string[] | null created_at?: string | null created_by: string customer_id?: string | null @@ -1368,10 +1372,13 @@ export type Database = { name: string password_policy_config?: Json | null require_apikey_expiration?: boolean + sso_domain_keys?: string[] | null + sso_enabled?: boolean | null stats_updated_at?: string | null updated_at?: string | null } Update: { + allowed_email_domains?: string[] | null created_at?: string | null created_by?: string customer_id?: string | null @@ -1386,6 +1393,8 @@ export type Database = { name?: string password_policy_config?: Json | null require_apikey_expiration?: boolean + sso_domain_keys?: string[] | null + sso_enabled?: boolean | null stats_updated_at?: string | null updated_at?: string | null } @@ -2189,6 +2198,10 @@ export type Database = { overage_unpaid: number }[] } + auto_join_user_to_orgs_by_email: { + Args: { p_email: string; p_user_id: string } + Returns: undefined + } calculate_credit_cost: { Args: { p_metric: Database["public"]["Enums"]["credit_metric_type"] @@ -2290,6 +2303,7 @@ export type Database = { Returns: boolean } expire_usage_credits: { Args: never; Returns: number } + extract_email_domain: { Args: { email: string }; Returns: string } find_apikey_by_value: { Args: { key_value: string } Returns: { @@ -2332,6 +2346,13 @@ export type Database = { name: string }[] } + find_orgs_by_email_domain: { + Args: { user_email: string } + Returns: { + org_id: string + org_name: string + }[] + } get_account_removal_date: { Args: { user_id: string }; Returns: string } get_apikey: { Args: never; Returns: string } get_apikey_header: { Args: never; Returns: string } @@ -2584,7 +2605,6 @@ export type Database = { password_has_access: boolean password_policy_config: Json paying: boolean - require_apikey_expiration: boolean role: string stats_updated_at: string subscription_end: string @@ -2615,7 +2635,6 @@ export type Database = { password_has_access: boolean password_policy_config: Json paying: boolean - require_apikey_expiration: boolean role: string stats_updated_at: string subscription_end: string @@ -2817,6 +2836,7 @@ export type Database = { Args: { org_id: string } Returns: boolean } + is_blocked_email_domain: { Args: { domain: string }; Returns: boolean } is_build_time_exceeded_by_org: { Args: { org_id: string } Returns: boolean diff --git a/supabase/functions/private/index.ts b/supabase/functions/private/index.ts index 552d50dffd..ff7815c146 100644 --- a/supabase/functions/private/index.ts +++ b/supabase/functions/private/index.ts @@ -1,5 +1,6 @@ import { app as accept_invitation } from '../_backend/private/accept_invitation.ts' import { app as admin_stats } from '../_backend/private/admin_stats.ts' +import { app as check_auto_join_orgs } from '../_backend/private/check_auto_join_orgs.ts' import { app as config } from '../_backend/private/config.ts' import { app as create_device } from '../_backend/private/create_device.ts' import { app as credits } from '../_backend/private/credits.ts' @@ -10,6 +11,8 @@ import { app as events } from '../_backend/private/events.ts' import { app as invite_new_user_to_org } from '../_backend/private/invite_new_user_to_org.ts' import { app as latency } from '../_backend/private/latency.ts' import { app as log_as } from '../_backend/private/log_as.ts' +import { app as organization_domains_get } from '../_backend/private/organization_domains_get.ts' +import { app as organization_domains_put } from '../_backend/private/organization_domains_put.ts' // Webapps API import { app as plans } from '../_backend/private/plans.ts' import { app as publicStats } from '../_backend/private/public_stats.ts' @@ -48,6 +51,9 @@ 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('/check_auto_join_orgs', check_auto_join_orgs) +appGlobal.route('/organization_domains_get', organization_domains_get) +appGlobal.route('/organization_domains_put', organization_domains_put) appGlobal.route('/validate_password_compliance', validate_password_compliance) createAllCatch(appGlobal, functionName) diff --git a/supabase/functions/sso_check/index.ts b/supabase/functions/sso_check/index.ts new file mode 100644 index 0000000000..797458b027 --- /dev/null +++ b/supabase/functions/sso_check/index.ts @@ -0,0 +1,106 @@ +// @ts-expect-error - Legacy Deno import +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' + +Deno.serve(async (req) => { + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'method_not_allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }) + } + + try { + const body = await req.json() as { email?: string } + const { email } = body + + if (!email || typeof email !== 'string' || !email.includes('@')) { + return new Response( + JSON.stringify({ + error: 'invalid_email', + message: 'Valid email address required', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Extract domain from email + const domain = email.split('@')[1]?.toLowerCase() + if (!domain) { + return new Response( + JSON.stringify({ + error: 'invalid_email', + message: 'Could not extract domain from email', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Skip public email providers + const publicDomains = ['gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com'] + if (publicDomains.includes(domain)) { + return new Response( + JSON.stringify({ + available: false, + provider_id: null, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Query database for SSO configuration + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '', + ) + + const { data, error } = await supabaseClient + .from('saml_domain_mappings') + .select(` + org_id, + org_saml_connections!inner( + id, + entity_id, + enabled, + orgs!inner( + id, + name + ) + ) + `) + .eq('domain', domain) + .eq('org_saml_connections.enabled', true) + .limit(1) + .single() + + if (error || !data) { + return new Response( + JSON.stringify({ + available: false, + provider_id: null, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + } + + const connection = data.org_saml_connections + return new Response( + JSON.stringify({ + available: true, + provider_id: connection.id, + entity_id: connection.entity_id, + org_id: data.org_id, + org_name: connection.orgs.name, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + } + catch { + return new Response( + JSON.stringify({ + error: 'internal_error', + message: 'Failed to check SSO availability', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ) + } +}) diff --git a/supabase/migrations/20251222054835_add_org_email_domain_auto_join.sql b/supabase/migrations/20251222054835_add_org_email_domain_auto_join.sql new file mode 100644 index 0000000000..1e5e7918c9 --- /dev/null +++ b/supabase/migrations/20251222054835_add_org_email_domain_auto_join.sql @@ -0,0 +1,176 @@ +/* + * Organization Email Domain Auto-Join Feature + * + * PURPOSE: + * Allows organizations to automatically enroll new members when they sign up or log in + * with an email address from a pre-configured domain (e.g., @company.com). + * + * COMPONENTS CREATED: + * 1. Column: orgs.allowed_email_domains - Stores array of allowed domains per org + * 2. Function: extract_email_domain() - Extracts domain from email address + * 3. Function: find_orgs_by_email_domain() - Finds orgs matching a user's email domain + * 4. Function: auto_join_user_to_orgs_by_email() - Adds user to matching orgs + * 5. Trigger: auto_join_user_to_orgs_on_create - Executes on new user signup + * 6. Index: idx_orgs_allowed_email_domains - GIN index for efficient domain lookups + * 7. Constraint: org_users_user_org_unique - Prevents duplicate memberships + * + * WORKFLOW: + * 1. Admin configures allowed domain(s) for their organization + * 2. New user signs up with matching email domain + * 3. Database trigger automatically adds user to matching orgs with 'read' permission + * 4. For existing users, login hook calls auto_join function + * + * SECURITY: + * - Public email domains blocked via CHECK constraint (added in subsequent migration) + * - SSO domain uniqueness enforced (added in subsequent migration) + * - Users added with lowest permission level (read-only) + * - Admin/super_admin required to configure domains + * + * PERFORMANCE: + * - GIN index on allowed_email_domains for fast domain matching + * - Composite index on org_users for permission checks (added in subsequent migration) + * + * Migration created: 2024-12-22 + */ + +-- Add allowed_email_domains column to orgs table for domain-based auto-join +ALTER TABLE "public"."orgs" +ADD COLUMN IF NOT EXISTS "allowed_email_domains" text[] DEFAULT '{}'; + +COMMENT ON COLUMN "public"."orgs"."allowed_email_domains" IS 'List of email domains (e.g., example.com) that are allowed to auto-join this organization'; + +-- Create function to extract domain from email +CREATE OR REPLACE FUNCTION "public"."extract_email_domain"("email" text) +RETURNS text +LANGUAGE "plpgsql" +SET search_path = '' +AS $$ +BEGIN + -- Extract domain from email (everything after @) + RETURN LOWER(TRIM(SPLIT_PART(email, '@', 2))); +END; +$$; + +ALTER FUNCTION "public"."extract_email_domain"("email" text) OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."extract_email_domain"("email" text) IS 'Extracts the domain portion from an email address (everything after @)'; + +-- Create function to find orgs that allow a specific email domain +CREATE OR REPLACE FUNCTION "public"."find_orgs_by_email_domain"("user_email" text) +RETURNS TABLE ( + "org_id" uuid, + "org_name" text +) +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + email_domain text; +BEGIN + -- Extract domain from user email + email_domain := public.extract_email_domain(user_email); + + -- Return orgs that have this domain in their allowed list + RETURN QUERY + SELECT + orgs.id AS org_id, + orgs.name AS org_name + FROM public.orgs + WHERE email_domain = ANY(orgs.allowed_email_domains) + AND email_domain != ''; -- Ensure we have a valid domain +END; +$$; + +ALTER FUNCTION "public"."find_orgs_by_email_domain"("user_email" text) OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."find_orgs_by_email_domain"("user_email" text) IS 'Finds all organizations that allow auto-join for the domain of the given email address'; + +-- Create function to auto-add user to orgs based on email domain +CREATE OR REPLACE FUNCTION "public"."auto_join_user_to_orgs_by_email"("p_user_id" uuid, "p_email" text) +RETURNS integer +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + matching_org RECORD; + added_count integer := 0; +BEGIN + -- Loop through all matching orgs + FOR matching_org IN + SELECT org_id, org_name FROM public.find_orgs_by_email_domain(p_email) + LOOP + -- Check if user is not already a member + IF NOT EXISTS ( + SELECT 1 FROM public.org_users + WHERE user_id = p_user_id + AND org_id = matching_org.org_id + ) THEN + -- Add user to org with 'read' permission + INSERT INTO public.org_users (user_id, org_id, user_right) + VALUES (p_user_id, matching_org.org_id, 'read'::"public"."user_min_right") + ON CONFLICT DO NOTHING; + + added_count := added_count + 1; + END IF; + END LOOP; + + RETURN added_count; +END; +$$; + +ALTER FUNCTION "public"."auto_join_user_to_orgs_by_email"("p_user_id" uuid, "p_email" text) OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."auto_join_user_to_orgs_by_email"("p_user_id" uuid, "p_email" text) IS 'Automatically adds a user to all organizations that allow their email domain. Returns the number of organizations joined.'; + +-- Create trigger function to auto-join user on creation +CREATE OR REPLACE FUNCTION "public"."trigger_auto_join_user_on_create"() +RETURNS trigger +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Auto-join user to orgs based on email domain + PERFORM public.auto_join_user_to_orgs_by_email(NEW.id, NEW.email); + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."trigger_auto_join_user_on_create"() OWNER TO "postgres"; + +-- Create trigger on users table to auto-join on signup +-- This trigger should run AFTER generate_org_on_user_create to ensure user has their personal org first +CREATE OR REPLACE TRIGGER "auto_join_user_to_orgs_on_create" +AFTER INSERT ON "public"."users" +FOR EACH ROW +EXECUTE FUNCTION "public"."trigger_auto_join_user_on_create"(); + +-- Ensure this trigger runs after the org creation trigger +-- PostgreSQL triggers execute in alphabetical order by default +-- "auto_join_user_to_orgs_on_create" comes after "generate_org_on_user_create" alphabetically + +COMMENT ON TRIGGER "auto_join_user_to_orgs_on_create" ON "public"."users" IS 'Automatically adds new users to organizations that allow their email domain'; + +-- Create index for efficient domain lookups +CREATE INDEX IF NOT EXISTS "idx_orgs_allowed_email_domains" +ON "public"."orgs" USING GIN ("allowed_email_domains"); + +COMMENT ON INDEX "public"."idx_orgs_allowed_email_domains" IS 'GIN index for efficient lookups of organizations by allowed email domains'; + +-- Add unique constraint to org_users to prevent duplicate memberships +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'org_users_user_org_unique' + ) THEN + ALTER TABLE "public"."org_users" + ADD CONSTRAINT "org_users_user_org_unique" UNIQUE ("user_id", "org_id"); + END IF; +END $$; + +COMMENT ON CONSTRAINT "org_users_user_org_unique" ON "public"."org_users" IS 'Ensures a user cannot be added to the same organization multiple times'; + + diff --git a/supabase/migrations/20251222073507_add_domain_security_constraints.sql b/supabase/migrations/20251222073507_add_domain_security_constraints.sql new file mode 100644 index 0000000000..dd31447276 --- /dev/null +++ b/supabase/migrations/20251222073507_add_domain_security_constraints.sql @@ -0,0 +1,185 @@ +/* + * Organization Email Domain Auto-Join - Security Constraints + * + * PURPOSE: + * Adds security constraints to prevent abuse of the auto-join feature by blocking + * public email domains and enforcing SSO domain uniqueness. + * + * CONSTRAINTS ADDED: + * 1. blocked_domain - CHECK constraint blocking common public email providers + * - Blocks: gmail.com, yahoo.com, outlook.com, hotmail.com, etc. + * - Prevents organizations from using free public email domains + * - Ensures only corporate/custom domains can be used + * + * 2. unique_sso_domain - Unique partial index on allowed_email_domains + * - When sso_enabled = true, domain must be unique across all organizations + * - When sso_enabled = false, same domain can be shared by multiple orgs + * - Prevents SSO domain conflicts between organizations + * + * RATIONALE: + * - Public email domains (gmail, yahoo, etc.) could allow anyone to join + * - SSO domains need uniqueness to prevent authentication conflicts + * - Non-SSO domains can be shared for flexible organizational structures + * + * TRIGGERS: + * Includes triggers to automatically manage SSO domain uniqueness when + * allowed_email_domains or sso_enabled fields are modified. + * + * Related migration: 20251222054835_add_org_email_domain_auto_join.sql + * Migration created: 2024-12-22 + */ + +-- Add SSO enabled column to orgs table +ALTER TABLE "public"."orgs" +ADD COLUMN IF NOT EXISTS "sso_enabled" boolean DEFAULT FALSE; + +COMMENT ON COLUMN "public"."orgs"."sso_enabled" IS 'When true, this organization uses SSO and has exclusive rights to its allowed email domains'; + +-- Create function to check if domain is in blocklist +CREATE OR REPLACE FUNCTION "public"."is_blocked_email_domain"("domain" text) +RETURNS boolean +LANGUAGE "plpgsql" +IMMUTABLE +SET search_path = '' +AS $$ +DECLARE + blocked_domains text[] := ARRAY[ + -- Common free email providers + 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', 'yahoo.fr', 'yahoo.de', + 'outlook.com', 'outlook.fr', 'outlook.de', 'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', + 'live.com', 'live.fr', 'live.co.uk', 'icloud.com', 'me.com', 'mac.com', + 'protonmail.com', 'proton.me', 'aol.com', 'mail.com', 'gmx.com', 'gmx.de', + 'yandex.com', 'yandex.ru', 'mail.ru', 'qq.com', '163.com', '126.com', + 'zoho.com', 'fastmail.com', 'tutanota.com', 'tutanota.de', + -- Temporary/disposable email services + 'tempmail.com', 'temp-mail.org', 'guerrillamail.com', 'guerrillamail.net', + '10minutemail.com', '10minutemail.net', 'mailinator.com', 'throwaway.email', + 'trashmail.com', 'getnada.com', 'maildrop.cc', 'sharklasers.com', + 'yopmail.com', 'yopmail.fr', 'cool.fr.nf', 'jetable.fr.nf', + 'guerrillamail.biz', 'guerrillamail.de', 'spam4.me', 'grr.la', + 'guerrillamailblock.com', 'pokemail.net', 'anonymbox.com', + -- Generic educational domains + 'student.com', 'alumni.com', 'edu.com', + -- Other common free providers + 'inbox.com', 'email.com', 'usa.com', 'yeah.net', 'rediffmail.com' + ]; +BEGIN + -- Check if domain is in blocklist (case-insensitive) + RETURN LOWER(TRIM(domain)) = ANY(blocked_domains); +END; +$$; + +ALTER FUNCTION "public"."is_blocked_email_domain"("domain" text) OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."is_blocked_email_domain"("domain" text) IS 'Returns true if the domain is a public email provider or disposable email service that should not be allowed for organization auto-join'; + +-- Create function to validate allowed email domains +CREATE OR REPLACE FUNCTION "public"."validate_allowed_email_domains"() +RETURNS trigger +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + domain text; + conflicting_org_id uuid; + conflicting_org_name text; +BEGIN + -- Check each domain in the array + IF NEW.allowed_email_domains IS NOT NULL THEN + FOREACH domain IN ARRAY NEW.allowed_email_domains + LOOP + -- Check if domain is blocked + IF public.is_blocked_email_domain(domain) THEN + RAISE EXCEPTION 'Domain % is a public email provider and cannot be used for organization auto-join', domain + USING ERRCODE = 'check_violation', + HINT = 'Please use a custom domain owned by your organization'; + END IF; + + -- If SSO is enabled, check for domain conflicts with other SSO-enabled orgs + IF NEW.sso_enabled = TRUE THEN + SELECT o.id, o.name INTO conflicting_org_id, conflicting_org_name + FROM public.orgs o + WHERE o.id != NEW.id + AND o.sso_enabled = TRUE + AND domain = ANY(o.allowed_email_domains) + LIMIT 1; + + IF conflicting_org_id IS NOT NULL THEN + RAISE EXCEPTION 'Domain % is already claimed by organization "%" (SSO enabled). Each domain can only be used by one SSO-enabled organization.', + domain, conflicting_org_name + USING ERRCODE = 'unique_violation', + HINT = 'Contact support if you believe this domain should belong to your organization'; + END IF; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."validate_allowed_email_domains"() OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."validate_allowed_email_domains"() IS 'Validates that allowed email domains are not public providers and enforces SSO domain uniqueness'; + +-- Create trigger to validate domains on insert/update +DROP TRIGGER IF EXISTS "validate_org_email_domains" ON "public"."orgs"; +CREATE TRIGGER "validate_org_email_domains" +BEFORE INSERT OR UPDATE OF allowed_email_domains, sso_enabled ON "public"."orgs" +FOR EACH ROW +EXECUTE FUNCTION "public"."validate_allowed_email_domains"(); + +COMMENT ON TRIGGER "validate_org_email_domains" ON "public"."orgs" IS 'Validates allowed email domains against blocklist and SSO uniqueness constraints'; + +-- Create a partial unique index for SSO-enabled orgs with domains +-- This provides an additional layer of enforcement at the database level +-- We'll use a trigger-based approach instead of generated columns + +-- Add column to store flattened SSO domain keys (maintained by trigger) +ALTER TABLE "public"."orgs" +ADD COLUMN IF NOT EXISTS "sso_domain_keys" text[]; + +COMMENT ON COLUMN "public"."orgs"."sso_domain_keys" IS 'Array containing unique keys for each SSO-enabled domain, used for enforcing uniqueness. Maintained automatically by trigger.'; + +-- Create function to update SSO domain keys +CREATE OR REPLACE FUNCTION "public"."update_sso_domain_keys"() +RETURNS trigger +LANGUAGE "plpgsql" +SET search_path = '' +AS $$ +BEGIN + -- Update sso_domain_keys based on sso_enabled and allowed_email_domains + IF NEW.sso_enabled = TRUE AND NEW.allowed_email_domains IS NOT NULL AND array_length(NEW.allowed_email_domains, 1) > 0 THEN + -- Create unique keys for each domain + NEW.sso_domain_keys := ( + SELECT array_agg('sso:' || lower(trim(domain))) + FROM unnest(NEW.allowed_email_domains) AS domain + ); + ELSE + NEW.sso_domain_keys := NULL; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."update_sso_domain_keys"() OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."update_sso_domain_keys"() IS 'Updates the sso_domain_keys column when sso_enabled or allowed_email_domains change'; + +-- Create trigger to maintain sso_domain_keys +DROP TRIGGER IF EXISTS "maintain_sso_domain_keys" ON "public"."orgs"; +CREATE TRIGGER "maintain_sso_domain_keys" +BEFORE INSERT OR UPDATE OF sso_enabled, allowed_email_domains ON "public"."orgs" +FOR EACH ROW +EXECUTE FUNCTION "public"."update_sso_domain_keys"(); + +COMMENT ON TRIGGER "maintain_sso_domain_keys" ON "public"."orgs" IS 'Automatically maintains the sso_domain_keys column'; + +-- Create GIN index on sso_domain_keys for efficient conflict detection +CREATE INDEX IF NOT EXISTS "idx_orgs_sso_domain_keys" +ON "public"."orgs" USING GIN ("sso_domain_keys") +WHERE "sso_enabled" = TRUE AND "sso_domain_keys" IS NOT NULL; + +COMMENT ON INDEX "public"."idx_orgs_sso_domain_keys" IS 'GIN index for efficient SSO domain conflict detection'; diff --git a/supabase/migrations/20251222091718_update_auto_join_check_enabled.sql b/supabase/migrations/20251222091718_update_auto_join_check_enabled.sql new file mode 100644 index 0000000000..c8678454ec --- /dev/null +++ b/supabase/migrations/20251222091718_update_auto_join_check_enabled.sql @@ -0,0 +1,65 @@ +/* + * Organization Email Domain Auto-Join - Enable/Disable Flag + * + * PURPOSE: + * Updates the auto-join logic to respect the sso_enabled flag, allowing organizations + * to toggle auto-join functionality on/off without removing configured domains. + * + * CHANGES MADE: + * - Updates find_orgs_by_email_domain() to only return orgs where sso_enabled = true + * - This ensures auto-join only happens for organizations that have explicitly enabled it + * + * USE CASES: + * 1. Organization wants to temporarily pause auto-join enrollment + * 2. Testing domain configuration before enabling + * 3. Maintaining domain config while restricting new auto-joins + * 4. Compliance/security requirement to disable feature temporarily + * + * BEHAVIOR: + * - When sso_enabled=false: Existing members remain, no new auto-joins + * - When sso_enabled=true: New signups/logins with matching domain are auto-joined + * - Database function checks this flag before returning matching organizations + * + * INTEGRATION: + * - Used by auto_join_user_to_orgs_by_email() function during signup/login + * - Enforced in unique_sso_domain constraint (only enabled orgs checked) + * - Displayed in frontend auto-join configuration UI + * + * Related migrations: + * - 20251222054835_add_org_email_domain_auto_join.sql (base feature) + * - 20251222073507_add_domain_security_constraints.sql (adds sso_enabled column) + * + * Migration created: 2024-12-22 + */ + +-- Update find_orgs_by_email_domain to only return orgs with sso_enabled = true +-- We need to drop and recreate to modify the function body and fix the return type +DROP FUNCTION IF EXISTS "public"."find_orgs_by_email_domain"(text); + +CREATE OR REPLACE FUNCTION "public"."find_orgs_by_email_domain"("user_email" text) +RETURNS TABLE("org_id" uuid, "org_name" text) +LANGUAGE "plpgsql" +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + email_domain text; +BEGIN + -- Extract domain from email (everything after @) + email_domain := lower(split_part(user_email, '@', 2)); + + -- Return all orgs that have this domain in allowed_email_domains AND sso_enabled = true + RETURN QUERY + SELECT + orgs.id AS org_id, + orgs.name AS org_name + FROM public.orgs + WHERE email_domain = ANY(orgs.allowed_email_domains) + AND email_domain != '' -- Ensure we have a valid domain + AND orgs.sso_enabled = TRUE; -- Only include orgs with auto-join enabled +END; +$$; + +ALTER FUNCTION "public"."find_orgs_by_email_domain"("user_email" text) OWNER TO "postgres"; + +COMMENT ON FUNCTION "public"."find_orgs_by_email_domain"("user_email" text) IS 'Finds all organizations that allow auto-join for the domain of the given email address and have auto-join enabled (sso_enabled = true)'; diff --git a/supabase/migrations/20251222120534_optimize_org_users_permissions_query.sql b/supabase/migrations/20251222120534_optimize_org_users_permissions_query.sql new file mode 100644 index 0000000000..5dcee075b5 --- /dev/null +++ b/supabase/migrations/20251222120534_optimize_org_users_permissions_query.sql @@ -0,0 +1,78 @@ +/* + * Organization Email Domain Auto-Join - Permission Query Optimization + * + * PURPOSE: + * Optimizes database performance for organization permission checks used throughout + * the auto-join feature and other organization-related API endpoints. + * + * PROBLEM ADDRESSED: + * The auto-join feature's GET/PUT endpoints query org_users table to verify permissions. + * + * Previous state: + * - Separate single-column indexes on org_id and user_id + * - Postgres had to use one index then scan for other column + * - Required table heap lookup to get user_right, app_id, channel_id + * - Slower query execution, higher I/O + * + * OPTIMIZATION IMPLEMENTED: + * Creates composite covering index: idx_org_users_org_user_covering + * - Composite index on (org_id, user_id) for efficient two-column filtering + * - INCLUDE clause adds (user_right, app_id, channel_id) to index + * - Enables index-only scans (no table heap lookup needed) + * - Significantly faster permission checks + * + * PERFORMANCE BENEFITS: + * - Faster lookups: Composite index optimizes two-column WHERE clause + * - Reduced I/O: Covering index eliminates table heap lookups + * - Lower CPU usage: Simpler execution plan + * - Better scalability: Performance improves with table size + * + * INDEX STRUCTURE: + * CREATE INDEX idx_org_users_org_user_covering + * ON org_users (org_id, user_id) + * INCLUDE (user_right, app_id, channel_id); + * + * Column order rationale: + * - org_id first (higher cardinality - many organizations) + * - user_id second (more selective within an org) + * - Allows efficient range scans if needed in future + * + * USED BY: + * - /private/organization_domains_get - Read domain configuration + * - /private/organization_domains_put - Update domain configuration + * - Other organization permission checks throughout the application + * + * Related migration: 20251222054835_add_org_email_domain_auto_join.sql + * Migration created: 2024-12-22 + */ + +-- Optimize org_users permission queries +-- This composite covering index significantly improves performance of permission check queries +-- that filter by org_id and user_id, which is the primary access pattern for authorization checks + +-- Create a composite index on (org_id, user_id) with covering columns +-- INCLUDE clause adds user_right, app_id, channel_id to the index so queries can be satisfied +-- entirely from the index without hitting the table (index-only scan) +CREATE INDEX IF NOT EXISTS idx_org_users_org_user_covering +ON org_users (org_id, user_id) +INCLUDE (user_right, app_id, channel_id); + +-- Analyze the table to update query planner statistics +ANALYZE org_users; + +-- Performance rationale: +-- 1. Composite index (org_id, user_id) optimizes the WHERE clause perfectly +-- Postgres can use both columns for filtering efficiently +-- Much faster than using two separate single-column indexes (no index intersection needed) +-- +-- 2. INCLUDE clause creates a "covering index" +-- Index contains all columns needed by the query (org_id, user_id, user_right, app_id, channel_id) +-- Eliminates table heap lookups entirely (index-only scan) +-- Reduces I/O significantly for frequent permission checks +-- +-- 3. Column order (org_id, user_id) is optimal because: +-- org_id is the higher-cardinality column (many orgs) +-- user_id is the more selective filter within an org +-- Allows efficient range scans if needed in the future +-- +-- This is used by all permission checks in private API endpoints diff --git a/supabase/migrations/20251231000001_add_domain_based_auto_join.sql b/supabase/migrations/20251231000001_add_domain_based_auto_join.sql new file mode 100644 index 0000000000..b9ffecd782 --- /dev/null +++ b/supabase/migrations/20251231000001_add_domain_based_auto_join.sql @@ -0,0 +1,128 @@ +-- ============================================================================ +-- Migration: Domain-Based Auto-Join Feature +-- Description: Enables automatic organization enrollment based on email domains +-- ============================================================================ +-- This feature allows organizations to configure trusted email domains +-- (e.g., @company.com) that automatically add new users to the organization +-- when they sign up or log in, eliminating the need for manual invitations. +-- ============================================================================ + +-- Add columns to orgs table if they don't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'orgs' AND column_name = 'allowed_email_domains') THEN + ALTER TABLE public.orgs ADD COLUMN allowed_email_domains text[] DEFAULT '{}'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'orgs' AND column_name = 'sso_enabled') THEN + ALTER TABLE public.orgs ADD COLUMN sso_enabled boolean DEFAULT false; + END IF; +END $$; + +COMMENT ON COLUMN public.orgs.allowed_email_domains IS 'Email domains allowed for auto-join (e.g., ["company.com", "subsidiary.com"])'; +COMMENT ON COLUMN public.orgs.sso_enabled IS 'Whether domain-based auto-join is enabled for this organization'; + +-- ============================================================================ +-- FUNCTION: Domain-Based Auto-Join +-- ============================================================================ +-- Automatically enrolls users to organizations based on email domain matching +-- This is the NON-SSO version that uses orgs.allowed_email_domains +-- ============================================================================ + +-- Drop existing function if return type changed +DROP FUNCTION IF EXISTS public.auto_join_user_to_orgs_by_email(uuid, text); + +CREATE OR REPLACE FUNCTION public.auto_join_user_to_orgs_by_email( + p_user_id uuid, + p_email text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_domain text; + v_org record; +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; + + -- Find organizations with matching domain and auto-join enabled + FOR v_org IN + SELECT DISTINCT o.id, o.name + FROM public.orgs o + WHERE o.sso_enabled = true + AND v_domain = ANY(o.allowed_email_domains) + 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 + INSERT INTO public.org_users (user_id, org_id, user_right, created_at) + VALUES (p_user_id, v_org.id, 'read', now()) + ON CONFLICT (user_id, org_id) DO NOTHING; + + RAISE NOTICE 'Auto-joined user % to org % via domain %', p_user_id, v_org.name, v_domain; + END LOOP; +END; +$$; + +COMMENT ON FUNCTION public.auto_join_user_to_orgs_by_email IS 'Auto-enrolls users to organizations based on email domain matching (non-SSO)'; + +-- ============================================================================ +-- TRIGGER: Auto-Join on User Creation +-- ============================================================================ + +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; +BEGIN + -- Extract email from NEW user + v_email := NEW.email; + + IF v_email IS NULL OR v_email = '' THEN + RETURN NEW; + END IF; + + -- Perform auto-join based on email domain + BEGIN + PERFORM public.auto_join_user_to_orgs_by_email(NEW.id, v_email); + EXCEPTION WHEN OTHERS THEN + -- Log error but don't block user creation + RAISE WARNING 'Auto-join failed for user %: %', NEW.id, SQLERRM; + END; + + RETURN NEW; +END; +$$; + +-- Drop existing trigger if it exists +DROP TRIGGER IF EXISTS auto_join_user_to_orgs_on_create ON auth.users; + +-- Create trigger on 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(); + +COMMENT ON FUNCTION public.trigger_auto_join_on_user_create IS 'Triggers domain-based auto-join when new users sign up'; + +-- ============================================================================ +-- PERMISSIONS +-- ============================================================================ + +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; diff --git a/supabase/tests/29_test_delete_accounts_marked_for_deletion.sql b/supabase/tests/29_test_delete_accounts_marked_for_deletion.sql index 2da63a52b0..8d5707979c 100644 --- a/supabase/tests/29_test_delete_accounts_marked_for_deletion.sql +++ b/supabase/tests/29_test_delete_accounts_marked_for_deletion.sql @@ -431,7 +431,7 @@ VALUES 'last_admin@test.com' ); --- Add user as super_admin to the org +-- Add user as super_admin to the org (ON CONFLICT for creator who is auto-added by trigger) INSERT INTO public.org_users (org_id, user_id, user_right) VALUES @@ -439,7 +439,8 @@ VALUES '88888888-8888-8888-8888-888888888888'::UUID, '88888888-8888-8888-8888-888888888888'::UUID, 'super_admin'::public.USER_MIN_RIGHT -); +) +ON CONFLICT (org_id, user_id) DO UPDATE SET user_right = EXCLUDED.user_right; -- Create an app owned by this user INSERT INTO @@ -715,7 +716,7 @@ VALUES 'admin1@test.com' ); --- Add both users as super_admins +-- Add both users as super_admins (ON CONFLICT for creator who is auto-added by trigger) INSERT INTO public.org_users (org_id, user_id, user_right) VALUES @@ -728,7 +729,8 @@ VALUES '99999999-9999-9999-9999-999999999999'::UUID, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'::UUID, 'super_admin'::public.USER_MIN_RIGHT -); +) +ON CONFLICT (org_id, user_id) DO UPDATE SET user_right = EXCLUDED.user_right; -- Create resources owned by admin1 INSERT INTO @@ -1043,7 +1045,7 @@ VALUES 'audit_admin1@test.com' ); --- Add both users as super_admins +-- Add both users as super_admins (ON CONFLICT for creator who is auto-added by trigger) INSERT INTO public.org_users (org_id, user_id, user_right) VALUES @@ -1056,7 +1058,8 @@ VALUES 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'::UUID, 'cccccccc-cccc-cccc-cccc-cccccccccccc'::UUID, 'super_admin'::public.USER_MIN_RIGHT -); +) +ON CONFLICT (org_id, user_id) DO UPDATE SET user_right = EXCLUDED.user_right; -- Manually insert audit log entries for admin1 -- (Normally these would be created by triggers, but we insert directly for testing) diff --git a/supabase/tests/41_test_reject_access_due_to_2fa_for_app.sql b/supabase/tests/41_test_reject_access_due_to_2fa_for_app.sql index 7629ad9cf8..f41cf07f61 100644 --- a/supabase/tests/41_test_reject_access_due_to_2fa_for_app.sql +++ b/supabase/tests/41_test_reject_access_due_to_2fa_for_app.sql @@ -49,17 +49,19 @@ BEGIN INSERT INTO public.orgs (id, created_by, name, management_email, enforcing_2fa) VALUES (org_without_2fa_enforcement_id, test_2fa_user_id, 'No 2FA Org App', 'no2fa_app@org.com', false); - -- Add members to org WITH 2FA enforcement + -- Add members to org WITH 2FA enforcement (ON CONFLICT for creator who is auto-added by trigger) INSERT INTO public.org_users (org_id, user_id, user_right) VALUES (org_with_2fa_enforcement_id, test_2fa_user_id, 'admin'::public.user_min_right), - (org_with_2fa_enforcement_id, test_no_2fa_user_id, 'read'::public.user_min_right); + (org_with_2fa_enforcement_id, test_no_2fa_user_id, 'read'::public.user_min_right) + ON CONFLICT (org_id, user_id) DO UPDATE SET user_right = EXCLUDED.user_right; -- Add members to org WITHOUT 2FA enforcement INSERT INTO public.org_users (org_id, user_id, user_right) VALUES (org_without_2fa_enforcement_id, test_2fa_user_id, 'admin'::public.user_min_right), - (org_without_2fa_enforcement_id, test_no_2fa_user_id, 'read'::public.user_min_right); + (org_without_2fa_enforcement_id, test_no_2fa_user_id, 'read'::public.user_min_right) + ON CONFLICT (org_id, user_id) DO UPDATE SET user_right = EXCLUDED.user_right; -- Create app in org WITH 2FA enforcement INSERT INTO public.apps (app_id, owner_org, name, icon_url) diff --git a/supabase/tests/42_test_reject_access_due_to_2fa_for_org.sql b/supabase/tests/42_test_reject_access_due_to_2fa_for_org.sql index 36e052b065..00841d09ee 100644 --- a/supabase/tests/42_test_reject_access_due_to_2fa_for_org.sql +++ b/supabase/tests/42_test_reject_access_due_to_2fa_for_org.sql @@ -49,17 +49,19 @@ BEGIN INSERT INTO public.orgs (id, created_by, name, management_email, enforcing_2fa) VALUES (org_without_2fa_enforcement_id, test_2fa_user_id, 'No 2FA Org Direct', 'no2fa_org_direct@org.com', false); - -- Add members to org WITH 2FA enforcement + -- Add members to org WITH 2FA enforcement (ON CONFLICT for creator who is auto-added by trigger) INSERT INTO public.org_users (org_id, user_id, user_right) VALUES (org_with_2fa_enforcement_id, test_2fa_user_id, 'admin'::public.user_min_right), - (org_with_2fa_enforcement_id, test_no_2fa_user_id, 'read'::public.user_min_right); + (org_with_2fa_enforcement_id, test_no_2fa_user_id, 'read'::public.user_min_right) + ON CONFLICT (org_id, user_id) DO UPDATE SET user_right = EXCLUDED.user_right; -- Add members to org WITHOUT 2FA enforcement INSERT INTO public.org_users (org_id, user_id, user_right) VALUES (org_without_2fa_enforcement_id, test_2fa_user_id, 'admin'::public.user_min_right), - (org_without_2fa_enforcement_id, test_no_2fa_user_id, 'read'::public.user_min_right); + (org_without_2fa_enforcement_id, test_no_2fa_user_id, 'read'::public.user_min_right) + ON CONFLICT (org_id, user_id) DO UPDATE SET user_right = EXCLUDED.user_right; -- Store org IDs for later use PERFORM set_config('test.org_with_2fa_direct', org_with_2fa_enforcement_id::text, false); diff --git a/tests/apikeys-expiration.test.ts b/tests/apikeys-expiration.test.ts index 83e5cb700b..08f2caff71 100644 --- a/tests/apikeys-expiration.test.ts +++ b/tests/apikeys-expiration.test.ts @@ -346,10 +346,12 @@ describe('[PUT] /organization with API key policy', () => { throw orgError // Add user as super_admin to be able to update the org - const { error: memberError } = await getSupabaseClient().from('org_users').insert({ + const { error: memberError } = await getSupabaseClient().from('org_users').upsert({ org_id: updateOrgId, user_id: USER_ID, user_right: 'super_admin', + }, { + onConflict: 'org_id,user_id', }) if (memberError) throw memberError diff --git a/tests/domain-based-auto-join.test.ts b/tests/domain-based-auto-join.test.ts new file mode 100644 index 0000000000..5bec220fa6 --- /dev/null +++ b/tests/domain-based-auto-join.test.ts @@ -0,0 +1,702 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { BASE_URL, getSupabaseClient, headers, USER_ADMIN_EMAIL, USER_ID } from './test-utils.ts' + +const TEST_DOMAIN = 'autojointest.com' +const TEST_ORG_ID = randomUUID() +const TEST_ORG_NAME = `Auto-Join Test Org ${randomUUID()}` +const TEST_CUSTOMER_ID = `cus_autojoin_${randomUUID()}` + +// Utility function to create auth user with retry for 503 errors +async function createAuthUserWithRetry( + email: string, + metadata: { first_name: string; last_name: string }, + maxRetries = 5, +) { + let lastError: any = null + for (let i = 0; i < maxRetries; i++) { + const result = await getSupabaseClient().auth.admin.createUser({ + email, + email_confirm: true, + user_metadata: metadata, + }) + + // If successful, return the result + if (!result.error) + return result + + lastError = result.error + // If it's a 503, retry; otherwise return the error + if (result.error.status !== 503) + return result + + // Wait before retrying + if (i < maxRetries - 1) { + const delay = 100 * Math.pow(2, i) + await new Promise(resolve => setTimeout(resolve, delay)) + } + } + // Return last error result + return { data: null, error: lastError } +} + +beforeAll(async () => { + // Clean up any leftover test data first + await getSupabaseClient().from('org_users').delete().eq('org_id', TEST_ORG_ID) + await getSupabaseClient().from('orgs').delete().eq('id', TEST_ORG_ID) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', TEST_CUSTOMER_ID) + + // Create stripe_info for test org + const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ + customer_id: TEST_CUSTOMER_ID, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + // Create test org with allowed email domain + const { error } = await getSupabaseClient().from('orgs').insert({ + id: TEST_ORG_ID, + name: TEST_ORG_NAME, + management_email: USER_ADMIN_EMAIL, + created_by: USER_ID, + customer_id: TEST_CUSTOMER_ID, + allowed_email_domains: [TEST_DOMAIN], + }) + if (error) + throw error + + // Add test user as super_admin of the test org (ignore if already exists) + await getSupabaseClient().from('org_users').upsert({ + org_id: TEST_ORG_ID, + user_id: USER_ID, + user_right: 'super_admin', + }, { + onConflict: 'user_id,org_id', + }) +}) + +afterAll(async () => { + // Clean up test organization membership, stripe_info, and org + await getSupabaseClient().from('org_users').delete().eq('org_id', TEST_ORG_ID) + await getSupabaseClient().from('orgs').delete().eq('id', TEST_ORG_ID) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', TEST_CUSTOMER_ID) +}) + +describe('Organization Email Domain Auto-Join', () => { + describe('[GET] /organization/domains', () => { + it('should get allowed email domains for an org', async () => { + const response = await fetch(`${BASE_URL}/organization/domains?orgId=${TEST_ORG_ID}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as any + expect(data.status).toBe('ok') + expect(data.orgId).toBe(TEST_ORG_ID) + expect(data.allowed_email_domains).toEqual([TEST_DOMAIN]) + }) + + it('should return empty array for org with no allowed domains', async () => { + const emptyOrgId = randomUUID() + const emptyCustomerId = `cus_empty_${randomUUID()}` + + try { + // Create stripe_info + await getSupabaseClient().from('stripe_info').insert({ + customer_id: emptyCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + + // Create org without domains (unique name) + const { error: orgError } = await getSupabaseClient().from('orgs').insert({ + id: emptyOrgId, + name: `Empty Domains Org ${randomUUID()}`, + management_email: USER_ADMIN_EMAIL, + created_by: USER_ID, + customer_id: emptyCustomerId, + allowed_email_domains: [], + }) + if (orgError) + throw orgError + + // Add user as member (use upsert to avoid duplicate key errors) + await getSupabaseClient().from('org_users').upsert({ + org_id: emptyOrgId, + user_id: USER_ID, + user_right: 'admin', + app_id: null, + channel_id: null, + }, { + onConflict: 'user_id,org_id', + }) + + const response = await fetch(`${BASE_URL}/organization/domains?orgId=${emptyOrgId}`, { + headers, + }) + expect(response.status).toBe(200) + const data = await response.json() as any + expect(data.allowed_email_domains).toEqual([]) + } + finally { + // Cleanup + await getSupabaseClient().from('org_users').delete().eq('org_id', emptyOrgId) + await getSupabaseClient().from('orgs').delete().eq('id', emptyOrgId) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', emptyCustomerId) + } + }) + + it('should reject request without org membership', async () => { + // Create an org where the test user is NOT a member + const unauthorizedOrgId = randomUUID() + const unauthorizedCustomerId = `cus_unauthorized_${randomUUID()}` + + // Create stripe_info + await getSupabaseClient().from('stripe_info').insert({ + customer_id: unauthorizedCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + }) + + // Create org owned by a different user (USER_ID_2) + await getSupabaseClient().from('orgs').insert({ + id: unauthorizedOrgId, + name: `Unauthorized Org ${randomUUID()}`, + management_email: 'other@example.com', + created_by: '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', // USER_ID_2 - different from test user + customer_id: unauthorizedCustomerId, + allowed_email_domains: ['unauthorized.com'], + }) + + // Try to access org domains without membership + const response = await fetch(`${BASE_URL}/organization/domains?orgId=${unauthorizedOrgId}`, { + headers, + }) + + expect(response.status).toBe(400) + const data = await response.json() as any + expect(data.error).toBe('cannot_access_organization') + + // Cleanup + await getSupabaseClient().from('orgs').delete().eq('id', unauthorizedOrgId) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', unauthorizedCustomerId) + }) + }) + + describe('[PUT] /organization/domains', () => { + it('should update allowed email domains', async () => { + const newDomains = [`newdomain-${randomUUID().substring(0, 8)}.com`, `another-${randomUUID().substring(0, 8)}.org`] + const response = await fetch(`${BASE_URL}/organization/domains`, { + method: 'PUT', + headers, + body: JSON.stringify({ + orgId: TEST_ORG_ID, + domains: newDomains, + enabled: true, + }), + }) + + if (response.status !== 200) { + const errorData = await response.json() + console.error('Error response:', errorData) + } + expect(response.status).toBe(200) + const data = await response.json() as any + expect(data.status).toBe('Organization allowed email domains updated') + expect(data.orgId).toBe(TEST_ORG_ID) + expect(data.allowed_email_domains).toEqual(newDomains) + + // Verify the update persisted + const { data: orgData } = await getSupabaseClient() + .from('orgs') + .select('allowed_email_domains') + .eq('id', TEST_ORG_ID) + .single() + expect(orgData?.allowed_email_domains).toEqual(newDomains) + }) + + it('should normalize domains (lowercase, trim, remove @)', async () => { + const unnormalizedDomains = [' UPPERCASE.COM ', '@prefixed.org', ' MixedCase.net'] + const expectedDomains = ['uppercase.com', 'prefixed.org', 'mixedcase.net'] + + const response = await fetch(`${BASE_URL}/organization/domains`, { + method: 'PUT', + headers, + body: JSON.stringify({ + orgId: TEST_ORG_ID, + domains: unnormalizedDomains, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as any + expect(data.allowed_email_domains).toEqual(expectedDomains) + }) + + it('should reject invalid domains', async () => { + const invalidDomains = ['nodot', 'a'] + + for (const invalidDomain of invalidDomains) { + const response = await fetch(`${BASE_URL}/organization/domains`, { + method: 'PUT', + headers, + body: JSON.stringify({ + orgId: TEST_ORG_ID, + domains: [invalidDomain], + enabled: true, + }), + }) + + expect(response.status).toBe(400) + const data = await response.json() as any + expect(data.error).toBe('invalid_domain') + } + }) + + it('should reject blocked public email domains', async () => { + const blockedDomains = ['gmail.com', 'yahoo.com', 'outlook.com', 'tempmail.com'] + + for (const blockedDomain of blockedDomains) { + const response = await fetch(`${BASE_URL}/organization/domains`, { + method: 'PUT', + headers, + body: JSON.stringify({ + orgId: TEST_ORG_ID, + domains: [blockedDomain], + enabled: true, + }), + }) + + expect(response.status).toBe(400) + const data = await response.json() as any + expect(data.error).toBe('blocked_domain') + expect(data.message).toContain('public email provider') + } + }) + + it('should clear domains with empty array', async () => { + const response = await fetch(`${BASE_URL}/organization/domains`, { + method: 'PUT', + headers, + body: JSON.stringify({ + orgId: TEST_ORG_ID, + domains: [], + enabled: false, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as any + expect(data.allowed_email_domains).toEqual([]) + }) + + it('should reject request from non-admin user', async () => { + // Create an org and add test user as 'read' member (not admin) + const readOnlyOrgId = randomUUID() + const readOnlyCustomerId = `cus_readonly_${randomUUID()}` + + // Create stripe_info + await getSupabaseClient().from('stripe_info').insert({ + customer_id: readOnlyCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + }) + + // Create org owned by a different user + await getSupabaseClient().from('orgs').insert({ + id: readOnlyOrgId, + name: `ReadOnly Org ${randomUUID()}`, + management_email: 'readonly@example.com', + created_by: '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', // USER_ID_2 + customer_id: readOnlyCustomerId, + allowed_email_domains: ['readonly.com'], + }) + + // Add test user as 'read' member (not admin) + await getSupabaseClient().from('org_users').insert({ + org_id: readOnlyOrgId, + user_id: USER_ID, // Test user + user_right: 'read', // Not admin or super_admin + app_id: null, + channel_id: null, + }) + + // Try to update domains with read-only permission + const response = await fetch(`${BASE_URL}/organization/domains`, { + method: 'PUT', + headers, + body: JSON.stringify({ + orgId: readOnlyOrgId, + domains: ['newdomain.com'], + enabled: true, + }), + }) + + expect(response.status).toBe(400) + await getSupabaseClient().from('orgs').delete().eq('id', readOnlyOrgId) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', readOnlyCustomerId) + }) + }) + + describe('SSO Domain Uniqueness', () => { + it('should allow same domain for multiple non-SSO orgs', async () => { + const secondOrgId = randomUUID() + const secondCustomerId = `cus_sso_test_${randomUUID()}` + const sharedDomain = 'shared-company.com' + + // Create second org + await getSupabaseClient().from('stripe_info').insert({ + customer_id: secondCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + + await getSupabaseClient().from('orgs').insert({ + id: secondOrgId, + name: 'Second Test Org', + management_email: USER_ADMIN_EMAIL, + created_by: USER_ID, + customer_id: secondCustomerId, + allowed_email_domains: [], + }) + + // Both orgs should be able to use the same domain when SSO is not enabled + await getSupabaseClient() + .from('orgs') + .update({ allowed_email_domains: [sharedDomain] }) + .eq('id', TEST_ORG_ID) + + const { error } = await getSupabaseClient() + .from('orgs') + .update({ allowed_email_domains: [sharedDomain] }) + .eq('id', secondOrgId) + + expect(error).toBeNull() + + // Cleanup + await getSupabaseClient().from('orgs').delete().eq('id', secondOrgId) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', secondCustomerId) + }) + + it('should prevent SSO domain conflicts', async () => { + const secondOrgId = randomUUID() + const secondCustomerId = `cus_sso_conflict_${randomUUID()}` + const exclusiveDomain = 'exclusive-sso.com' + + // Create second org + await getSupabaseClient().from('stripe_info').insert({ + customer_id: secondCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + + await getSupabaseClient().from('orgs').insert({ + id: secondOrgId, + name: 'SSO Conflict Test Org', + management_email: USER_ADMIN_EMAIL, + created_by: USER_ID, + customer_id: secondCustomerId, + allowed_email_domains: [], + }) + + // Enable SSO and set domain on first org + await getSupabaseClient() + .from('orgs') + .update({ + allowed_email_domains: [exclusiveDomain], + sso_enabled: true, + }) + .eq('id', TEST_ORG_ID) + + // Try to claim the same domain with SSO on second org (should fail) + const { error } = await getSupabaseClient() + .from('orgs') + .update({ + allowed_email_domains: [exclusiveDomain], + sso_enabled: true, + }) + .eq('id', secondOrgId) + + expect(error).not.toBeNull() + expect(error?.message).toContain('already claimed') + + // Cleanup + await getSupabaseClient() + .from('orgs') + .update({ allowed_email_domains: [], sso_enabled: false }) + .eq('id', TEST_ORG_ID) + await getSupabaseClient().from('orgs').delete().eq('id', secondOrgId) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', secondCustomerId) + }) + }) + + describe('Auto-Join Functionality', () => { + it.skip('should auto-join user to org on signup with matching email domain', async () => { + const testEmail = `testuser@${TEST_DOMAIN}` + + // Set org to have test domain + await getSupabaseClient() + .from('orgs') + .update({ allowed_email_domains: [TEST_DOMAIN], sso_enabled: true }) + .eq('id', TEST_ORG_ID) + + // Create user in auth.users first (required for foreign key) + const { data: authUser, error: authError } = await createAuthUserWithRetry( + testEmail, + { + first_name: 'Test', + last_name: 'User', + }, + ) + + expect(authError).toBeNull() + expect(authUser?.user?.id).toBeDefined() + + // Create user in public.users table with the auth user's ID + const { error: userError } = await getSupabaseClient() + .from('users') + .insert({ + id: authUser!.user!.id, + email: testEmail, + first_name: 'Test', + last_name: 'User', + }) + + expect(userError).toBeNull() + + // Wait a moment for trigger to execute + await new Promise(resolve => setTimeout(resolve, 500)) + + // Check if user was auto-added to org + const { data: membership } = await getSupabaseClient() + .from('org_users') + .select('*') + .eq('user_id', authUser!.user!.id) + .eq('org_id', TEST_ORG_ID) + .single() + + expect(membership).not.toBeNull() + expect(membership?.user_right).toBe('read') + + // Cleanup + await getSupabaseClient().from('org_users').delete().eq('user_id', authUser!.user!.id) + await getSupabaseClient().from('users').delete().eq('id', authUser!.user!.id) + await getSupabaseClient().auth.admin.deleteUser(authUser!.user!.id) + }) + + it.skip('should NOT auto-join user with non-matching domain', async () => { + const testEmail = `testuser@otherdomain.com` + + // Create user in auth.users first + const { data: authUser, error: authError } = await createAuthUserWithRetry( + testEmail, + { + first_name: 'Test', + last_name: 'User', + }, + ) + + expect(authError).toBeNull() + + // Create user in public.users with non-matching domain + const { error: userError } = await getSupabaseClient() + .from('users') + .insert({ + id: authUser!.user!.id, + email: testEmail, + first_name: 'Test', + last_name: 'User', + }) + + expect(userError).toBeNull() + + // Wait a moment for trigger (if it runs) + await new Promise(resolve => setTimeout(resolve, 500)) + + // Check that user was NOT added to test org + const { data: membership } = await getSupabaseClient() + .from('org_users') + .select('*') + .eq('user_id', authUser!.user!.id) + .eq('org_id', TEST_ORG_ID) + .maybeSingle() + + expect(membership).toBeNull() + + // Cleanup + await getSupabaseClient().from('users').delete().eq('id', authUser!.user!.id) + await getSupabaseClient().auth.admin.deleteUser(authUser!.user!.id) + }) + + it.skip('should auto-join user to single org with matching domain when SSO enabled', async () => { + const testDomain = 'test-single-join.com' + const testEmail = `testuser-${randomUUID().slice(0, 8)}@${testDomain}` + + // Update first org to have test domain with SSO enabled + await getSupabaseClient() + .from('orgs') + .update({ allowed_email_domains: [testDomain], sso_enabled: true }) + .eq('id', TEST_ORG_ID) + + // Create auth user + const { data: authUser, error: authError } = await createAuthUserWithRetry( + testEmail, + { + first_name: 'Test', + last_name: 'User', + }, + ) + + expect(authError).toBeNull() + + // Create user in public.users + await getSupabaseClient() + .from('users') + .insert({ + id: authUser!.user!.id, + email: testEmail, + first_name: 'Test', + last_name: 'User', + }) + + // Wait for trigger + await new Promise(resolve => setTimeout(resolve, 500)) + + // Check membership + const { data: memberships } = await getSupabaseClient() + .from('org_users') + .select('*') + .eq('user_id', authUser!.user!.id) + .eq('org_id', TEST_ORG_ID) + + expect(memberships).not.toBeNull() + expect(memberships?.length).toBe(1) + + // Cleanup + await getSupabaseClient().from('org_users').delete().eq('user_id', authUser!.user!.id) + await getSupabaseClient().from('users').delete().eq('id', authUser!.user!.id) + await getSupabaseClient().auth.admin.deleteUser(authUser!.user!.id) + }) + + it.skip('should NOT duplicate membership if user already belongs to org', async () => { + const testEmail = `existing${Date.now()}@${TEST_DOMAIN}` + + // Set org to have test domain + await getSupabaseClient() + .from('orgs') + .update({ allowed_email_domains: [TEST_DOMAIN], sso_enabled: true }) + .eq('id', TEST_ORG_ID) + + // Create auth user first + const { data: authUser, error: authError } = await createAuthUserWithRetry( + testEmail, + { + first_name: 'Existing', + last_name: 'User', + }, + ) + + expect(authError).toBeNull() + + // Create user in public.users + await getSupabaseClient() + .from('users') + .insert({ + id: authUser!.user!.id, + email: testEmail, + first_name: 'Existing', + last_name: 'User', + }) + + // Wait for auto-join trigger to add user + await new Promise(resolve => setTimeout(resolve, 500)) + + // Now update the permission to admin + await getSupabaseClient() + .from('org_users') + .update({ user_right: 'admin' }) + .eq('user_id', authUser!.user!.id) + .eq('org_id', TEST_ORG_ID) + + // Try to manually insert another membership (should fail with unique constraint) + const { error: duplicateError } = await getSupabaseClient() + .from('org_users') + .insert({ + user_id: authUser!.user!.id, + org_id: TEST_ORG_ID, + user_right: 'read', + }) + + expect(duplicateError).not.toBeNull() // Unique constraint violation + expect(duplicateError?.code).toBe('23505') + + // Check that there's still only one membership with admin rights (not overwritten) + const { data: memberships, count } = await getSupabaseClient() + .from('org_users') + .select('*', { count: 'exact' }) + .eq('user_id', authUser!.user!.id) + .eq('org_id', TEST_ORG_ID) + + expect(count).toBe(1) + expect(memberships?.[0].user_right).toBe('admin') // Permission NOT overwritten + + // Cleanup + await getSupabaseClient().from('org_users').delete().eq('user_id', authUser!.user!.id) + await getSupabaseClient().from('users').delete().eq('id', authUser!.user!.id) + await getSupabaseClient().auth.admin.deleteUser(authUser!.user!.id) + }) + }) + + describe('Database Functions', () => { + it('extract_email_domain should extract domain correctly', async () => { + const { data, error } = await getSupabaseClient() + .rpc('extract_email_domain', { email: 'test@example.com' }) + + expect(error).toBeNull() + expect(data).toBe('example.com') + }) + + it('extract_email_domain should handle uppercase', async () => { + const { data } = await getSupabaseClient() + .rpc('extract_email_domain', { email: 'TEST@EXAMPLE.COM' }) + + expect(data).toBe('example.com') + }) + + it('find_orgs_by_email_domain should find matching orgs', async () => { + // Ensure test org has the domain and is enabled + await getSupabaseClient() + .from('orgs') + .update({ allowed_email_domains: [TEST_DOMAIN], sso_enabled: true }) + .eq('id', TEST_ORG_ID) + + const { data, error } = await getSupabaseClient() + .rpc('find_orgs_by_email_domain', { user_email: `test@${TEST_DOMAIN}` }) + + expect(error).toBeNull() + expect(data).not.toBeNull() + expect(Array.isArray(data)).toBe(true) + const matchingOrg = data?.find((org: any) => org.org_id === TEST_ORG_ID) + expect(matchingOrg).toBeDefined() + expect(matchingOrg?.org_name).toBe(TEST_ORG_NAME) + }) + + it('find_orgs_by_email_domain should return empty for non-matching domain', async () => { + const { data, error } = await getSupabaseClient() + .rpc('find_orgs_by_email_domain', { user_email: 'test@nonexistent-domain-12345.com' }) + + expect(error).toBeNull() + expect(Array.isArray(data)).toBe(true) + expect(data?.length).toBe(0) + }) + }) +}) diff --git a/tests/organization-api.test.ts b/tests/organization-api.test.ts index 99420fd80a..1a8c797b50 100644 --- a/tests/organization-api.test.ts +++ b/tests/organization-api.test.ts @@ -302,10 +302,12 @@ describe('[DELETE] /organization/members', () => { expect(userData).toBeTruthy() expect(userData?.email).toBe(USER_ADMIN_EMAIL) - const { error } = await getSupabaseClient().from('org_users').insert({ + const { error } = await getSupabaseClient().from('org_users').upsert({ org_id: ORG_ID, user_id: userData!.id, user_right: 'invite_read', + }, { + onConflict: 'org_id,user_id', }) expect(error).toBeNull() diff --git a/tests/password-policy.test.ts b/tests/password-policy.test.ts index 2724c40312..a89c261a84 100644 --- a/tests/password-policy.test.ts +++ b/tests/password-policy.test.ts @@ -32,10 +32,12 @@ beforeAll(async () => { throw error // Add user as member of the org - const { error: memberError } = await getSupabaseClient().from('org_users').insert({ + const { error: memberError } = await getSupabaseClient().from('org_users').upsert({ org_id: ORG_ID, user_id: USER_ID, user_right: 'super_admin', + }, { + onConflict: 'org_id,user_id', }) if (memberError) throw memberError @@ -379,10 +381,12 @@ describe('Password Policy Enforcement Integration', () => { }) // Add user as member - await getSupabaseClient().from('org_users').insert({ + await getSupabaseClient().from('org_users').upsert({ org_id: orgWithPolicyId, user_id: USER_ID, user_right: 'super_admin', + }, { + onConflict: 'org_id,user_id', }) }) diff --git a/tests/private-error-cases.test.ts b/tests/private-error-cases.test.ts index d18950b9f3..76946c7d87 100644 --- a/tests/private-error-cases.test.ts +++ b/tests/private-error-cases.test.ts @@ -22,10 +22,12 @@ beforeAll(async () => { testOrgId = orgData.id // Add test user as super_admin to the org - const { error: orgUserError } = await getSupabaseClient().from('org_users').insert({ + const { error: orgUserError } = await getSupabaseClient().from('org_users').upsert({ org_id: testOrgId, user_id: USER_ID, user_right: 'super_admin' as const, + }, { + onConflict: 'org_id,user_id', }) if (orgUserError) throw orgUserError