From 546263737140b88ee9c5d7bd6393d511121bae2b Mon Sep 17 00:00:00 2001 From: airflow-ommax <278046412+airflow-ommax@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:22:32 +0200 Subject: [PATCH] feat(api): allow owners and admins to create an API key for another member createApiKey already forwards a userId to better-auth, but the tRPC route hardcoded the caller, so a key could only ever be minted for the calling session's user. Automation that provisions users (SSO/SCIM-style onboarding) therefore had no way to bootstrap a per-user key, and the only workaround was sharing one admin key across everyone, which loses attribution and per-user revocation. Accept an optional userId. Minting for someone else requires the caller to be an organization owner or admin, requires metadata.organizationId, and verifies the target is a member of that organization. Omitting userId keeps the previous behaviour exactly, so this is backwards compatible. The audit entry records that the key was created on behalf of another user. The authorization decision is a pure helper in lib/api-keys.ts with unit tests, next to the existing api-key name validation. --- .../dokploy/__test__/api/api-key-name.test.ts | 23 +++++++- apps/dokploy/lib/api-keys.ts | 12 +++++ apps/dokploy/server/api/routers/user.ts | 52 +++++++++++++++++-- 3 files changed, 83 insertions(+), 4 deletions(-) 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; }),