diff --git a/apps/dokploy/__test__/api/api-key-name.test.ts b/apps/dokploy/__test__/api/api-key-name.test.ts index 677a527575..c447bec73d 100644 --- a/apps/dokploy/__test__/api/api-key-name.test.ts +++ b/apps/dokploy/__test__/api/api-key-name.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys"; +import { + API_KEY_NAME_MAX_LENGTH, + apiKeyNameSchema, + canCreateApiKeyForAnotherUser, +} from "@/lib/api-keys"; describe("apiKeyNameSchema", () => { it("rejects an empty name", () => { @@ -24,3 +28,20 @@ describe("apiKeyNameSchema", () => { } }); }); + +describe("canCreateApiKeyForAnotherUser", () => { + it("allows owners and admins", () => { + expect(canCreateApiKeyForAnotherUser("owner")).toBe(true); + expect(canCreateApiKeyForAnotherUser("admin")).toBe(true); + }); + + it("refuses a plain member, who may still mint for themselves", () => { + expect(canCreateApiKeyForAnotherUser("member")).toBe(false); + }); + + it("refuses an absent role instead of defaulting to allowed", () => { + expect(canCreateApiKeyForAnotherUser(undefined)).toBe(false); + expect(canCreateApiKeyForAnotherUser(null)).toBe(false); + expect(canCreateApiKeyForAnotherUser("")).toBe(false); + }); +}); diff --git a/apps/dokploy/lib/api-keys.ts b/apps/dokploy/lib/api-keys.ts index b64e3e0a92..906e10d0f0 100644 --- a/apps/dokploy/lib/api-keys.ts +++ b/apps/dokploy/lib/api-keys.ts @@ -21,3 +21,15 @@ export const apiKeyNameSchema = z API_KEY_NAME_MAX_LENGTH, `Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`, ); + +/** + * Whether an organization role may create an API key on behalf of another + * member. Owners and admins may; everyone else may only mint for themselves. + * + * A key minted this way carries the TARGET user's permissions, so this is the + * one place that decision lives, kept pure so it can be unit tested. + */ +export const canCreateApiKeyForAnotherUser = ( + role?: string | null, +): boolean => role === "owner" || role === "admin"; + diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index b4782b1d29..76d7ec0a8a 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -35,7 +35,10 @@ import { TRPCError } from "@trpc/server"; import * as bcrypt from "bcrypt"; import { and, asc, eq, gt, ne } from "drizzle-orm"; import { z } from "zod"; -import { apiKeyNameSchema } from "@/lib/api-keys"; +import { + apiKeyNameSchema, + canCreateApiKeyForAnotherUser, +} from "@/lib/api-keys"; import { audit } from "@/server/api/utils/audit"; import { adminProcedure, @@ -49,6 +52,12 @@ const apiCreateApiKey = z.object({ name: apiKeyNameSchema, prefix: z.string().optional(), expiresIn: z.number().optional(), + /** + * Mint the key for another member of the organization instead of the + * caller. Restricted to organization owners and admins; the target must be + * a member of the same organization. Omit it to mint for yourself. + */ + userId: z.string().optional(), metadata: z.object({ organizationId: z.string(), }), @@ -553,12 +562,49 @@ export const userRouter = createTRPCRouter({ } } - const apiKey = await createApiKey(ctx.user.id, input); + const targetUserId = input.userId ?? ctx.user.id; + + if (targetUserId !== ctx.user.id) { + if (!canCreateApiKeyForAnotherUser(ctx.user.role)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Only organization owners and admins can create an API key for another user", + }); + } + + if (!input.metadata?.organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "metadata.organizationId is required when creating an API key for another user", + }); + } + + const targetMember = await db.query.member.findFirst({ + where: and( + eq(member.organizationId, input.metadata.organizationId), + eq(member.userId, targetUserId), + ), + }); + + if (!targetMember) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "The target user is not a member of this organization", + }); + } + } + + const apiKey = await createApiKey(targetUserId, input); await audit(ctx, { action: "create", resourceType: "user", resourceId: apiKey.id, - resourceName: input.name, + resourceName: + targetUserId === ctx.user.id + ? input.name + : `${input.name} (on behalf of ${targetUserId})`, }); return apiKey; }),