Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ interface AddApiKeyModalProps {
label: string;
environmentPermissions: Array<{ environmentId: string; permission: ApiKeyPermission }>;
organizationAccess: TOrganizationAccess;
allProjects?: boolean;
allProjectsPermission?: ApiKeyPermission;
}) => Promise<void>;
projects: TOrganizationProject[];
isCreatingAPIKey: boolean;
Expand Down Expand Up @@ -97,6 +99,11 @@ export const AddApiKeyModal = ({
// Initialize with one permission by default
const [selectedPermissions, setSelectedPermissions] = useState<Record<string, PermissionRecord>>({});

const [allProjectsEnabled, setAllProjectsEnabled] = useState(false);
const [allProjectsPermission, setAllProjectsPermission] = useState<ApiKeyPermission>(
ApiKeyPermission.read
);

const projectOptions: ProjectOption[] = projects.map((project) => ({
id: project.id,
name: project.name,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -231,9 +244,57 @@ export const AddApiKeyModal = ({
/>
</div>

<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>All Projects Access</Label>
<Switch
checked={allProjectsEnabled}
onCheckedChange={(checked) => {
setAllProjectsEnabled(checked);
if (checked) {
setSelectedPermissions({});
}
}}
/>
</div>
{allProjectsEnabled && (
<div className="space-y-2">
<Label>Permission Level</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{allProjectsPermission}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{permissionOptions.map((option) => (
<DropdownMenuItem key={option} onClick={() => setAllProjectsPermission(option)}>
{option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<p className="text-sm text-slate-500">
This API key will have {allProjectsPermission} access to all current and future projects
in this organization.
</p>
</div>
)}
</div>

<div className="space-y-2">
<Label>{t("environments.project.api_keys.project_access")}</Label>
<div className="space-y-2">
{!allProjectsEnabled ? (
<div className="space-y-2">
{/* Permission rows */}
{Object.keys(selectedPermissions).map((key) => {
const permissionIndex = parseInt(key.split("-")[1]);
Expand Down Expand Up @@ -344,6 +405,11 @@ export const AddApiKeyModal = ({
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")}
</Button>
</div>
) : (
<p className="text-sm text-slate-500">
All projects access is enabled. Individual project permissions are not needed.
</p>
)}
</div>

<div className="space-y-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
setIsLoading(true);
const createApiKeyResponse = await createApiKeyAction({
Expand All @@ -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,
},
});

Expand Down
48 changes: 46 additions & 2 deletions apps/web/modules/organization/settings/api-keys/lib/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => {
},
},
},
organization: {
include: {
projects: {
include: {
environments: true,
Comment on lines +73 to +77

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load organization projects only for all-project API keys

getApiKeyWithPermissions now eagerly includes every project and environment in the organization for every API key lookup, even when the key is not allProjects. Because this function is used by v1/v2 authentication on request paths, this adds org-size-dependent query cost to normal API traffic and can cause significant latency for large organizations; fetch these relations only when allProjects is actually enabled.

Useful? React with 👍 / 👎.

},
},
},
},
},
});

Expand All @@ -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) {
Expand Down Expand Up @@ -123,6 +157,8 @@ export const createApiKey = async (
apiKeyData: TApiKeyCreateInput & {
environmentPermissions?: Array<{ environmentId: string; permission: ApiKeyPermission }>;
organizationAccess: TOrganizationAccess;
allProjects?: boolean;
allProjectsPermission?: ApiKeyPermission;
}
): Promise<TApiKeyWithEnvironmentPermission & { actualKey: string }> => {
validateInputs([organizationId, ZId], [apiKeyData, ZApiKeyCreateInput]);
Expand All @@ -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({
Expand All @@ -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) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ZApiKeyCreateInput>;
Expand Down
22 changes: 12 additions & 10 deletions packages/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Comment on lines +734 to +735

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a migration for new ApiKey all-project columns

This change introduces allProjects and allProjectsPermission on the Prisma model, and the same commit starts reading/writing those fields in API-key creation/auth paths, but no corresponding schema migration is added under packages/database/migration. In environments that apply migrations from that directory, the DB schema will remain unchanged and these Prisma queries will fail at runtime with missing-column errors.

Useful? React with 👍 / 👎.


@@index([organizationId])
}
Expand Down
4 changes: 4 additions & 0 deletions packages/database/zod/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiKey>;

export const ZApiKeyCreateInput = z.object({
Expand All @@ -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({
Expand Down