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
23 changes: 22 additions & 1 deletion apps/dokploy/__test__/api/api-key-name.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
});
});
12 changes: 12 additions & 0 deletions apps/dokploy/lib/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

52 changes: 49 additions & 3 deletions apps/dokploy/server/api/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
}),
Expand Down Expand Up @@ -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;
}),
Expand Down