diff --git a/CHANGELOG.md b/CHANGELOG.md
index 339c50cc3..0d0974597 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,10 @@ 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)
+
+### 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
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.
+
+ )}
+
+
+
+
+ {
+ setNewlyCreatedKey(null);
+ setNewKeyName("");
+ setIsCreateDialogOpen(true);
+ }}
+ >
+
+ Create API Key
+
+
+
+
+ {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}
+
+
+ {copySuccess ? (
+
+ ) : (
+
+ )}
+
+
+
+ ) : (
+
+ setNewKeyName(e.target.value)}
+ placeholder="Enter a name for your API key"
+ className="mb-2"
+ />
+
+ )}
+
+
+ {newlyCreatedKey ? (
+
+ Done
+
+ ) : (
+ <>
+
+ Cancel
+
+
+ {isCreatingKey && }
+ Create
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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.
-
-
-
-
-
- {
- setNewlyCreatedKey(null);
- setNewKeyName("");
- setIsCreateDialogOpen(true);
- }}>
-
- Create API Key
-
-
-
-
- {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}
-
-
- {copySuccess ? (
-
- ) : (
-
- )}
-
-
-
- ) : (
-
- setNewKeyName(e.target.value)}
- placeholder="Enter a name for your API key"
- className="mb-2"
- />
-
- )}
-
-
- {newlyCreatedKey ? (
-
- Done
-
- ) : (
- <>
-
- Cancel
-
-
- {isCreatingKey && }
- Create
-
- >
- )}
-
-
-
-
-
-
-
- );
-}
\ 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.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', () => {
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;