diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx index f079901c73..711ef5bc9b 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx @@ -164,6 +164,7 @@ export default async function APIKeyDetailPage(props: { > Back to API Keys listing + res.data.at(0)?.lastUsed ?? 0, + ); + return (
+
); diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-confirmation-dialog.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-confirmation-dialog.tsx new file mode 100644 index 0000000000..56b8c93ce1 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-confirmation-dialog.tsx @@ -0,0 +1,58 @@ +"use client"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type Props = { + open: boolean; + setOpen: (open: boolean) => void; + onClick: () => void; + lastUsed: number; +}; + +export function RerollConfirmationDialog({ open, setOpen, onClick, lastUsed }: Props) { + return ( + setOpen(o)}> + + + Reroll Key + + Make sure to replace it in your system before it expires. This action cannot be undone. + + + +

+ {lastUsed + ? `This key was used for the last time on ${new Date(lastUsed).toString()}` + : "This key was never used."} +

+ + + Warning + This action is not reversible. Please be certain. + + + + + + +
+
+ ); +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-key.tsx new file mode 100644 index 0000000000..329a093eef --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-key.tsx @@ -0,0 +1,251 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Form, FormDescription, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { trpc } from "@/lib/trpc/client"; +import { parseTrpcError } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { EncryptedKey, Key, Permission, Role } from "@unkey/db"; +import ms from "ms"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { RerollConfirmationDialog } from "./reroll-confirmation-dialog"; +import { RerollNewKeyDialog } from "./reroll-new-key-dialog"; + +type Props = { + apiId: string; + apiKey: Key & { + roles: Role[]; + permissions: Permission[]; + encrypted: EncryptedKey; + }; + lastUsed: number; +}; + +const EXPIRATION_OPTIONS = [ + { key: "now", value: "Now" }, + { key: "5m", value: "5 minutes" }, + { key: "30m", value: "30 minutes" }, + { key: "1h", value: "1 hour" }, + { key: "6h", value: "6 hours" }, + { key: "24h", value: "24 hours" }, + { key: "7d", value: "7 days" }, +]; + +const formSchema = z.object({ + expiresIn: z.coerce.string(), +}); + +export const RerollKey: React.FC = ({ apiKey, apiId, lastUsed }: Props) => { + const form = useForm>({ + resolver: zodResolver(formSchema), + reValidateMode: "onBlur", + defaultValues: { + expiresIn: "1h", + }, + }); + + const createKey = trpc.key.create.useMutation({ + onMutate() { + toast.success("Rerolling Key"); + }, + onError(err) { + console.error(err); + const message = parseTrpcError(err); + toast.error(message); + }, + }); + + const copyRolesToNewKey = trpc.rbac.connectRoleToKey.useMutation({ + onError(err) { + console.error(err); + const message = parseTrpcError(err); + toast.error(message); + }, + }); + + const copyPermissionsToNewKey = trpc.rbac.addPermissionToKey.useMutation({ + onError(err) { + console.error(err); + const message = parseTrpcError(err); + toast.error(message); + }, + }); + + const copyEncryptedToNewKey = trpc.key.update.encrypted.useMutation({ + onError(err) { + console.error(err); + const message = parseTrpcError(err); + toast.error(message); + }, + }); + + const updateDeletedAt = trpc.key.update.deletedAt.useMutation({ + onSuccess() { + toast.success("Key Rerolled."); + }, + onError(err) { + console.error(err); + const message = parseTrpcError(err); + toast.error(message); + }, + }); + + const updateExpiration = trpc.key.update.expiration.useMutation({ + onSuccess() { + toast.success("Key Rerolled."); + }, + onError(err) { + console.error(err); + const message = parseTrpcError(err); + toast.error(message); + }, + }); + + async function onSubmit(values: z.infer) { + const ratelimit = apiKey.ratelimitLimit + ? { + async: apiKey.ratelimitAsync ?? false, + duration: apiKey.ratelimitDuration ?? 0, + limit: apiKey.ratelimitLimit ?? 0, + } + : undefined; + + const refill = apiKey.refillInterval + ? { + interval: apiKey.refillInterval ?? "daily", + amount: apiKey.refillAmount ?? 0, + } + : undefined; + + const newKey = await createKey.mutateAsync({ + ...apiKey, + keyAuthId: apiKey.keyAuthId, + name: apiKey.name || undefined, + environment: apiKey.environment || undefined, + meta: apiKey.meta ? JSON.parse(apiKey.meta) : undefined, + expires: apiKey.expires?.getTime() ?? undefined, + remaining: apiKey.remaining ?? undefined, + identityId: apiKey.identityId ?? undefined, + ratelimit, + refill, + }); + + apiKey.roles?.forEach(async (role) => { + await copyRolesToNewKey.mutateAsync({ roleId: role.id, keyId: newKey.keyId }); + }); + + apiKey.permissions?.forEach(async (permission) => { + await copyPermissionsToNewKey.mutateAsync({ + permission: permission.name, + keyId: newKey.keyId, + }); + }); + + if (apiKey.encrypted) { + await copyEncryptedToNewKey.mutateAsync({ + encrypted: apiKey.encrypted.encrypted, + encryptiodKeyId: apiKey.encrypted.encryptionKeyId, + keyId: newKey.keyId, + }); + } + + if (values.expiresIn === "now") { + await updateDeletedAt.mutateAsync({ + keyId: apiKey.id, + deletedAt: new Date(Date.now()), + }); + } else { + const miliseconds = ms(values.expiresIn); + const expiration = new Date(Date.now() + miliseconds); + + await updateExpiration.mutateAsync({ + keyId: apiKey.id, + expiration, + enableExpiration: true, + }) + } + } + + const [confirmatioDialogOpen, setConfirmationDialogOpen] = useState(false); + const confirmationSubmit = () => { + setConfirmationDialogOpen(false); + onSubmit(form.getValues()); + }; + + return ( + <> + + +
+ + + + Reroll Key + + Rerolling creates a new identical key with the same configuration and automatically + expires the current one. Make sure to replace it in your system before it expires. + + + +
+ + ( + + + + Choose an optional delay period before the old key expires. + + + + )} + /> +
+
+ + + +
+
+ + + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-new-key-dialog.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-new-key-dialog.tsx new file mode 100644 index 0000000000..8bb3521b9d --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-new-key-dialog.tsx @@ -0,0 +1,76 @@ +"use client"; +import { CopyButton } from "@/components/dashboard/copy-button"; +import { VisibleButton } from "@/components/dashboard/visible-button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Code } from "@/components/ui/code"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { AlertCircle } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +type Props = { + newKey: + | { + keyId: `key_${string}`; + key: string; + } + | undefined; + apiId: string; + keyAuthId: string; +}; + +export function RerollNewKeyDialog({ newKey, apiId, keyAuthId }: Props) { + if (!newKey) { + return null; + } + + const split = newKey.key.split("_"); + const maskedKey = + split.length >= 2 ? `${split[0]}_${"*".repeat(split[1].length)}` : "*".repeat(split[0].length); + const [showKey, setShowKey] = useState(false); + + const [open, setOpen] = useState(Boolean(newKey)); + + return ( + setOpen(o)}> + + + Your New API Key + + + + This key is only shown once and can not be recovered + + Please pass it on to your user or store it somewhere safe. + + + +
{showKey ? newKey.key : maskedKey}
+
+ + +
+
+ + + + + + + + +
+
+ ); +} diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx index f463f13052..fa35e44dc9 100644 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx +++ b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx @@ -1,10 +1,8 @@ "use client"; -import { CopyButton } from "@/components/dashboard/copy-button"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { toast } from "@/components/ui/toaster"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { trpc } from "@/lib/trpc/client"; import { parseTrpcError } from "@/lib/utils"; import { Loader2 } from "lucide-react"; @@ -32,7 +30,7 @@ export const PermissionToggle: React.FC = ({ const router = useRouter(); const [optimisticChecked, setOptimisticChecked] = useState(checked); - const addPermission = trpc.rbac.addPermissionToRootKey.useMutation({ + const addPermission = trpc.rbac.addPermissionToKey.useMutation({ onMutate: () => { setOptimisticChecked(true); }, @@ -60,7 +58,7 @@ export const PermissionToggle: React.FC = ({ cancel: { label: "Undo", onClick: () => { - addPermission.mutate({ rootKeyId, permission: permissionName }); + addPermission.mutate({ keyId: rootKeyId, permission: permissionName }); }, }, }); @@ -98,7 +96,7 @@ export const PermissionToggle: React.FC = ({ } } else { if (!preventEnabling) { - addPermission.mutate({ rootKeyId, permission: permissionName }); + addPermission.mutate({ keyId: rootKeyId, permission: permissionName }); } } }} diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index f5873f80a3..95cb0cd5c2 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -9,7 +9,9 @@ import { createKey } from "./key/create"; import { createRootKey } from "./key/createRootKey"; import { deleteKeys } from "./key/delete"; import { deleteRootKeys } from "./key/deleteRootKey"; +import { updateKeyDeletedAt } from "./key/updateDeletedAt"; import { updateKeyEnabled } from "./key/updateEnabled"; +import { updateKeyEncrypted } from "./key/updateEncrypted"; import { updateKeyExpiration } from "./key/updateExpiration"; import { updateKeyMetadata } from "./key/updateMetadata"; import { updateKeyName } from "./key/updateName"; @@ -27,7 +29,7 @@ import { deleteNamespace } from "./ratelimit/deleteNamespace"; import { deleteOverride } from "./ratelimit/deleteOverride"; import { updateNamespaceName } from "./ratelimit/updateNamespaceName"; import { updateOverride } from "./ratelimit/updateOverride"; -import { addPermissionToRootKey } from "./rbac/addPermissionToRootKey"; +import { addPermissionToKey } from "./rbac/addPermissionToKey"; import { connectPermissionToRole } from "./rbac/connectPermissionToRole"; import { connectRoleToKey } from "./rbac/connectRoleToKey"; import { createPermission } from "./rbac/createPermission"; @@ -66,7 +68,9 @@ export const router = t.router({ create: createKey, delete: deleteKeys, update: t.router({ + deletedAt: updateKeyDeletedAt, enabled: updateKeyEnabled, + encrypted: updateKeyEncrypted, expiration: updateKeyExpiration, metadata: updateKeyMetadata, name: updateKeyName, @@ -112,7 +116,7 @@ export const router = t.router({ createIssue: createPlainIssue, }), rbac: t.router({ - addPermissionToRootKey: addPermissionToRootKey, + addPermissionToKey: addPermissionToKey, connectPermissionToRole: connectPermissionToRole, connectRoleToKey: connectRoleToKey, createPermission: createPermission, diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts index fd1b4b0bed..2ff5dd5a92 100644 --- a/apps/dashboard/lib/trpc/routers/key/create.ts +++ b/apps/dashboard/lib/trpc/routers/key/create.ts @@ -33,6 +33,7 @@ export const createKey = t.procedure .optional(), enabled: z.boolean().default(true), environment: z.string().optional(), + identityId: z.string().optional(), }), ) .mutation(async ({ input, ctx }) => { @@ -92,6 +93,7 @@ export const createKey = t.procedure deletedAt: null, enabled: input.enabled, environment: input.environment, + identityId: input.identityId, }) .catch((_err) => { throw new TRPCError({ diff --git a/apps/dashboard/lib/trpc/routers/key/updateDeletedAt.ts b/apps/dashboard/lib/trpc/routers/key/updateDeletedAt.ts new file mode 100644 index 0000000000..5a988c8d3f --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/key/updateDeletedAt.ts @@ -0,0 +1,71 @@ +import { db, eq, schema } from "@/lib/db"; +import { ingestAuditLogs } from "@/lib/tinybird"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { auth, t } from "../../trpc"; + +export const updateKeyDeletedAt = t.procedure + .use(auth) + .input( + z.object({ + keyId: z.string(), + deletedAt: z.date(), + }), + ) + .mutation(async ({ input, ctx }) => { + const key = await db.query.keys.findFirst({ + where: (table, { eq, and, isNull }) => + and(eq(table.id, input.keyId), isNull(table.deletedAt)), + with: { + workspace: true, + }, + }); + if (!key || key.workspace.tenantId !== ctx.tenant.id) { + throw new TRPCError({ + message: + "We are unable to find the the correct key. Please contact support using support@unkey.dev.", + code: "NOT_FOUND", + }); + } + + try { + await db.transaction(async (tx) => { + await tx + .update(schema.keys) + .set({ + deletedAt: input.deletedAt, + }) + .where(eq(schema.keys.id, key.id)); + + await ingestAuditLogs({ + workspaceId: key.workspace.id, + actor: { + type: "user", + id: ctx.user.id, + }, + event: "key.update", + description: `Changed the deletion date of ${key.id} to ${input.deletedAt.toUTCString()}`, + resources: [ + { + type: "key", + id: key.id, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }).catch((err) => { + tx.rollback(); + throw err; + }); + }); + } catch (_err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to update this key. Please contact support using support@unkey.dev", + }); + } + return true; + }); diff --git a/apps/dashboard/lib/trpc/routers/key/updateEncrypted.ts b/apps/dashboard/lib/trpc/routers/key/updateEncrypted.ts new file mode 100644 index 0000000000..5559679501 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/key/updateEncrypted.ts @@ -0,0 +1,70 @@ +import { db, schema } from "@/lib/db"; +import { ingestAuditLogs } from "@/lib/tinybird"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { auth, t } from "../../trpc"; + +export const updateKeyEncrypted = t.procedure + .use(auth) + .input( + z.object({ + keyId: z.string(), + encrypted: z.string(), + encryptiodKeyId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const key = await db.query.keys.findFirst({ + where: (table, { eq, isNull, and }) => + and(eq(table.id, input.keyId), isNull(table.deletedAt)), + with: { + workspace: true, + }, + }); + if (!key || key.workspace.tenantId !== ctx.tenant.id) { + throw new TRPCError({ + message: + "We are unable to find the correct key. Please contact support using support@unkey.dev.", + code: "NOT_FOUND", + }); + } + + const tuple = { + keyId: input.keyId, + encrypted: input.encrypted, + encryptionKeyId: input.encryptiodKeyId, + workspaceId: ctx.tenant.id, + }; + await db + .insert(schema.encryptedKeys) + .values({ ...tuple }) + .onDuplicateKeyUpdate({ set: { ...tuple } }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to update key encrypted. Please contact support using support@unkey.dev", + }); + }); + + await ingestAuditLogs({ + workspaceId: key.workspace.id, + actor: { + type: "user", + id: ctx.user.id, + }, + event: "key.update", + description: `Created a encrypted relation to ${key.id}`, + resources: [ + { + type: "key", + id: key.id, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + return true; + }); diff --git a/apps/dashboard/lib/trpc/routers/rbac/addPermissionToRootKey.ts b/apps/dashboard/lib/trpc/routers/rbac/addPermissionToKey.ts similarity index 89% rename from apps/dashboard/lib/trpc/routers/rbac/addPermissionToRootKey.ts rename to apps/dashboard/lib/trpc/routers/rbac/addPermissionToKey.ts index 2ff9505a4b..18d838beb6 100644 --- a/apps/dashboard/lib/trpc/routers/rbac/addPermissionToRootKey.ts +++ b/apps/dashboard/lib/trpc/routers/rbac/addPermissionToKey.ts @@ -6,11 +6,11 @@ import { z } from "zod"; import { auth, t } from "../../trpc"; import { upsertPermissions } from "../rbac"; -export const addPermissionToRootKey = t.procedure +export const addPermissionToKey = t.procedure .use(auth) .input( z.object({ - rootKeyId: z.string(), + keyId: z.string(), permission: z.string(), }), ) @@ -35,9 +35,9 @@ export const addPermissionToRootKey = t.procedure }); } - const rootKey = await db.query.keys.findFirst({ + const key = await db.query.keys.findFirst({ where: (table, { eq, and }) => - and(eq(table.forWorkspaceId, workspace.id), eq(table.id, input.rootKeyId)), + and(eq(table.forWorkspaceId, workspace.id), eq(table.id, input.keyId)), with: { permissions: { with: { @@ -46,7 +46,7 @@ export const addPermissionToRootKey = t.procedure }, }, }); - if (!rootKey) { + if (!key) { throw new TRPCError({ code: "NOT_FOUND", message: @@ -54,14 +54,14 @@ export const addPermissionToRootKey = t.procedure }); } - const { permissions, auditLogs } = await upsertPermissions(ctx, rootKey.workspaceId, [ + const { permissions, auditLogs } = await upsertPermissions(ctx, key.workspaceId, [ permission.data, ]); const p = permissions[0]; await db .insert(schema.keysPermissions) .values({ - keyId: rootKey.id, + keyId: key.id, permissionId: p.id, workspaceId: p.workspaceId, }) @@ -80,11 +80,11 @@ export const addPermissionToRootKey = t.procedure workspaceId: workspace.id, actor: { type: "user", id: ctx.user.id }, event: "authorization.connect_permission_and_key", - description: `Attached ${p.id} to ${rootKey.id}`, + description: `Attached ${p.id} to ${key.id}`, resources: [ { type: "key", - id: rootKey.id, + id: key.id, }, { type: "permission",