Skip to content

Commit cf85620

Browse files
feat: add env vars to restrict API key creation and usage to org owners (#1007)
* feat: add env vars to restrict API key creation and usage to org owners Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update CHANGELOG for #1007 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add Changed entry to CHANGELOG for deprecated env var Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): update withAuthV2 tests for getAuthenticatedUser source field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5c4b0ce commit cf85620

File tree

11 files changed

+510
-296
lines changed

11 files changed

+510
-296
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- Added AGENTS.md with Cursor Cloud development environment instructions. [#1001](https://github.com/sourcebot-dev/sourcebot/pull/1001)
1212
- 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)
13+
- 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)
14+
15+
### Changed
16+
- 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)
1317

1418
## [4.15.6] - 2026-03-13
1519

docs/docs/configuration/environment-variables.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ The following environment variables allow you to configure your Sourcebot deploy
5353
| `PERMISSION_SYNC_REPO_DRIVEN_ENABLED` | `true` | <p>Enables/disables [repo-driven permission syncing](/docs/features/permission-syncing#how-it-works). Only applies when `PERMISSION_SYNC_ENABLED` is `true`.</p> |
5454
| `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` **(deprecated)** | `false` | <p>Deprecated. Use `PERMISSION_SYNC_ENABLED` instead.</p> |
5555
| `AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING` | `true` | <p>When enabled, different SSO accounts with the same email address will automatically be linked.</p> |
56+
| `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` | `false` | <p>When enabled, only organization owners can create API keys. Non-owner members will receive a `403` error if they attempt to create one.</p> |
57+
| `EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS` **(deprecated)** | `false` | <p>Deprecated. Use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead.</p> |
58+
| `DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS` | `false` | <p>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.</p> |
5659

5760

5861
### Review Agent Environment Variables

packages/shared/src/env.server.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,22 @@ const options = {
246246

247247
SOURCEBOT_DEMO_EXAMPLES_PATH: z.string().optional(),
248248

249+
DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS: booleanSchema.default('false'),
250+
251+
DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS: booleanSchema
252+
.optional()
253+
.transform(value => {
254+
return value ?? ((process.env.EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS as 'true' | 'false') ?? 'false');
255+
}),
256+
257+
/**
258+
* @deprecated Use `DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS` instead.
259+
*/
260+
EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS: booleanSchema.default('false'),
261+
262+
249263
// Experimental Environment Variables
250264
// @note: These environment variables are subject to change at any time and are not garunteed to be backwards compatible.
251-
EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS: booleanSchema.default('false'),
252265
EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'),
253266
// @NOTE: Take care to update actions.ts when changing the name of this.
254267
EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN: z.string().optional(),

packages/web/src/actions.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | unde
8282
return notAuthenticated();
8383
}
8484

85+
if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true') {
86+
const membership = await prisma.userToOrg.findFirst({
87+
where: { userId: user.id },
88+
});
89+
if (membership?.role !== OrgRole.OWNER) {
90+
return {
91+
statusCode: StatusCodes.FORBIDDEN,
92+
errorCode: ErrorCode.API_KEY_USAGE_DISABLED,
93+
message: "API key usage is disabled for non-admin users.",
94+
} satisfies ServiceError;
95+
}
96+
}
97+
8598
await prisma.apiKey.update({
8699
where: {
87100
hash: apiKeyOrError.apiKey.hash,
@@ -312,7 +325,7 @@ export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiK
312325
export const createApiKey = async (name: string, domain: string): Promise<{ key: string } | ServiceError> => sew(() =>
313326
withAuth((userId) =>
314327
withOrgMembership(userId, domain, async ({ org, userRole }) => {
315-
if (env.EXPERIMENT_DISABLE_API_KEY_CREATION_FOR_NON_ADMIN_USERS === 'true' && userRole !== OrgRole.OWNER) {
328+
if ((env.DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS === 'true' || env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true') && userRole !== OrgRole.OWNER) {
316329
logger.error(`API key creation is disabled for non-admin users. User ${userId} is not an owner.`);
317330
return {
318331
statusCode: StatusCodes.FORBIDDEN,
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
'use client';
2+
3+
import { createApiKey, getUserApiKeys } from "@/actions";
4+
import { Button } from "@/components/ui/button";
5+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
6+
import { Input } from "@/components/ui/input";
7+
import { isServiceError } from "@/lib/utils";
8+
import { Copy, Check, AlertTriangle, Loader2, Plus } from "lucide-react";
9+
import { useCallback, useEffect, useMemo, useState } from "react";
10+
import { useDomain } from "@/hooks/useDomain";
11+
import { useToast } from "@/components/hooks/use-toast";
12+
import useCaptureEvent from "@/hooks/useCaptureEvent";
13+
import { DataTable } from "@/components/ui/data-table";
14+
import { columns, ApiKeyColumnInfo } from "./columns";
15+
import { Skeleton } from "@/components/ui/skeleton";
16+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
17+
18+
export function ApiKeysPage({ canCreateApiKey }: { canCreateApiKey: boolean }) {
19+
const domain = useDomain();
20+
const { toast } = useToast();
21+
const captureEvent = useCaptureEvent();
22+
23+
const [apiKeys, setApiKeys] = useState<{ name: string; createdAt: Date; lastUsedAt: Date | null }[]>([]);
24+
const [isLoading, setIsLoading] = useState(true);
25+
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
26+
const [newKeyName, setNewKeyName] = useState("");
27+
const [isCreatingKey, setIsCreatingKey] = useState(false);
28+
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
29+
const [copySuccess, setCopySuccess] = useState(false);
30+
const [error, setError] = useState<string | null>(null);
31+
32+
const loadApiKeys = useCallback(async () => {
33+
setIsLoading(true);
34+
setError(null);
35+
try {
36+
const keys = await getUserApiKeys(domain);
37+
if (isServiceError(keys)) {
38+
setError("Failed to load API keys");
39+
toast({
40+
title: "Error",
41+
description: "Failed to load API keys",
42+
variant: "destructive",
43+
});
44+
return;
45+
}
46+
setApiKeys(keys);
47+
} catch (error) {
48+
console.error(error);
49+
setError("Failed to load API keys");
50+
toast({
51+
title: "Error",
52+
description: "Failed to load API keys",
53+
variant: "destructive",
54+
});
55+
} finally {
56+
setIsLoading(false);
57+
}
58+
}, [domain, toast]);
59+
60+
useEffect(() => {
61+
loadApiKeys();
62+
}, [loadApiKeys]);
63+
64+
const handleCreateApiKey = async () => {
65+
if (!newKeyName.trim()) {
66+
toast({
67+
title: "Error",
68+
description: "API key name cannot be empty",
69+
variant: "destructive",
70+
});
71+
return;
72+
}
73+
74+
setIsCreatingKey(true);
75+
try {
76+
const result = await createApiKey(newKeyName.trim(), domain);
77+
if (isServiceError(result)) {
78+
toast({
79+
title: "Error",
80+
description: `Failed to create API key: ${result.message}`,
81+
variant: "destructive",
82+
});
83+
captureEvent('wa_api_key_creation_fail', {});
84+
85+
return;
86+
}
87+
88+
setNewlyCreatedKey(result.key);
89+
await loadApiKeys();
90+
captureEvent('wa_api_key_created', {});
91+
} catch (error) {
92+
console.error(error);
93+
toast({
94+
title: "Error",
95+
description: `Failed to create API key: ${error}`,
96+
variant: "destructive",
97+
});
98+
captureEvent('wa_api_key_creation_fail', {});
99+
} finally {
100+
setIsCreatingKey(false);
101+
}
102+
};
103+
104+
const handleCopyApiKey = () => {
105+
if (!newlyCreatedKey) return;
106+
107+
navigator.clipboard.writeText(newlyCreatedKey)
108+
.then(() => {
109+
setCopySuccess(true);
110+
setTimeout(() => setCopySuccess(false), 2000);
111+
})
112+
.catch(() => {
113+
toast({
114+
title: "Error",
115+
description: "Failed to copy API key to clipboard",
116+
variant: "destructive",
117+
});
118+
});
119+
};
120+
121+
const handleCloseDialog = () => {
122+
setIsCreateDialogOpen(false);
123+
setNewKeyName("");
124+
setNewlyCreatedKey(null);
125+
setCopySuccess(false);
126+
};
127+
128+
const tableData = useMemo(() => {
129+
if (isLoading) return Array(4).fill(null).map(() => ({
130+
name: "",
131+
createdAt: "",
132+
lastUsedAt: null,
133+
}));
134+
135+
if (!apiKeys) return [];
136+
137+
return apiKeys.map((key): ApiKeyColumnInfo => ({
138+
name: key.name,
139+
createdAt: key.createdAt.toISOString(),
140+
lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
141+
})).sort((a, b) => {
142+
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
143+
});
144+
}, [apiKeys, isLoading]);
145+
146+
const tableColumns = useMemo(() => {
147+
if (isLoading) {
148+
return columns().map((column) => {
149+
if ('accessorKey' in column && column.accessorKey === "name") {
150+
return {
151+
...column,
152+
cell: () => (
153+
<div className="flex items-center gap-2">
154+
<Skeleton className="h-4 w-4 rounded-md" /> {/* Icon skeleton */}
155+
<Skeleton className="h-4 w-48" /> {/* Name skeleton */}
156+
</div>
157+
),
158+
}
159+
}
160+
161+
return {
162+
...column,
163+
cell: () => <Skeleton className="h-4 w-24" />,
164+
}
165+
})
166+
}
167+
168+
return columns();
169+
}, [isLoading]);
170+
171+
if (error) {
172+
return <div>Error loading API keys</div>;
173+
}
174+
175+
return (
176+
<div className="flex flex-col gap-6">
177+
<div className="flex flex-row items-center justify-between">
178+
<div>
179+
<h3 className="text-lg font-medium">API Keys</h3>
180+
<p className="text-sm text-muted-foreground max-w-lg">
181+
Create and manage API keys for programmatic access to Sourcebot. All API keys are scoped to the user who created them.
182+
</p>
183+
</div>
184+
185+
<TooltipProvider>
186+
<Tooltip>
187+
{!canCreateApiKey && (
188+
<TooltipContent>
189+
API key creation is restricted.
190+
</TooltipContent>
191+
)}
192+
<TooltipTrigger asChild>
193+
<span className={!canCreateApiKey ? "cursor-not-allowed" : undefined}>
194+
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
195+
<DialogTrigger asChild>
196+
<Button
197+
disabled={!canCreateApiKey}
198+
className={!canCreateApiKey ? "pointer-events-none" : undefined}
199+
onClick={() => {
200+
setNewlyCreatedKey(null);
201+
setNewKeyName("");
202+
setIsCreateDialogOpen(true);
203+
}}
204+
>
205+
<Plus className="h-4 w-4 mr-2" />
206+
Create API Key
207+
</Button>
208+
</DialogTrigger>
209+
<DialogContent className="sm:max-w-md">
210+
<DialogHeader>
211+
<DialogTitle>{newlyCreatedKey ? 'Your New API Key' : 'Create API Key'}</DialogTitle>
212+
</DialogHeader>
213+
214+
{newlyCreatedKey ? (
215+
<div className="space-y-4">
216+
<div className="flex items-center gap-2 p-3 border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 rounded-md text-yellow-700 dark:text-yellow-400">
217+
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
218+
<p className="text-sm">
219+
This is the only time you&apos;ll see this API key. Make sure to copy it now.
220+
</p>
221+
</div>
222+
223+
<div className="flex items-center space-x-2">
224+
<div className="bg-muted p-2 rounded-md text-sm flex-1 break-all font-mono">
225+
{newlyCreatedKey}
226+
</div>
227+
<Button
228+
size="icon"
229+
variant="outline"
230+
onClick={handleCopyApiKey}
231+
>
232+
{copySuccess ? (
233+
<Check className="h-4 w-4 text-green-500" />
234+
) : (
235+
<Copy className="h-4 w-4" />
236+
)}
237+
</Button>
238+
</div>
239+
</div>
240+
) : (
241+
<div className="py-4">
242+
<Input
243+
value={newKeyName}
244+
onChange={(e) => setNewKeyName(e.target.value)}
245+
placeholder="Enter a name for your API key"
246+
className="mb-2"
247+
/>
248+
</div>
249+
)}
250+
251+
<DialogFooter className="sm:justify-between">
252+
{newlyCreatedKey ? (
253+
<Button onClick={handleCloseDialog}>
254+
Done
255+
</Button>
256+
) : (
257+
<>
258+
<Button variant="outline" onClick={handleCloseDialog}>
259+
Cancel
260+
</Button>
261+
<Button
262+
onClick={handleCreateApiKey}
263+
disabled={isCreatingKey || !newKeyName.trim()}
264+
>
265+
{isCreatingKey && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
266+
Create
267+
</Button>
268+
</>
269+
)}
270+
</DialogFooter>
271+
</DialogContent>
272+
</Dialog>
273+
</span>
274+
</TooltipTrigger>
275+
</Tooltip>
276+
</TooltipProvider>
277+
</div>
278+
279+
<DataTable
280+
columns={tableColumns}
281+
data={tableData}
282+
searchKey="name"
283+
searchPlaceholder="Search API keys..."
284+
/>
285+
</div>
286+
);
287+
}

0 commit comments

Comments
 (0)