Skip to content
Merged
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 @@ -29,6 +29,7 @@ const REQUIRED_DANGEROUS = [
"volumeBackups.restoreVolumeBackupWithLogs",
"user.createApiKey",
"user.deleteApiKey",
"user.revealApiKey",
"user.deleteAccount",
"user.remove",
"user.assignPermissions",
Expand Down
149 changes: 149 additions & 0 deletions apps/dokploy/__test__/users/api-key-reveal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
checkPermission: vi.fn(),
checkProtectedResourceAccess: vi.fn(),
checkSuperSessionAccess: vi.fn(),
findApiKeySecret: vi.fn(),
apikeyFindFirst: vi.fn(),
audit: vi.fn(),
}));

vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
createApiKey: vi.fn(),
findApiKeySecret: mocks.findApiKeySecret,
}));

vi.mock("@dokploy/server/db", () => ({
db: {
query: {
apikey: { findFirst: mocks.apikeyFindFirst },
},
},
}));

vi.mock("@dokploy/server/services/super-password", async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/super-password")
>()),
checkProtectedResourceAccess: mocks.checkProtectedResourceAccess,
// The router-wide super session middleware; covered by its own tests.
checkSuperSessionAccess: mocks.checkSuperSessionAccess,
}));

vi.mock("@dokploy/server/services/permission", () => ({
assertRoleAssignmentAllowed: vi.fn(),
checkPermission: mocks.checkPermission,
hasPermission: vi.fn(),
resolvePermissions: vi.fn(),
}));

vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(),
}));

vi.mock("@dokploy/server/utils/url/network", () => ({
fetchWithPublicEgress: vi.fn(),
}));

vi.mock("@/server/api/utils/audit", () => ({
audit: mocks.audit,
}));

vi.mock("@/server/api/utils/monitoring-access", () => ({
assertContainerMetricsServiceAccess: vi.fn(),
}));

const { userRouter } = await import("../../server/api/routers/user");

const callerFor = (session: Record<string, unknown> = {}) =>
userRouter.createCaller({
db: {},
req: {},
res: {},
session: {
id: "session-1",
userId: "user-1",
activeOrganizationId: "org-1",
...session,
},
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as never);

const storedKey = {
id: "key-1",
name: "ci-deploy",
referenceId: "user-1",
metadata: JSON.stringify({ organizationId: "org-1" }),
};

describe("user.revealApiKey", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkPermission.mockResolvedValue(undefined);
mocks.checkProtectedResourceAccess.mockResolvedValue(null);
mocks.checkSuperSessionAccess.mockResolvedValue(null);
mocks.apikeyFindFirst.mockResolvedValue(storedKey);
mocks.findApiKeySecret.mockResolvedValue("ci-deploy_secret-value");
});

it("returns the stored key inside an open super session and audits it", async () => {
await expect(
callerFor().revealApiKey({ apiKeyId: "key-1" }),
).resolves.toEqual({ key: "ci-deploy_secret-value" });
expect(mocks.audit).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "reveal", resourceId: "key-1" }),
);
});

it("asks for the super password when no super session is open", async () => {
mocks.checkProtectedResourceAccess.mockResolvedValue(
"super-session-required",
);
await expect(
callerFor().revealApiKey({ apiKeyId: "key-1" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.findApiKeySecret).not.toHaveBeenCalled();
});

it("is never available to API key sessions", async () => {
await expect(
callerFor({ authMethod: "api-key" }).revealApiKey({ apiKeyId: "key-1" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.findApiKeySecret).not.toHaveBeenCalled();
});

it("refuses keys of other users", async () => {
mocks.apikeyFindFirst.mockResolvedValue({
...storedKey,
referenceId: "user-2",
});
await expect(
callerFor().revealApiKey({ apiKeyId: "key-1" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.findApiKeySecret).not.toHaveBeenCalled();
});

it("refuses keys bound to another organization", async () => {
mocks.apikeyFindFirst.mockResolvedValue({
...storedKey,
metadata: JSON.stringify({ organizationId: "org-2" }),
});
await expect(
callerFor().revealApiKey({ apiKeyId: "key-1" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("explains that keys created before the feature cannot be shown", async () => {
mocks.findApiKeySecret.mockResolvedValue(null);
await expect(
callerFor().revealApiKey({ apiKeyId: "key-1" }),
).rejects.toMatchObject({
code: "NOT_FOUND",
message: expect.stringContaining("before keys could be revealed"),
});
expect(mocks.audit).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ export const AddApiKey = () => {
<DialogHeader>
<DialogTitle>API Key Generated Successfully</DialogTitle>
<DialogDescription>
Please copy your API key now. You won't be able to see it again!
Copy your API key now. You can reveal it again later from this
page with your super password.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
Expand Down
105 changes: 105 additions & 0 deletions apps/dokploy/components/dashboard/settings/api/reveal-api-key.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import copy from "copy-to-clipboard";
import { Eye } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";

interface Props {
apiKeyId: string;
name: string | null;
revealable: boolean;
}

export const RevealApiKey = ({ apiKeyId, name, revealable }: Props) => {
const [key, setKey] = useState<string | null>(null);
const { mutateAsync, isPending } = api.user.revealApiKey.useMutation();

const reveal = async () => {
try {
const result = await mutateAsync({ apiKeyId });
setKey(result.key);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error revealing API key",
);
}
};

return (
<>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
{/* Disabled buttons swallow pointer events, so the tooltip needs a wrapper. */}
<span>
<Button
variant="ghost"
size="icon"
isLoading={isPending}
disabled={!revealable}
onClick={reveal}
>
<Eye className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{revealable
? "Show key (requires the super password)"
: "This key was created before keys could be revealed"}
</TooltipContent>
</Tooltip>
</TooltipProvider>

<Dialog
open={key !== null}
onOpenChange={(open) => !open && setKey(null)}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{name || "API Key"}</DialogTitle>
<DialogDescription>
Anyone with this key can act as you. Don't share it.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<CodeEditor
className="font-mono text-sm break-all"
language="properties"
value={key ?? ""}
readOnly
/>
<div className="flex justify-end gap-3">
<Button
onClick={() => {
copy(key ?? "");
toast.success("API key copied to clipboard");
}}
>
Copy to Clipboard
</Button>
<Button variant="outline" onClick={() => setKey(null)}>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
};
62 changes: 35 additions & 27 deletions apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { AddApiKey } from "./add-api-key";
import { RevealApiKey } from "./reveal-api-key";

export const ShowApiKeys = () => {
const { data, refetch } = api.user.get.useQuery();
Expand Down Expand Up @@ -89,34 +90,41 @@ export const ShowApiKeys = () => {
)}
</div>
</div>
<DialogAction
title="Delete API Key"
description="Are you sure you want to delete this API key? This action cannot be undone."
type="destructive"
onClick={async () => {
try {
await deleteApiKey({
apiKeyId: apiKey.id,
});
await refetch();
toast.success("API key deleted successfully");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Error deleting API key",
);
}
}}
>
<Button
variant="ghost"
size="icon"
isLoading={isLoadingDelete}
<div className="flex items-center gap-1">
<RevealApiKey
apiKeyId={apiKey.id}
name={apiKey.name}
revealable={apiKey.revealable}
/>
<DialogAction
title="Delete API Key"
description="Are you sure you want to delete this API key? This action cannot be undone."
type="destructive"
onClick={async () => {
try {
await deleteApiKey({
apiKeyId: apiKey.id,
});
await refetch();
toast.success("API key deleted successfully");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Error deleting API key",
);
}
}}
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
isLoading={isLoadingDelete}
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div>
</div>
))
Expand Down
7 changes: 7 additions & 0 deletions apps/dokploy/components/proprietary/audit-logs/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import {
ArrowUpDown,
Eye,
FileJson,
LogIn,
LogOut,
Expand Down Expand Up @@ -77,6 +78,12 @@ const ACTION_CONFIG: Record<
className:
"bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
},
reveal: {
label: "Revealed",
icon: Eye,
className:
"bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
},
};

const RESOURCE_LABELS: Record<string, string> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const ACTION_OPTIONS = [
{ value: "redeploy", label: "Redeployed" },
{ value: "login", label: "Login" },
{ value: "logout", label: "Logout" },
{ value: "reveal", label: "Revealed" },
];

const RESOURCE_OPTIONS = [
Expand Down Expand Up @@ -83,7 +84,8 @@ type AuditAction =
| "cancel"
| "redeploy"
| "login"
| "logout";
| "logout"
| "reveal";
type AuditResourceType =
| "project"
| "service"
Expand Down
7 changes: 7 additions & 0 deletions apps/dokploy/drizzle/0206_apikey_secret.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE "apikey_secret" (
"apikey_id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "apikey_secret" ADD CONSTRAINT "apikey_secret_apikey_id_apikey_id_fk" FOREIGN KEY ("apikey_id") REFERENCES "public"."apikey"("id") ON DELETE cascade ON UPDATE no action;
Loading
Loading