From 3728a35b8a9f72eea07b3db2700e81af6fecb628 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:25:56 +0000 Subject: [PATCH] feat: Add organization-wide API keys with uniform permissions - Add allProjects and allProjectsPermission fields to ApiKey model - Update Zod schemas to support new fields - Modify API key creation to handle all-projects mode - Enhance authentication to dynamically generate permissions for all org projects - Add UI toggle for 'All Projects Access' in AddApiKeyModal with permission level selection - Ensure newly created projects are automatically accessible with existing API keys - Maintain backward compatibility with environment-specific API keys This allows API keys to grant access to all current and future projects in an organization with a uniform permission level (read/write/manage). Co-Authored-By: syed.abid@earnestdata-analytics.in --- .../api-keys/components/add-api-key-modal.tsx | 82 +++++++++++++++++-- .../api-keys/components/edit-api-keys.tsx | 4 + .../settings/api-keys/lib/api-key.ts | 48 ++++++++++- .../settings/api-keys/types/api-keys.ts | 2 + packages/database/schema.prisma | 22 ++--- packages/database/zod/api-keys.ts | 4 + 6 files changed, 142 insertions(+), 20 deletions(-) diff --git a/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx b/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx index c733a1ce62db..6b7c88bd6050 100644 --- a/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx +++ b/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx @@ -35,6 +35,8 @@ interface AddApiKeyModalProps { label: string; environmentPermissions: Array<{ environmentId: string; permission: ApiKeyPermission }>; organizationAccess: TOrganizationAccess; + allProjects?: boolean; + allProjectsPermission?: ApiKeyPermission; }) => Promise; projects: TOrganizationProject[]; isCreatingAPIKey: boolean; @@ -97,6 +99,11 @@ export const AddApiKeyModal = ({ // Initialize with one permission by default const [selectedPermissions, setSelectedPermissions] = useState>({}); + const [allProjectsEnabled, setAllProjectsEnabled] = useState(false); + const [allProjectsPermission, setAllProjectsPermission] = useState( + ApiKeyPermission.read + ); + const projectOptions: ProjectOption[] = projects.map((project) => ({ id: project.id, name: project.name, @@ -160,26 +167,32 @@ export const AddApiKeyModal = ({ const submitAPIKey = async () => { const data = getValues(); - if (checkForDuplicatePermissions()) { + if (!allProjectsEnabled && checkForDuplicatePermissions()) { toast.error(t("environments.project.api_keys.duplicate_access")); return; } // Convert permissions to the format expected by the API - const environmentPermissions = Object.values(selectedPermissions).map((permission) => ({ - environmentId: permission.environmentId, - permission: permission.permission, - })); + const environmentPermissions = allProjectsEnabled + ? [] + : Object.values(selectedPermissions).map((permission) => ({ + environmentId: permission.environmentId, + permission: permission.permission, + })); await onSubmit({ label: data.label, environmentPermissions, organizationAccess: selectedOrganizationAccess, + allProjects: allProjectsEnabled, + allProjectsPermission: allProjectsEnabled ? allProjectsPermission : undefined, }); reset(); setSelectedPermissions({}); setSelectedOrganizationAccess(defaultOrganizationAccess); + setAllProjectsEnabled(false); + setAllProjectsPermission(ApiKeyPermission.read); }; // Get environment options for a project @@ -194,8 +207,8 @@ export const AddApiKeyModal = ({ return true; } - // Check if at least one project permission is set or one organization access toggle is ON - const hasProjectAccess = Object.keys(selectedPermissions).length > 0; + // Check if at least one project permission is set or one organization access toggle is ON or all projects is enabled + const hasProjectAccess = Object.keys(selectedPermissions).length > 0 || allProjectsEnabled; const hasOrganizationAccess = Object.values(selectedOrganizationAccess).some((accessGroup) => Object.values(accessGroup).some((value) => value === true) @@ -231,9 +244,57 @@ export const AddApiKeyModal = ({ /> +
+
+ + { + setAllProjectsEnabled(checked); + if (checked) { + setSelectedPermissions({}); + } + }} + /> +
+ {allProjectsEnabled && ( +
+ + + + + + + {permissionOptions.map((option) => ( + setAllProjectsPermission(option)}> + {option} + + ))} + + +

+ This API key will have {allProjectsPermission} access to all current and future projects + in this organization. +

+
+ )} +
+
-
+ {!allProjectsEnabled ? ( +
{/* Permission rows */} {Object.keys(selectedPermissions).map((key) => { const permissionIndex = parseInt(key.split("-")[1]); @@ -344,6 +405,11 @@ export const AddApiKeyModal = ({ + {t("environments.settings.api_keys.add_permission")}
+ ) : ( +

+ All projects access is enabled. Individual project permissions are not needed. +

+ )}
diff --git a/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx index da2034596e64..9a3d9b299a01 100644 --- a/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx +++ b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx @@ -65,6 +65,8 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje label: string; environmentPermissions: Array<{ environmentId: string; permission: ApiKeyPermission }>; organizationAccess: TOrganizationAccess; + allProjects?: boolean; + allProjectsPermission?: ApiKeyPermission; }): Promise => { setIsLoading(true); const createApiKeyResponse = await createApiKeyAction({ @@ -73,6 +75,8 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje label: data.label, environmentPermissions: data.environmentPermissions, organizationAccess: data.organizationAccess, + allProjects: data.allProjects, + allProjectsPermission: data.allProjectsPermission, }, }); diff --git a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts index d50e9d9025ac..24d9e7c72396 100644 --- a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts +++ b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts @@ -70,6 +70,15 @@ export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => { }, }, }, + organization: { + include: { + projects: { + include: { + environments: true, + }, + }, + }, + }, }, }); @@ -85,6 +94,31 @@ export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => { }, }); + if (apiKeyData.allProjects && apiKeyData.allProjectsPermission) { + const allEnvironments = apiKeyData.organization.projects.flatMap((project) => + project.environments.map((env) => ({ + id: apiKeyData.id + "-" + env.id, + apiKeyId: apiKeyData.id, + environmentId: env.id, + permission: apiKeyData.allProjectsPermission!, + createdAt: new Date(), + updatedAt: new Date(), + environment: { + ...env, + project: { + id: project.id, + name: project.name, + }, + }, + })) + ); + + return { + ...apiKeyData, + apiKeyEnvironments: allEnvironments, + }; + } + return apiKeyData; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -123,6 +157,8 @@ export const createApiKey = async ( apiKeyData: TApiKeyCreateInput & { environmentPermissions?: Array<{ environmentId: string; permission: ApiKeyPermission }>; organizationAccess: TOrganizationAccess; + allProjects?: boolean; + allProjectsPermission?: ApiKeyPermission; } ): Promise => { validateInputs([organizationId, ZId], [apiKeyData, ZApiKeyCreateInput]); @@ -131,7 +167,13 @@ export const createApiKey = async ( const hashedKey = hashApiKey(key); // Extract environmentPermissions from apiKeyData - const { environmentPermissions, organizationAccess, ...apiKeyDataWithoutPermissions } = apiKeyData; + const { + environmentPermissions, + organizationAccess, + allProjects, + allProjectsPermission, + ...apiKeyDataWithoutPermissions + } = apiKeyData; // Create the API key const result = await prisma.apiKey.create({ @@ -141,7 +183,9 @@ export const createApiKey = async ( createdBy: userId, organization: { connect: { id: organizationId } }, organizationAccess, - ...(environmentPermissions && environmentPermissions.length > 0 + allProjects: allProjects || false, + allProjectsPermission: allProjectsPermission || null, + ...(environmentPermissions && environmentPermissions.length > 0 && !allProjects ? { apiKeyEnvironments: { create: environmentPermissions.map((envPerm) => ({ diff --git a/apps/web/modules/organization/settings/api-keys/types/api-keys.ts b/apps/web/modules/organization/settings/api-keys/types/api-keys.ts index ef84af1550c0..a95c47a5ba3d 100644 --- a/apps/web/modules/organization/settings/api-keys/types/api-keys.ts +++ b/apps/web/modules/organization/settings/api-keys/types/api-keys.ts @@ -18,6 +18,8 @@ export const ZApiKeyCreateInput = ZApiKey.required({ .extend({ environmentPermissions: z.array(ZApiKeyEnvironmentPermission).optional(), organizationAccess: ZOrganizationAccess, + allProjects: z.boolean().optional(), + allProjectsPermission: z.nativeEnum(ApiKeyPermission).optional(), }); export type TApiKeyCreateInput = z.infer; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 6541dbe335bb..89902670c660 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -720,17 +720,19 @@ model Invite { /// @property lastUsedAt - Timestamp of last usage /// @property apiKeyEnvironments - Environments this key has access to model ApiKey { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - createdBy String? - lastUsedAt DateTime? - label String - hashedKey String @unique - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - apiKeyEnvironments ApiKeyEnvironment[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) + createdBy String? + lastUsedAt DateTime? + label String + hashedKey String @unique + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + apiKeyEnvironments ApiKeyEnvironment[] /// [OrganizationAccess] - organizationAccess Json @default("{}") + organizationAccess Json @default("{}") + allProjects Boolean @default(false) + allProjectsPermission ApiKeyPermission? @@index([organizationId]) } diff --git a/packages/database/zod/api-keys.ts b/packages/database/zod/api-keys.ts index b15871f13e35..cda106a78661 100644 --- a/packages/database/zod/api-keys.ts +++ b/packages/database/zod/api-keys.ts @@ -25,6 +25,8 @@ export const ZApiKey = z.object({ hashedKey: z.string(), organizationId: z.string().cuid2(), organizationAccess: ZOrganizationAccess, + allProjects: z.boolean(), + allProjectsPermission: z.nativeEnum(ApiKeyPermission).nullable(), }) satisfies z.ZodType; export const ZApiKeyCreateInput = z.object({ @@ -33,6 +35,8 @@ export const ZApiKeyCreateInput = z.object({ environmentIds: z.array(z.string().cuid2()), permissions: z.record(z.string().cuid2(), ZApiKeyPermission), createdBy: z.string(), + allProjects: z.boolean().optional(), + allProjectsPermission: ZApiKeyPermission.optional(), }); export const ZApiKeyEnvironmentCreateInput = z.object({