From 549f070858649803d6d6bc26c282374d79d87383 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 16 Mar 2026 13:11:17 -0700 Subject: [PATCH 1/4] feat: add env vars to restrict API key creation and usage to org owners Co-Authored-By: Claude Sonnet 4.6 --- .../configuration/environment-variables.mdx | 3 + packages/shared/src/env.server.ts | 15 +- packages/web/src/actions.ts | 15 +- .../[domain]/settings/apiKeys/apiKeysPage.tsx | 287 ++++++++++++++++++ .../app/[domain]/settings/apiKeys/layout.tsx | 42 +++ .../app/[domain]/settings/apiKeys/page.tsx | 282 ++--------------- .../web/src/app/[domain]/settings/layout.tsx | 12 +- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/withAuthV2.ts | 34 ++- 9 files changed, 411 insertions(+), 280 deletions(-) create mode 100644 packages/web/src/app/[domain]/settings/apiKeys/apiKeysPage.tsx create mode 100644 packages/web/src/app/[domain]/settings/apiKeys/layout.tsx diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 828fe953d..729162d8d 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -53,6 +53,9 @@ The following environment variables allow you to configure your Sourcebot deploy | `PERMISSION_SYNC_REPO_DRIVEN_ENABLED` | `true` |

Enables/disables [repo-driven permission syncing](/docs/features/permission-syncing#how-it-works). Only applies when `PERMISSION_SYNC_ENABLED` is `true`.

| | `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` **(deprecated)** | `false` |

Deprecated. Use `PERMISSION_SYNC_ENABLED` instead.

| | `AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING` | `true` |

When enabled, different SSO accounts with the same email address will automatically be linked.

| +| `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` | `false` |

When enabled, only organization owners can create API keys. Non-owner members will receive a `403` error if they attempt to create one.

| +| `EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS` **(deprecated)** | `false` |

Deprecated. Use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead.

| +| `DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS` | `false` |

When enabled, only organization owners can create or use API keys. Non-owner members will receive a `403` error if they attempt to create or authenticate with an API key. If you only want to restrict creation (not usage), use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead.

| ### Review Agent Environment Variables diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index a1b07cd3a..b08ef6ccc 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -246,9 +246,22 @@ const options = { SOURCEBOT_DEMO_EXAMPLES_PATH: z.string().optional(), + DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS: booleanSchema.default('false'), + + DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS: booleanSchema + .optional() + .transform(value => { + return value ?? ((process.env.EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS as 'true' | 'false') ?? 'false'); + }), + + /** + * @deprecated Use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead. + */ + EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS: booleanSchema.default('false'), + + // Experimental Environment Variables // @note: These environment variables are subject to change at any time and are not garunteed to be backwards compatible. - EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS: booleanSchema.default('false'), EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'), // @NOTE: Take care to update actions.ts when changing the name of this. EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN: z.string().optional(), diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index a0e7a3394..4961144a0 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -82,6 +82,19 @@ export const withAuth = async (fn: (userId: string, apiKeyHash: string | unde return notAuthenticated(); } + if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true') { + const membership = await prisma.userToOrg.findFirst({ + where: { userId: user.id }, + }); + if (membership?.role !== OrgRole.OWNER) { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.API_KEY_USAGE_DISABLED, + message: "API key usage is disabled for non-admin users.", + } satisfies ServiceError; + } + } + await prisma.apiKey.update({ where: { hash: apiKeyOrError.apiKey.hash, @@ -312,7 +325,7 @@ export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiK export const createApiKey = async (name: string, domain: string): Promise<{ key: string } | ServiceError> => sew(() => withAuth((userId) => withOrgMembership(userId, domain, async ({ org, userRole }) => { - if (env.EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS === 'true' && userRole !== OrgRole.OWNER) { + if ((env.DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS === 'true' || env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true') && userRole !== OrgRole.OWNER) { logger.error(`API key creation is disabled for non-admin users. User ${userId} is not an owner.`); return { statusCode: StatusCodes.FORBIDDEN, diff --git a/packages/web/src/app/[domain]/settings/apiKeys/apiKeysPage.tsx b/packages/web/src/app/[domain]/settings/apiKeys/apiKeysPage.tsx new file mode 100644 index 000000000..0d86a2b8e --- /dev/null +++ b/packages/web/src/app/[domain]/settings/apiKeys/apiKeysPage.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { createApiKey, getUserApiKeys } from "@/actions"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { isServiceError } from "@/lib/utils"; +import { Copy, Check, AlertTriangle, Loader2, Plus } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDomain } from "@/hooks/useDomain"; +import { useToast } from "@/components/hooks/use-toast"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { DataTable } from "@/components/ui/data-table"; +import { columns, ApiKeyColumnInfo } from "./columns"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +export function ApiKeysPage({ canCreateApiKey }: { canCreateApiKey: boolean }) { + const domain = useDomain(); + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + + const [apiKeys, setApiKeys] = useState<{ name: string; createdAt: Date; lastUsedAt: Date | null }[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [isCreatingKey, setIsCreatingKey] = useState(false); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + const [error, setError] = useState(null); + + const loadApiKeys = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const keys = await getUserApiKeys(domain); + if (isServiceError(keys)) { + setError("Failed to load API keys"); + toast({ + title: "Error", + description: "Failed to load API keys", + variant: "destructive", + }); + return; + } + setApiKeys(keys); + } catch (error) { + console.error(error); + setError("Failed to load API keys"); + toast({ + title: "Error", + description: "Failed to load API keys", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }, [domain, toast]); + + useEffect(() => { + loadApiKeys(); + }, [loadApiKeys]); + + const handleCreateApiKey = async () => { + if (!newKeyName.trim()) { + toast({ + title: "Error", + description: "API key name cannot be empty", + variant: "destructive", + }); + return; + } + + setIsCreatingKey(true); + try { + const result = await createApiKey(newKeyName.trim(), domain); + if (isServiceError(result)) { + toast({ + title: "Error", + description: `Failed to create API key: ${result.message}`, + variant: "destructive", + }); + captureEvent('wa_api_key_creation_fail', {}); + + return; + } + + setNewlyCreatedKey(result.key); + await loadApiKeys(); + captureEvent('wa_api_key_created', {}); + } catch (error) { + console.error(error); + toast({ + title: "Error", + description: `Failed to create API key: ${error}`, + variant: "destructive", + }); + captureEvent('wa_api_key_creation_fail', {}); + } finally { + setIsCreatingKey(false); + } + }; + + const handleCopyApiKey = () => { + if (!newlyCreatedKey) return; + + navigator.clipboard.writeText(newlyCreatedKey) + .then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }) + .catch(() => { + toast({ + title: "Error", + description: "Failed to copy API key to clipboard", + variant: "destructive", + }); + }); + }; + + const handleCloseDialog = () => { + setIsCreateDialogOpen(false); + setNewKeyName(""); + setNewlyCreatedKey(null); + setCopySuccess(false); + }; + + const tableData = useMemo(() => { + if (isLoading) return Array(4).fill(null).map(() => ({ + name: "", + createdAt: "", + lastUsedAt: null, + })); + + if (!apiKeys) return []; + + return apiKeys.map((key): ApiKeyColumnInfo => ({ + name: key.name, + createdAt: key.createdAt.toISOString(), + lastUsedAt: key.lastUsedAt?.toISOString() ?? null, + })).sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + }, [apiKeys, isLoading]); + + const tableColumns = useMemo(() => { + if (isLoading) { + return columns().map((column) => { + if ('accessorKey' in column && column.accessorKey === "name") { + return { + ...column, + cell: () => ( +
+ {/* Icon skeleton */} + {/* Name skeleton */} +
+ ), + } + } + + return { + ...column, + cell: () => , + } + }) + } + + return columns(); + }, [isLoading]); + + if (error) { + return
Error loading API keys
; + } + + return ( +
+
+
+

API Keys

+

+ Create and manage API keys for programmatic access to Sourcebot. All API keys are scoped to the user who created them. +

+
+ + + + {!canCreateApiKey && ( + + API key creation is restricted. + + )} + + + + + + + + + {newlyCreatedKey ? 'Your New API Key' : 'Create API Key'} + + + {newlyCreatedKey ? ( +
+
+ +

+ This is the only time you'll see this API key. Make sure to copy it now. +

+
+ +
+
+ {newlyCreatedKey} +
+ +
+
+ ) : ( +
+ setNewKeyName(e.target.value)} + placeholder="Enter a name for your API key" + className="mb-2" + /> +
+ )} + + + {newlyCreatedKey ? ( + + ) : ( + <> + + + + )} + +
+
+
+
+
+
+
+ + +
+ ); +} diff --git a/packages/web/src/app/[domain]/settings/apiKeys/layout.tsx b/packages/web/src/app/[domain]/settings/apiKeys/layout.tsx new file mode 100644 index 000000000..9a3a2f8fa --- /dev/null +++ b/packages/web/src/app/[domain]/settings/apiKeys/layout.tsx @@ -0,0 +1,42 @@ +import { getMe } from "@/actions"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { notFound } from "next/navigation"; +import { isServiceError } from "@/lib/utils"; +import { OrgRole } from "@sourcebot/db"; +import { getOrgFromDomain } from "@/data/org"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; +import { env } from "@sourcebot/shared"; + +export default async function ApiKeysLayout({ children, params }: { children: React.ReactNode, params: Promise<{ domain: string }> }) { + const { domain } = await params; + + const org = await getOrgFromDomain(domain); + if (!org) { + throw new ServiceErrorException({ + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.ORG_NOT_FOUND, + message: "Organization not found", + }); + } + + const me = await getMe(); + if (isServiceError(me)) { + throw new ServiceErrorException(me); + } + + const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; + if (!userRoleInOrg) { + throw new ServiceErrorException({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.UNEXPECTED_ERROR, + message: "User role not found", + }); + } + + if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true' && userRoleInOrg !== OrgRole.OWNER) { + return notFound(); + } + + return children; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/apiKeys/page.tsx b/packages/web/src/app/[domain]/settings/apiKeys/page.tsx index 9940beb9f..37f6f8924 100644 --- a/packages/web/src/app/[domain]/settings/apiKeys/page.tsx +++ b/packages/web/src/app/[domain]/settings/apiKeys/page.tsx @@ -1,269 +1,21 @@ -'use client'; - -import { createApiKey, getUserApiKeys } from "@/actions"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; +import { getMe } from "@/actions"; import { isServiceError } from "@/lib/utils"; -import { Copy, Check, AlertTriangle, Loader2, Plus } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useDomain } from "@/hooks/useDomain"; -import { useToast } from "@/components/hooks/use-toast"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -import { DataTable } from "@/components/ui/data-table"; -import { columns, ApiKeyColumnInfo } from "./columns"; -import { Skeleton } from "@/components/ui/skeleton"; - -export default function ApiKeysPage() { - const domain = useDomain(); - const { toast } = useToast(); - const captureEvent = useCaptureEvent(); - - const [apiKeys, setApiKeys] = useState<{ name: string; createdAt: Date; lastUsedAt: Date | null }[]>([]); - const [isLoading, setIsLoading] = useState(true); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [newKeyName, setNewKeyName] = useState(""); - const [isCreatingKey, setIsCreatingKey] = useState(false); - const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); - const [copySuccess, setCopySuccess] = useState(false); - const [error, setError] = useState(null); - - const loadApiKeys = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - const keys = await getUserApiKeys(domain); - if (isServiceError(keys)) { - setError("Failed to load API keys"); - toast({ - title: "Error", - description: "Failed to load API keys", - variant: "destructive", - }); - return; - } - setApiKeys(keys); - } catch (error) { - console.error(error); - setError("Failed to load API keys"); - toast({ - title: "Error", - description: "Failed to load API keys", - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }, [domain, toast]); - - useEffect(() => { - loadApiKeys(); - }, [loadApiKeys]); - - const handleCreateApiKey = async () => { - if (!newKeyName.trim()) { - toast({ - title: "Error", - description: "API key name cannot be empty", - variant: "destructive", - }); - return; - } - - setIsCreatingKey(true); - try { - const result = await createApiKey(newKeyName.trim(), domain); - if (isServiceError(result)) { - toast({ - title: "Error", - description: `Failed to create API key: ${result.message}`, - variant: "destructive", - }); - captureEvent('wa_api_key_creation_fail', {}); - - return; - } - - setNewlyCreatedKey(result.key); - await loadApiKeys(); - captureEvent('wa_api_key_created', {}); - } catch (error) { - console.error(error); - toast({ - title: "Error", - description: `Failed to create API key: ${error}`, - variant: "destructive", - }); - captureEvent('wa_api_key_creation_fail', {}); - } finally { - setIsCreatingKey(false); - } - }; - - const handleCopyApiKey = () => { - if (!newlyCreatedKey) return; - - navigator.clipboard.writeText(newlyCreatedKey) - .then(() => { - setCopySuccess(true); - setTimeout(() => setCopySuccess(false), 2000); - }) - .catch(() => { - toast({ - title: "Error", - description: "Failed to copy API key to clipboard", - variant: "destructive", - }); - }); - }; - - const handleCloseDialog = () => { - setIsCreateDialogOpen(false); - setNewKeyName(""); - setNewlyCreatedKey(null); - setCopySuccess(false); - }; - - const tableData = useMemo(() => { - if (isLoading) return Array(4).fill(null).map(() => ({ - name: "", - createdAt: "", - lastUsedAt: null, - })); - - if (!apiKeys) return []; - - return apiKeys.map((key): ApiKeyColumnInfo => ({ - name: key.name, - createdAt: key.createdAt.toISOString(), - lastUsedAt: key.lastUsedAt?.toISOString() ?? null, - })).sort((a, b) => { - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }); - }, [apiKeys, isLoading]); - - const tableColumns = useMemo(() => { - if (isLoading) { - return columns().map((column) => { - if ('accessorKey' in column && column.accessorKey === "name") { - return { - ...column, - cell: () => ( -
- {/* Icon skeleton */} - {/* Name skeleton */} -
- ), - } - } - - return { - ...column, - cell: () => , - } - }) +import { env } from "@sourcebot/shared"; +import { OrgRole } from "@sourcebot/db"; +import { getOrgFromDomain } from "@/data/org"; +import { ApiKeysPage } from "./apiKeysPage"; + +export default async function Page({ params }: { params: Promise<{ domain: string }> }) { + const { domain } = await params; + + let canCreateApiKey = true; + if (env.DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS === 'true') { + const [org, me] = await Promise.all([getOrgFromDomain(domain), getMe()]); + if (org && !isServiceError(me)) { + const role = me.memberships.find((m) => m.id === org.id)?.role; + canCreateApiKey = role === OrgRole.OWNER; } - - return columns(); - }, [isLoading]); - - if (error) { - return
Error loading API keys
; } - return ( -
-
-
-

API Keys

-

- Create and manage API keys for programmatic access to Sourcebot. All API keys are scoped to the user who created them. -

-
- - - - - - - - {newlyCreatedKey ? 'Your New API Key' : 'Create API Key'} - - - {newlyCreatedKey ? ( -
-
- -

- This is the only time you'll see this API key. Make sure to copy it now. -

-
- -
-
- {newlyCreatedKey} -
- -
-
- ) : ( -
- setNewKeyName(e.target.value)} - placeholder="Enter a name for your API key" - className="mb-2" - /> -
- )} - - - {newlyCreatedKey ? ( - - ) : ( - <> - - - - )} - -
-
-
- - -
- ); -} \ No newline at end of file + return ; +} diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 68b334131..adec18d13 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -10,7 +10,7 @@ import { getConnectionStats, getMe, getOrgAccountRequests } from "@/actions"; import { ServiceErrorException } from "@/lib/serviceError"; import { getOrgFromDomain } from "@/data/org"; import { OrgRole } from "@prisma/client"; -import { hasEntitlement } from "@sourcebot/shared"; +import { env, hasEntitlement } from "@sourcebot/shared"; interface LayoutProps { children: React.ReactNode; @@ -98,10 +98,12 @@ export default async function SettingsLayout( isNotificationDotVisible: connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0, } ] : []), - { - title: "API Keys", - href: `/${domain}/settings/apiKeys`, - }, + ...(env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'false' || userRoleInOrg === OrgRole.OWNER ? [ + { + title: "API Keys", + href: `/${domain}/settings/apiKeys`, + } + ] : []), { title: "Analytics", href: `/${domain}/settings/analytics`, diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 7658cf47a..a3e897eac 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -37,4 +37,5 @@ export enum ErrorCode { INVALID_GIT_REF = 'INVALID_GIT_REF', LAST_OWNER_CANNOT_BE_DEMOTED = 'LAST_OWNER_CANNOT_BE_DEMOTED', LAST_OWNER_CANNOT_BE_REMOVED = 'LAST_OWNER_CANNOT_BE_REMOVED', + API_KEY_USAGE_DISABLED = 'API_KEY_USAGE_DISABLED', } diff --git a/packages/web/src/withAuthV2.ts b/packages/web/src/withAuthV2.ts index 32300a1ba..e950829e4 100644 --- a/packages/web/src/withAuthV2.ts +++ b/packages/web/src/withAuthV2.ts @@ -1,5 +1,5 @@ import { prisma as __unsafePrisma, userScopedPrismaClientExtension } from "@/prisma"; -import { hashSecret, OAUTH_ACCESS_TOKEN_PREFIX, API_KEY_PREFIX, LEGACY_API_KEY_PREFIX } from "@sourcebot/shared"; +import { hashSecret, OAUTH_ACCESS_TOKEN_PREFIX, API_KEY_PREFIX, LEGACY_API_KEY_PREFIX, env } from "@sourcebot/shared"; import { ApiKey, Org, OrgRole, PrismaClient, UserWithAccounts } from "@sourcebot/db"; import { headers } from "next/headers"; import { auth } from "./auth"; @@ -67,7 +67,7 @@ export const withOptionalAuthV2 = async (fn: (params: OptionalAuthContext) => }; export const getAuthContext = async (): Promise => { - const user = await getAuthenticatedUser(); + const authResult = await getAuthenticatedUser(); const org = await __unsafePrisma.org.findUnique({ where: { @@ -79,6 +79,8 @@ export const getAuthContext = async (): Promise { +type AuthSource = 'session' | 'oauth' | 'api_key'; + +export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, source: AuthSource } | undefined> => { // First, check if we have a valid JWT session. const session = await auth(); if (session) { @@ -112,7 +130,7 @@ export const getAuthenticatedUser = async () => { } }); - return user ?? undefined; + return user ? { user, source: 'session' } : undefined; } // If not, check for a Bearer token in the Authorization header. @@ -137,7 +155,7 @@ export const getAuthenticatedUser = async () => { where: { hash }, data: { lastUsedAt: new Date() }, }); - return oauthToken.user; + return { user: oauthToken.user, source: 'oauth' }; } } @@ -153,7 +171,7 @@ export const getAuthenticatedUser = async () => { where: { hash: apiKey.hash }, data: { lastUsedAt: new Date() }, }); - return user; + return { user, source: 'api_key' }; } } } @@ -190,7 +208,7 @@ export const getAuthenticatedUser = async () => { }, }); - return user; + return { user, source: 'api_key' }; } return undefined; From 5f20e1cfb8dd8cd84f5b39bea42682aee4806f10 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 16 Mar 2026 13:11:46 -0700 Subject: [PATCH 2/4] chore: update CHANGELOG for #1007 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 339c50cc3..9028a50ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added AGENTS.md with Cursor Cloud development environment instructions. [#1001](https://github.com/sourcebot-dev/sourcebot/pull/1001) - Added support for configuring SMTP via individual environment variables (SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD) as an alternative to SMTP_CONNECTION_URL. [#1002](https://github.com/sourcebot-dev/sourcebot/pull/1002) +- Added `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` and `DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS` environment variables to restrict API key creation and usage to organization owners. [#1007](https://github.com/sourcebot-dev/sourcebot/pull/1007) ## [4.15.6] - 2026-03-13 From 24b941da7395d74d37e161483b53339b89fe1fda Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 16 Mar 2026 13:12:30 -0700 Subject: [PATCH 3/4] chore: add Changed entry to CHANGELOG for deprecated env var Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9028a50ad..0d0974597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for configuring SMTP via individual environment variables (SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD) as an alternative to SMTP_CONNECTION_URL. [#1002](https://github.com/sourcebot-dev/sourcebot/pull/1002) - Added `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` and `DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS` environment variables to restrict API key creation and usage to organization owners. [#1007](https://github.com/sourcebot-dev/sourcebot/pull/1007) +### Changed +- Deprecated `EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS` in favour of `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS`. The old variable will continue to work as a fallback. [#1007](https://github.com/sourcebot-dev/sourcebot/pull/1007) + ## [4.15.6] - 2026-03-13 ### Added From f5ff9ea31fa2f51b1f6b27e7ce6f879a00ae1245 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 16 Mar 2026 13:16:47 -0700 Subject: [PATCH 4/4] fix(web): update withAuthV2 tests for getAuthenticatedUser source field Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/withAuthV2.test.ts | 111 ++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 16 deletions(-) diff --git a/packages/web/src/withAuthV2.test.ts b/packages/web/src/withAuthV2.test.ts index f1bb6e40e..2eb5522e5 100644 --- a/packages/web/src/withAuthV2.test.ts +++ b/packages/web/src/withAuthV2.test.ts @@ -4,6 +4,8 @@ import { notAuthenticated } from './lib/serviceError'; import { getAuthContext, getAuthenticatedUser, withAuthV2, withOptionalAuthV2 } from './withAuthV2'; import { MOCK_API_KEY, MOCK_OAUTH_TOKEN, MOCK_ORG, MOCK_USER_WITH_ACCOUNTS, prisma } from './__mocks__/prisma'; import { OrgRole } from '@sourcebot/db'; +import { ErrorCode } from './lib/errorCodes'; +import { StatusCodes } from 'http-status-codes'; const mocks = vi.hoisted(() => { return { @@ -11,6 +13,7 @@ const mocks = vi.hoisted(() => { auth: vi.fn(async (): Promise => null), headers: vi.fn(async (): Promise => new Headers()), hasEntitlement: vi.fn((_entitlement: string) => false), + env: {} as Record, } }); @@ -40,7 +43,7 @@ vi.mock('@sourcebot/shared', () => ({ OAUTH_ACCESS_TOKEN_PREFIX: 'sboa_', API_KEY_PREFIX: 'sbk_', LEGACY_API_KEY_PREFIX: 'sourcebot-', - env: {} + env: mocks.env, })); // Test utility to set the mock session @@ -70,6 +73,8 @@ beforeEach(() => { vi.clearAllMocks(); mocks.auth.mockResolvedValue(null); mocks.headers.mockResolvedValue(new Headers()); + // Reset env flags between tests + Object.keys(mocks.env).forEach(key => delete mocks.env[key]); }); describe('getAuthenticatedUser', () => { @@ -80,9 +85,10 @@ describe('getAuthenticatedUser', () => { id: userId, }); setMockSession(createMockSession({ user: { id: 'test-user-id' } })); - const user = await getAuthenticatedUser(); - expect(user).not.toBeUndefined(); - expect(user?.id).toBe(userId); + const result = await getAuthenticatedUser(); + expect(result).not.toBeUndefined(); + expect(result?.user.id).toBe(userId); + expect(result?.source).toBe('session'); }); test('should return a user object if a valid api key is present', async () => { @@ -98,9 +104,10 @@ describe('getAuthenticatedUser', () => { }); setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' })); - const user = await getAuthenticatedUser(); - expect(user).not.toBeUndefined(); - expect(user?.id).toBe(userId); + const result = await getAuthenticatedUser(); + expect(result).not.toBeUndefined(); + expect(result?.user.id).toBe(userId); + expect(result?.source).toBe('api_key'); expect(prisma.apiKey.update).toHaveBeenCalledWith({ where: { hash: 'apikey', @@ -124,9 +131,10 @@ describe('getAuthenticatedUser', () => { }); setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sbk_apikey' })); - const user = await getAuthenticatedUser(); - expect(user).not.toBeUndefined(); - expect(user?.id).toBe(userId); + const result = await getAuthenticatedUser(); + expect(result).not.toBeUndefined(); + expect(result?.user.id).toBe(userId); + expect(result?.source).toBe('api_key'); expect(prisma.apiKey.update).toHaveBeenCalledWith({ where: { hash: 'apikey' }, data: { lastUsedAt: expect.any(Date) }, @@ -146,9 +154,10 @@ describe('getAuthenticatedUser', () => { }); setMockHeaders(new Headers({ 'Authorization': 'Bearer sourcebot-apikey' })); - const user = await getAuthenticatedUser(); - expect(user).not.toBeUndefined(); - expect(user?.id).toBe(userId); + const result = await getAuthenticatedUser(); + expect(result).not.toBeUndefined(); + expect(result?.user.id).toBe(userId); + expect(result?.source).toBe('api_key'); expect(prisma.apiKey.update).toHaveBeenCalledWith({ where: { hash: 'apikey', @@ -170,9 +179,10 @@ describe('getAuthenticatedUser', () => { mocks.hasEntitlement.mockReturnValue(true); prisma.oAuthToken.findUnique.mockResolvedValue(MOCK_OAUTH_TOKEN); setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); - const user = await getAuthenticatedUser(); - expect(user).not.toBeUndefined(); - expect(user?.id).toBe(MOCK_USER_WITH_ACCOUNTS.id); + const result = await getAuthenticatedUser(); + expect(result).not.toBeUndefined(); + expect(result?.user.id).toBe(MOCK_USER_WITH_ACCOUNTS.id); + expect(result?.source).toBe('oauth'); }); test('should update lastUsedAt when an OAuth Bearer token is used', async () => { @@ -380,6 +390,75 @@ describe('getAuthContext', () => { prisma: undefined, }); }); + + describe('DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS', () => { + test('should return a 403 service error when flag is enabled and a non-owner authenticates via api key', async () => { + mocks.env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS = 'true'; + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ ...MOCK_USER_WITH_ACCOUNTS, id: userId }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + role: OrgRole.MEMBER, + }); + prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); + setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' })); + + const authContext = await getAuthContext(); + expect(authContext).toStrictEqual({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.API_KEY_USAGE_DISABLED, + message: 'API key usage is disabled for non-admin users.', + }); + }); + + test('should allow an owner to authenticate via api key when flag is enabled', async () => { + mocks.env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS = 'true'; + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ ...MOCK_USER_WITH_ACCOUNTS, id: userId }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + role: OrgRole.OWNER, + }); + prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); + setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' })); + + const authContext = await getAuthContext(); + expect(authContext).toStrictEqual({ + user: { ...MOCK_USER_WITH_ACCOUNTS, id: userId }, + org: MOCK_ORG, + role: OrgRole.OWNER, + prisma: undefined, + }); + }); + + test('should allow a non-owner to authenticate via session when flag is enabled', async () => { + mocks.env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS = 'true'; + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ ...MOCK_USER_WITH_ACCOUNTS, id: userId }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + role: OrgRole.MEMBER, + }); + setMockSession(createMockSession({ user: { id: userId } })); + + const authContext = await getAuthContext(); + expect(authContext).toStrictEqual({ + user: { ...MOCK_USER_WITH_ACCOUNTS, id: userId }, + org: MOCK_ORG, + role: OrgRole.MEMBER, + prisma: undefined, + }); + }); + }); }); describe('withAuthV2', () => {