diff --git a/db/migrations/20260515170000_auth_profiles_default_for_connector.sql b/db/migrations/20260515170000_auth_profiles_default_for_connector.sql new file mode 100644 index 000000000..ae1241e77 --- /dev/null +++ b/db/migrations/20260515170000_auth_profiles_default_for_connector.sql @@ -0,0 +1,23 @@ +-- migrate:up +-- Admin-managed default app profile per (org, connector_key). +-- Today getPrimaryAuthProfileForKind picks the most-recently-updated active +-- oauth_app profile for the connector — admins have no way to designate +-- which profile members should fall through to. The flag lets the admin +-- pin a chosen profile; the resolver prefers flagged rows first. +-- +-- Constrained to oauth_app for now since that's the only kind where +-- "default for connector" is meaningful (env / interactive / browser_session +-- are picked by other rules — device binding, capture mode, etc.). + +ALTER TABLE auth_profiles + ADD COLUMN is_default_for_connector boolean NOT NULL DEFAULT false; + +CREATE UNIQUE INDEX auth_profiles_default_for_connector_unique + ON auth_profiles (organization_id, connector_key) + WHERE is_default_for_connector AND profile_kind = 'oauth_app'; + +-- migrate:down +DROP INDEX IF EXISTS auth_profiles_default_for_connector_unique; + +ALTER TABLE auth_profiles + DROP COLUMN IF EXISTS is_default_for_connector; diff --git a/db/schema.sql b/db/schema.sql index 8ecda841b..9ca4e6132 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -255,6 +255,7 @@ CREATE TABLE public.auth_profiles ( browser_kind text, user_data_dir text, cdp_url text, + is_default_for_connector boolean DEFAULT false NOT NULL, CONSTRAINT auth_profiles_browser_kind_check CHECK (((browser_kind IS NULL) OR (browser_kind = ANY (ARRAY['chrome'::text, 'brave'::text, 'arc'::text, 'edge'::text])))), CONSTRAINT auth_profiles_connector_key_required CHECK (((connector_key IS NOT NULL) OR (profile_kind = 'browser_session'::text))), CONSTRAINT auth_profiles_device_browser_path_mutex CHECK (((device_worker_id IS NULL) OR (profile_kind <> 'browser_session'::text) OR (user_data_dir IS NULL) OR (cdp_url IS NULL))), @@ -2856,6 +2857,12 @@ CREATE INDEX agents_organization_id_idx ON public.agents USING btree (organizati CREATE INDEX auth_profiles_connector_kind_idx ON public.auth_profiles USING btree (organization_id, connector_key, profile_kind, status); +-- +-- Name: auth_profiles_default_for_connector_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX auth_profiles_default_for_connector_unique ON public.auth_profiles USING btree (organization_id, connector_key) WHERE (is_default_for_connector AND (profile_kind = 'oauth_app'::text)); + -- -- Name: auth_profiles_device_worker_idx; Type: INDEX; Schema: public; Owner: - -- @@ -4999,6 +5006,7 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260515120000'), ('20260515150000'), ('20260515160000'), + ('20260515170000'), ('20260516120000'), ('20260516200000'), ('20260516200100'); diff --git a/packages/server/src/auth/__tests__/tool-access.test.ts b/packages/server/src/auth/__tests__/tool-access.test.ts index 2e2d35af6..1437e06ad 100644 --- a/packages/server/src/auth/__tests__/tool-access.test.ts +++ b/packages/server/src/auth/__tests__/tool-access.test.ts @@ -55,7 +55,18 @@ describe('requiresOwnerAdmin', () => { expect( requiresOwnerAdmin('manage_connections', { action: 'update_connector_default_config' }, false) ).toBe(true); + }); + + it('should allow members to create + reauthenticate their own connections', () => { + // `manage_connections.create` and `reauthenticate` are member-write; the + // handler enforces app_auth_profile_slug must match the org default for + // non-admins, so non-admins can't bring an alternate OAuth client. + expect(requiresOwnerAdmin('manage_connections', { action: 'create' }, false)).toBe(false); + expect(requiresMemberWrite('manage_connections', { action: 'create' }, false)).toBe(true); expect(requiresOwnerAdmin('manage_connections', { action: 'reauthenticate' }, false)).toBe( + false + ); + expect(requiresMemberWrite('manage_connections', { action: 'reauthenticate' }, false)).toBe( true ); }); @@ -67,11 +78,29 @@ describe('requiresOwnerAdmin', () => { expect( requiresOwnerAdmin('manage_auth_profiles', { action: 'test_auth_profile' }, false) ).toBe(true); + expect( + requiresOwnerAdmin('manage_auth_profiles', { action: 'delete_auth_profile' }, false) + ).toBe(true); + expect( + requiresOwnerAdmin('manage_auth_profiles', { action: 'set_default_auth_profile' }, false) + ).toBe(true); + }); + + it('should allow members to create their own oauth_account profile', () => { + // create_auth_profile / update_auth_profile are member-write at the + // policy layer; the handler gates by profile_kind so non-oauth_account + // kinds (env, oauth_app, browser_session) stay admin-only. expect( requiresOwnerAdmin('manage_auth_profiles', { action: 'create_auth_profile' }, false) + ).toBe(false); + expect( + requiresMemberWrite('manage_auth_profiles', { action: 'create_auth_profile' }, false) ).toBe(true); expect( - requiresOwnerAdmin('manage_auth_profiles', { action: 'delete_auth_profile' }, false) + requiresOwnerAdmin('manage_auth_profiles', { action: 'update_auth_profile' }, false) + ).toBe(false); + expect( + requiresMemberWrite('manage_auth_profiles', { action: 'update_auth_profile' }, false) ).toBe(true); }); diff --git a/packages/server/src/auth/tool-access.ts b/packages/server/src/auth/tool-access.ts index 445f6004e..f9e0b3306 100644 --- a/packages/server/src/auth/tool-access.ts +++ b/packages/server/src/auth/tool-access.ts @@ -22,17 +22,32 @@ const MEMBER_WRITE_ACTIONS: Record | null> = { // via SDK namespace wrappers from inside `run_sdk`, and `routeAction` consults // these tables to fire the same per-action access decisions. manage_entity: new Set(['create', 'update', 'link', 'unlink', 'update_link']), + // Members can install connections that bind to their own OAuth account + // grant. The handler gates `app_auth_profile_slug` overrides against the + // org default + caller role, so members can't pick a non-default app + // profile. + manage_connections: new Set(['create', 'reauthenticate']), + // Members create / reconnect their own oauth_account profile. The handler + // gates `profile_kind` against role so env / oauth_app / browser_session + // stay admin-only. + manage_auth_profiles: new Set([ + 'create_auth_profile', + 'update_auth_profile', + 'test_auth_profile', + 'get_auth_profile', + ]), }; const OWNER_ADMIN_ACTIONS: Record> = { manage_entity: new Set(['delete']), manage_entity_schema: new Set(['create', 'update', 'delete', 'add_rule', 'remove_rule']), manage_connections: new Set([ - 'create', + // `create` and `reauthenticate` are in MEMBER_WRITE_ACTIONS — members + // install their own connections (handler enforces app_auth_profile slug + // override + role gates). 'update', 'delete', 'connect', - 'reauthenticate', 'test', 'install_connector', 'uninstall_connector', @@ -44,11 +59,13 @@ const OWNER_ADMIN_ACTIONS: Record> = { ]), manage_feeds: new Set(['create_feed', 'update_feed', 'delete_feed', 'trigger_feed']), manage_auth_profiles: new Set([ + // `create_auth_profile` and `update_auth_profile` are in + // MEMBER_WRITE_ACTIONS — the handler enforces oauth_account-only access + // for non-admins so members can't create org-shared credentials. 'get_auth_profile', 'test_auth_profile', - 'create_auth_profile', - 'update_auth_profile', 'delete_auth_profile', + 'set_default_auth_profile', ]), manage_operations: new Set(['execute', 'approve', 'reject']), manage_watchers: new Set([ diff --git a/packages/server/src/tools/admin/helpers/connection-helpers.ts b/packages/server/src/tools/admin/helpers/connection-helpers.ts index 401db3301..8c4cc6119 100644 --- a/packages/server/src/tools/admin/helpers/connection-helpers.ts +++ b/packages/server/src/tools/admin/helpers/connection-helpers.ts @@ -512,6 +512,7 @@ export function serializeAuthProfile(authProfile: AuthProfileRow): Record; @@ -212,6 +229,11 @@ export async function manageAuthProfiles( args as Extract, ctx ), + set_default_auth_profile: () => + handleSetDefaultAuthProfile( + args as Extract, + ctx + ), }); } @@ -372,6 +394,30 @@ async function handleTestAuthProfile( }; } +/** + * When an oauth_app profile is revoked/errored, any connection that mints + * tokens against it can no longer authenticate. Flip those connections to + * pending_auth so the UI surfaces the breakage; the admin re-pins or rotates + * the app, then operators re-authorize the connection. + */ +async function syncConnectionsForOAuthAppProfile( + organizationId: string, + authProfileId: number, + active: boolean +): Promise { + const sql = getDb(); + const nextConnectionStatus = active ? 'active' : 'pending_auth'; + + await sql` + UPDATE connections + SET status = ${nextConnectionStatus}, + updated_at = NOW() + WHERE organization_id = ${organizationId} + AND app_auth_profile_id = ${authProfileId} + AND deleted_at IS NULL + `; +} + async function syncConnectionsForBrowserAuthProfile( organizationId: string, authProfileId: number, @@ -406,6 +452,20 @@ async function handleCreateAuthProfile( args: Extract, ctx: ToolContext ): Promise { + // Only oauth_account profiles are user-personal; every other kind is an + // org-shared credential (env keys, OAuth app client_id/secret, browser + // session, interactive). Gate non-personal kinds on admin role. + if (args.profile_kind !== 'oauth_account') { + const role = ctx.userId + ? await getWorkspaceRole(getDb(), ctx.organizationId, ctx.userId) + : null; + if (role !== 'admin' && role !== 'owner') { + return { + error: `Only admins can create ${args.profile_kind} auth profiles. Ask an organization owner or admin to configure these credentials.`, + }; + } + } + // browser_session profiles are device-scoped; connector_key is optional // (only used as a hint to look up a default cdp_url). Other kinds remain // per-connector and require it. @@ -452,6 +512,19 @@ async function handleCreateAuthProfile( error: `Auth profile '${existing.slug}' already exists with a different kind/connector (${existing.profile_kind} / ${existing.connector_key}) — use a new slug`, }; } + // Non-admins reusing an existing oauth_account slug must own it — + // otherwise a member who knows another member's pending profile slug + // could mint a fresh connect token for it and complete OAuth into a + // profile already referenced by someone else's connections. + const role = ctx.userId + ? await getWorkspaceRole(getDb(), ctx.organizationId, ctx.userId) + : null; + const callerIsAdmin = role === 'admin' || role === 'owner'; + if (!callerIsAdmin && existing.created_by !== ctx.userId) { + return { + error: `Auth profile '${existing.slug}' belongs to another user. Choose a different slug.`, + }; + } if (existing.status === 'active') { return { action: 'create_auth_profile', auth_profile: serializeAuthProfile(existing) }; } @@ -606,6 +679,35 @@ async function handleUpdateAuthProfile( args: Extract, ctx: ToolContext ): Promise { + // Mirror create gating: only oauth_account profiles are member-editable. + // env / oauth_app / browser_session are org-shared credentials — admin only. + // For oauth_account, non-admins can only touch a profile they created — the + // slug alone shouldn't let one member rotate another member's tokens. + const existingForRoleCheck = await getAuthProfileBySlug( + ctx.organizationId, + args.auth_profile_slug + ); + if (existingForRoleCheck) { + const role = ctx.userId + ? await getWorkspaceRole(getDb(), ctx.organizationId, ctx.userId) + : null; + const callerIsAdmin = role === 'admin' || role === 'owner'; + if (existingForRoleCheck.profile_kind !== 'oauth_account' && !callerIsAdmin) { + return { + error: `Only admins can modify ${existingForRoleCheck.profile_kind} auth profiles.`, + }; + } + if ( + !callerIsAdmin && + existingForRoleCheck.profile_kind === 'oauth_account' && + existingForRoleCheck.created_by !== ctx.userId + ) { + return { + error: `You can only update OAuth account profiles you created. Ask an admin if you need to manage another member's profile.`, + }; + } + } + let authProfile = await updateAuthProfile({ organizationId: ctx.organizationId, slug: args.auth_profile_slug, @@ -728,6 +830,28 @@ async function handleUpdateAuthProfile( ); } + // Cascade for oauth_app: admins flipping an app profile to revoked/error + // need dependent connections to surface as broken (instead of silently + // continuing to point at a profile whose creds the gateway can no longer + // resolve). For the revoke/error case we re-do the status flip inside a + // transaction together with the cascade + default-clear so there's no + // window where connections still reference the revoked profile (the prior + // updateAuthProfile call above already wrote status, but its tx is now + // closed — this overwrite is idempotent and lands the full state change + // atomically). + if (authProfile.profile_kind === 'oauth_app') { + if (authProfile.status === 'revoked' || authProfile.status === 'error') { + const atomic = await revokeOAuthAppProfileAtomic({ + organizationId: ctx.organizationId, + profileId: authProfile.id, + nextStatus: authProfile.status, + }); + if (atomic) authProfile = atomic; + } else if (authProfile.status === 'active') { + await syncConnectionsForOAuthAppProfile(ctx.organizationId, authProfile.id, true); + } + } + return { action: 'update_auth_profile', auth_profile: serializeAuthProfile(authProfile) }; } @@ -757,19 +881,85 @@ async function handleDeleteAuthProfile( }; } - // Clean up connect tokens referencing this profile - await sql` - UPDATE connect_tokens - SET auth_profile_id = NULL - WHERE auth_profile_id = ${existing.id} - `; + // Sync + delete must happen atomically: between flipping dependent + // connections to `pending_auth` and the DELETE, a concurrent + // `manage_connections.create` could insert a new connection referencing + // this profile. The FK `ON DELETE SET NULL` would then leave that + // connection active with `app_auth_profile_id = NULL`. Lock the profile + // row up front (`FOR UPDATE` conflicts with the FK insert's FOR KEY SHARE + // lock, so concurrent inserts block until we commit and then fail FK). + const deleted = await sql.begin(async (tx) => { + const lockRows = await tx` + SELECT id FROM auth_profiles + WHERE organization_id = ${ctx.organizationId} + AND id = ${existing.id} + FOR UPDATE + `; + if (lockRows.length === 0) return null; - // Pause browser-backed connections BEFORE deleting (ON DELETE SET NULL would orphan them) - if (existing.profile_kind === 'browser_session') { - await syncConnectionsForBrowserAuthProfile(ctx.organizationId, existing.id, false); - } + await tx` + UPDATE connect_tokens + SET auth_profile_id = NULL + WHERE auth_profile_id = ${existing.id} + `; - const deleted = await deleteAuthProfile(ctx.organizationId, args.auth_profile_slug); + if (existing.profile_kind === 'browser_session') { + // Pause dependent connections + their feeds (mirrors + // syncConnectionsForBrowserAuthProfile, inlined for tx locality). + await tx` + UPDATE connections + SET status = 'pending_auth', updated_at = NOW() + WHERE organization_id = ${ctx.organizationId} + AND auth_profile_id = ${existing.id} + `; + await tx` + UPDATE feeds f + SET status = 'paused', + next_run_at = NULL, + updated_at = NOW() + FROM connections c + WHERE f.connection_id = c.id + AND c.organization_id = ${ctx.organizationId} + AND c.auth_profile_id = ${existing.id} + `; + } + + if (existing.profile_kind === 'oauth_app') { + await tx` + UPDATE connections + SET status = 'pending_auth', updated_at = NOW() + WHERE organization_id = ${ctx.organizationId} + AND app_auth_profile_id = ${existing.id} + AND deleted_at IS NULL + `; + } + + // Explicitly null the FK columns before delete. The FK is composite + // `(organization_id, *_auth_profile_id) ON DELETE SET NULL`; without a + // SET NULL column list, Postgres would attempt to null organization_id + // too (NOT NULL → constraint violation). Setting only the profile-id + // column here avoids that and matches the intended semantic. + await tx` + UPDATE connections + SET auth_profile_id = NULL, updated_at = NOW() + WHERE organization_id = ${ctx.organizationId} + AND auth_profile_id = ${existing.id} + `; + await tx` + UPDATE connections + SET app_auth_profile_id = NULL, updated_at = NOW() + WHERE organization_id = ${ctx.organizationId} + AND app_auth_profile_id = ${existing.id} + `; + + const deletedRows = await tx` + DELETE FROM auth_profiles + WHERE organization_id = ${ctx.organizationId} + AND id = ${existing.id} + RETURNING id + `; + return deletedRows.length > 0 ? deletedRows[0] : null; + }); if (!deleted) { return { error: `Failed to delete auth profile '${args.auth_profile_slug}'` }; } @@ -780,3 +970,42 @@ async function handleDeleteAuthProfile( auth_profile_slug: args.auth_profile_slug, }; } + +async function handleSetDefaultAuthProfile( + args: Extract, + ctx: ToolContext +): Promise { + if (args.auth_profile_slug !== null) { + const target = await getAuthProfileBySlug(ctx.organizationId, args.auth_profile_slug); + if (!target) { + return { error: `Auth profile '${args.auth_profile_slug}' not found` }; + } + if (target.profile_kind !== 'oauth_app') { + return { + error: `Auth profile '${args.auth_profile_slug}' is a ${target.profile_kind} profile; only oauth_app profiles can be pinned as connector defaults.`, + }; + } + if (target.connector_key !== args.connector_key) { + return { + error: `Auth profile '${args.auth_profile_slug}' is bound to connector '${target.connector_key}', not '${args.connector_key}'.`, + }; + } + if (target.status !== 'active') { + return { + error: `Auth profile '${args.auth_profile_slug}' is ${target.status}; only active profiles can be pinned as the default.`, + }; + } + } + + const pinned = await setDefaultAuthProfileForConnector({ + organizationId: ctx.organizationId, + connectorKey: args.connector_key, + slug: args.auth_profile_slug, + }); + + return { + action: 'set_default_auth_profile', + connector_key: args.connector_key, + auth_profile: pinned ? serializeAuthProfile(pinned) : null, + }; +} diff --git a/packages/server/src/tools/admin/manage_connections.ts b/packages/server/src/tools/admin/manage_connections.ts index 0f3016ada..957f6836e 100644 --- a/packages/server/src/tools/admin/manage_connections.ts +++ b/packages/server/src/tools/admin/manage_connections.ts @@ -32,6 +32,7 @@ import { import { createAuthProfile, getAuthProfileById, + getAuthProfileBySlug, getBrowserSessionReadiness, getPrimaryAuthProfileForKind, normalizeAuthValues, @@ -789,16 +790,48 @@ async function handleCreate( const sql = getDb(); const { organizationId, userId } = ctx; + // Resolve caller role once — we use it for created_by overrides, explicit + // app_auth_profile picks, and member-friendly error messages downstream. + const callerRole = userId ? await getWorkspaceRole(sql, organizationId, userId) : null; + const callerIsAdmin = callerRole === 'admin' || callerRole === 'owner'; + // Resolve effective owner — admins can create connections on behalf of other users let effectiveCreatedBy = userId; if (args.created_by && args.created_by !== userId) { - const callerRole = await getWorkspaceRole(sql, organizationId, userId!); - if (callerRole !== 'admin' && callerRole !== 'owner') { + if (!callerIsAdmin) { return { error: 'Only admins can create connections for other users.' }; } effectiveCreatedBy = args.created_by; } + // `entity_link_overrides` writes to `connector_definitions` for the entire + // org. Even though `create` is now member-write, that mutation must stay + // admin-only — otherwise a member could change connector-level entity + // mapping while ostensibly installing their own connection. + if (!callerIsAdmin && args.entity_link_overrides !== undefined) { + return { + error: + 'Only admins can change connector entity-link overrides. Omit `entity_link_overrides`, or ask an admin to update them via `set_connector_entity_link_overrides`.', + }; + } + + // Non-admins must accept the org-default app profile — they can't pick or + // bring an alternate OAuth client. If they explicitly pass a slug, it has + // to match the admin-pinned default for the connector. + if (!callerIsAdmin && args.app_auth_profile_slug) { + const picked = await getAuthProfileBySlug(organizationId, args.app_auth_profile_slug); + if (!picked || picked.profile_kind !== 'oauth_app') { + return { error: `App auth profile '${args.app_auth_profile_slug}' not found` }; + } + const pinnedAsDefault = + picked.is_default_for_connector && picked.connector_key === args.connector_key; + if (!pinnedAsDefault) { + return { + error: `Only admins can override the OAuth app profile. Ask an admin to pin '${args.app_auth_profile_slug}' as the default for this connector, or omit app_auth_profile_slug to use the org default.`, + }; + } + } + // Ensure connector is installed from bundled catalog if needed await ensureConnectorInstalled({ organizationId, connectorKey: args.connector_key }); @@ -930,10 +963,35 @@ async function handleCreate( }; } + // Non-admin members can only bind a connection to a runtime auth profile + // they own. `env` profiles are admin-managed org-shared credentials — + // members must never bind to them. `oauth_account` and `browser_session` + // profiles are member-creatable but still per-user, so a member can't + // hijack another member's grant by passing their slug. + if (authSelection?.authProfile && !callerIsAdmin) { + const profile = authSelection.authProfile; + if (profile.profile_kind === 'env') { + return { + error: + 'Only admins can use env-credential auth profiles. Ask an admin to install this connection.', + }; + } + if ( + (profile.profile_kind === 'oauth_account' || profile.profile_kind === 'browser_session') && + profile.created_by !== ctx.userId + ) { + return { + error: `Auth profile '${profile.slug}' belongs to another user. Create your own profile (action: 'create_auth_profile') and use its slug instead.`, + }; + } + } + if (authSelection?.selectedKind === 'oauth_account') { if (!authSelection.appAuthProfile) { return { - error: 'Select or create an OAuth app profile before creating the connection.', + error: callerIsAdmin + ? 'Select or create an OAuth app profile before creating the connection.' + : `No OAuth app credentials configured for this connector. Ask an admin to set up the ${authSelection.oauthMethod?.provider ?? args.connector_key} app in /oauth-apps first.`, }; } if (authSelection.appAuthProfile.status !== 'active') { @@ -941,6 +999,19 @@ async function handleCreate( error: `Selected app auth profile '${authSelection.appAuthProfile.slug}' is not active.`, }; } + // Even when the slug is omitted, non-admins can only fall through to the + // admin-pinned default for this exact connector. The resolver may + // otherwise return a recency-picked provider-wide row, which would let a + // member silently use an OAuth client the admin never blessed. + if ( + !callerIsAdmin && + (!authSelection.appAuthProfile.is_default_for_connector || + authSelection.appAuthProfile.connector_key !== args.connector_key) + ) { + return { + error: `No default OAuth app configured for this connector. Ask an admin to pin a ${authSelection.oauthMethod?.provider ?? args.connector_key} app as the default in /oauth-apps.`, + }; + } } const displayName = await resolveConnectionDisplayName({ @@ -1881,6 +1952,7 @@ async function handleReauthenticate( c.status AS connection_status, c.connector_key, c.auth_profile_id, + c.created_by AS connection_created_by, ap.profile_kind, ap.status AS auth_profile_status FROM connections c @@ -1900,10 +1972,21 @@ async function handleReauthenticate( connection_status: string; connector_key: string; auth_profile_id: number | null; + connection_created_by: string | null; profile_kind: string | null; auth_profile_status: string | null; }; + // `reauthenticate` flips the connection + its interactive profile to + // `pending_auth` and kicks off an auth run — that has to be the connection + // owner or an admin/owner. Without this gate, any org member could disrupt + // (or hijack the pairing of) another member's interactive connection. + const callerRole = await getWorkspaceRole(sql, organizationId, ctx.userId); + const callerIsAdmin = callerRole === 'admin' || callerRole === 'owner'; + if (!callerIsAdmin && row.connection_created_by !== ctx.userId) { + return { error: 'You can only re-authenticate connections you created.' }; + } + if (!row.auth_profile_id || row.profile_kind !== 'interactive') { return { error: 'Connection does not use an interactive auth profile' }; } diff --git a/packages/server/src/utils/auth-profiles.ts b/packages/server/src/utils/auth-profiles.ts index db6418d30..a6e01f252 100644 --- a/packages/server/src/utils/auth-profiles.ts +++ b/packages/server/src/utils/auth-profiles.ts @@ -29,6 +29,7 @@ export interface AuthProfileRow { browser_kind: BrowserKind | null; user_data_dir: string | null; cdp_url: string | null; + is_default_for_connector: boolean; } interface BrowserSessionSummary { @@ -241,7 +242,8 @@ const AUTH_PROFILE_COLUMNS = ` id, organization_id, slug, display_name, connector_key, profile_kind, status, auth_data, account_id, provider, created_by, created_at, updated_at, - device_worker_id, browser_kind, user_data_dir, cdp_url + device_worker_id, browser_kind, user_data_dir, cdp_url, + is_default_for_connector ` as const; export async function listAuthProfiles(params: { @@ -438,6 +440,9 @@ export async function getPrimaryAuthProfileForKind(params: { const sql = getDb(); if (params.profileKind === 'oauth_app' && params.provider) { + // Admins pin a default via is_default_for_connector; that's the strongest + // preference. After that, prefer a profile bound to this specific + // connector_key over a provider-only match, then fall back to recency. const rows = await sql` SELECT ${sql.unsafe(AUTH_PROFILE_COLUMNS)} FROM auth_profiles @@ -449,6 +454,7 @@ export async function getPrimaryAuthProfileForKind(params: { OR lower(provider) = lower(${params.provider}) ) ORDER BY + CASE WHEN is_default_for_connector AND connector_key = ${params.connectorKey} THEN 0 ELSE 1 END, CASE WHEN connector_key = ${params.connectorKey} THEN 0 ELSE 1 END, updated_at DESC, id DESC @@ -502,6 +508,89 @@ export async function getPrimaryAuthProfileForKind(params: { return rows.length > 0 ? (rows[0] as AuthProfileRow) : null; } +/** + * Atomically transition an oauth_app profile to a "broken" status (revoked or + * error) while also flipping every connection that minted tokens against it + * to pending_auth and clearing the connector default flag if set. Running + * these three statements in one transaction prevents the window where a + * connection still references a revoked profile, and is the only safe + * substitute for the missing FK-level cascade. + */ +export async function revokeOAuthAppProfileAtomic(params: { + organizationId: string; + profileId: number; + nextStatus: 'revoked' | 'error'; +}): Promise { + const sql = getDb(); + return sql.begin(async (tx) => { + const profileRows = await tx` + UPDATE auth_profiles + SET status = ${params.nextStatus}, + is_default_for_connector = false, + updated_at = NOW() + WHERE organization_id = ${params.organizationId} + AND id = ${params.profileId} + AND profile_kind = 'oauth_app' + RETURNING ${tx.unsafe(AUTH_PROFILE_COLUMNS)} + `; + + if (profileRows.length === 0) return null; + + await tx` + UPDATE connections + SET status = 'pending_auth', + updated_at = NOW() + WHERE organization_id = ${params.organizationId} + AND app_auth_profile_id = ${params.profileId} + AND deleted_at IS NULL + `; + + return profileRows[0] as AuthProfileRow; + }) as Promise; +} + +/** + * Pin one oauth_app profile as the org default for its connector_key. Clears + * the flag on any sibling profile for the same (org, connector_key) first so + * the partial unique index never trips. + * + * Pass `slug: null` to clear the default for the connector entirely (no + * profile pinned). Returns the row that ended up flagged, or null on clear. + */ +export async function setDefaultAuthProfileForConnector(params: { + organizationId: string; + connectorKey: string; + slug: string | null; +}): Promise { + const sql = getDb(); + + return sql.begin(async (tx) => { + await tx` + UPDATE auth_profiles + SET is_default_for_connector = false, + updated_at = NOW() + WHERE organization_id = ${params.organizationId} + AND connector_key = ${params.connectorKey} + AND profile_kind = 'oauth_app' + AND is_default_for_connector + `; + + if (params.slug === null) return null; + + const rows = await tx` + UPDATE auth_profiles + SET is_default_for_connector = true, + updated_at = NOW() + WHERE organization_id = ${params.organizationId} + AND slug = ${params.slug} + AND profile_kind = 'oauth_app' + RETURNING ${tx.unsafe(AUTH_PROFILE_COLUMNS)} + `; + + return rows.length > 0 ? (rows[0] as AuthProfileRow) : null; + }) as Promise; +} + export async function resolveAuthProfileSlugToId(params: { organizationId: string; slug?: string | null; diff --git a/packages/web b/packages/web index c81613b00..4516312cf 160000 --- a/packages/web +++ b/packages/web @@ -1 +1 @@ -Subproject commit c81613b00d5b4b4e60f9930696c1244118d0f897 +Subproject commit 4516312cf933313e693a2093da4ffb024740cbc5