Merge pull request #4685 from Infisical/ENG-3925

feat(pam): account credential rotation
This commit is contained in:
Sheen
2025-10-22 20:26:46 +08:00
committed by GitHub
45 changed files with 950 additions and 93 deletions
View File
+1
View File
@@ -0,0 +1 @@
export const UNCHANGED_PASSWORD_SENTINEL = "__INFISICAL_UNCHANGED__";
+29
View File
@@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TPamResourceOption } from "./types/resource-options";
import { PamResourceType } from "./enums";
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
export const pamKeys = {
@@ -12,6 +13,12 @@ export const pamKeys = {
session: () => [...pamKeys.all, "session"] as const,
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
getResource: (resourceType: string, resourceId: string) => [
...pamKeys.resource(),
"get",
resourceType,
resourceId
],
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
@@ -68,6 +75,28 @@ export const useListPamResources = (
});
};
export const useGetPamResourceById = (
resourceType?: PamResourceType,
resourceId?: string,
options?: Omit<
UseQueryOptions<TPamResource, unknown, TPamResource, ReturnType<typeof pamKeys.getResource>>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.getResource(resourceType || "", resourceId || ""),
queryFn: async () => {
const { data } = await apiRequest.get<{ resource: TPamResource }>(
`/api/v1/pam/resources/${resourceType}/${resourceId}`
);
return data.resource;
},
enabled: !!resourceId && !!resourceType && (options?.enabled ?? true),
...options
});
};
// Accounts
export const useListPamAccounts = (
projectId: string,
@@ -9,9 +9,13 @@ export interface TBasePamAccount {
id: string;
name: string;
resourceType: PamResourceType;
rotationCredentialsConfigured: boolean;
};
name: string;
description?: string | null;
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
lastRotatedAt?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource";
// Resources
export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & {
connectionDetails: TBaseSqlConnectionDetails;
rotationAccountCredentials?: TBaseSqlCredentials | null;
};
// Accounts
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -29,6 +30,8 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>;
export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
const [showPassword, setShowPassword] = useState(false);
const isUpdate = Boolean(auditLogStream);
const form = useForm<FormData>({
@@ -96,10 +99,10 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
>
<Input
{...field}
type="password"
placeholder="Bearer <token>"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={(e) => {
placeholder="Bearer <token>"
onFocus={() => {
if (
auditLogStream &&
auditLogStream.credentials.headers[i] &&
@@ -108,9 +111,9 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
) {
field.onChange("");
}
e.target.type = "text";
setShowPassword(true);
}}
onBlur={(e) => {
onBlur={() => {
if (
auditLogStream &&
auditLogStream.credentials.headers[i] &&
@@ -119,7 +122,7 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
) {
field.onChange("******");
}
e.target.type = "password";
setShowPassword(false);
}}
/>
</FormControl>
@@ -34,10 +34,11 @@ const CreateForm = ({
}: CreateFormProps) => {
const createPamAccount = useCreatePamAccount();
console.log({ folderId });
const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
formData: DiscriminativePick<
TPamAccount,
"name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds"
>
) => {
try {
const account = await createPamAccount.mutateAsync({
@@ -64,7 +65,13 @@ const CreateForm = ({
switch (resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm onSubmit={onSubmit} />;
return (
<PostgresAccountForm
onSubmit={onSubmit}
resourceId={resourceId}
resourceType={resourceType}
/>
);
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -74,7 +81,10 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
const updatePamAccount = useUpdatePamAccount();
const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
formData: DiscriminativePick<
TPamAccount,
"name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds"
>
) => {
try {
const updatedAccount = await updatePamAccount.mutateAsync({
@@ -1,26 +1,31 @@
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { TPostgresAccount } from "@app/hooks/api/pam";
import { PamResourceType, TPostgresAccount, useGetPamResourceById } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
import { SqlAccountFields } from "./shared/SqlAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
type Props = {
account?: TPostgresAccount;
resourceId?: string;
resourceType?: PamResourceType;
onSubmit: (formData: FormData) => Promise<void>;
};
const formSchema = genericAccountFieldsSchema.extend({
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
credentials: BaseSqlAccountSchema
});
type FormData = z.infer<typeof formSchema>;
export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => {
const isUpdate = Boolean(account);
const form = useForm<FormData>({
@@ -30,7 +35,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
...account,
credentials: {
...account.credentials,
password: "******"
password: UNCHANGED_PASSWORD_SENTINEL
}
}
: undefined
@@ -41,6 +46,20 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
formState: { isSubmitting, isDirty }
} = form;
const [rotationCredentialsConfigured, setRotationCredentialsConfigured] = useState(false);
const { data: resource } = useGetPamResourceById(resourceType, resourceId, {
enabled: !account && !!resourceId && !!resourceType
});
useEffect(() => {
if (account) {
setRotationCredentialsConfigured(account.resource.rotationCredentialsConfigured);
} else {
setRotationCredentialsConfigured(!!resource?.rotationAccountCredentials);
}
}, [account, resource]);
return (
<FormProvider {...form}>
<form
@@ -50,6 +69,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
>
<GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} />
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
@@ -0,0 +1,87 @@
import { Controller, useFormContext } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/components/v2";
export const rotateAccountFieldsSchema = z.object({
rotationEnabled: z.boolean(),
rotationIntervalSeconds: z.number().nullable().optional()
});
export const RotateAccountFields = ({
rotationCredentialsConfigured
}: {
rotationCredentialsConfigured: boolean;
}) => {
const { control, watch } = useFormContext<{
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
}>();
const rotationEnabled = watch("rotationEnabled");
return (
<Tooltip
isDisabled={rotationCredentialsConfigured}
content="The resource which owns this account does not have rotation credentials configured."
>
<div
className={twMerge(
"flex h-9 w-fit items-center gap-3",
!rotationCredentialsConfigured && "opacity-50"
)}
>
<Controller
control={control}
name="rotationEnabled"
defaultValue={false}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-0">
<Switch
className="ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="rotation-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
isDisabled={!rotationCredentialsConfigured}
/>
</FormControl>
)}
/>
<span className="text-sm">Rotate Credentials Every</span>
<Controller
name="rotationIntervalSeconds"
control={control}
defaultValue={2592000}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className="mb-0"
>
<Select
value={(value || 2592000).toString()}
onValueChange={(val) => onChange(parseInt(val, 10))}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select an interval..."
dropdownContainerClassName="max-w-none"
isDisabled={!rotationEnabled || !rotationCredentialsConfigured}
dropdownContainerStyle={{
width: "130px"
}}
>
<SelectItem value="2592000">30 Days</SelectItem>
<SelectItem value="604800">7 Days</SelectItem>
<SelectItem value="259200">3 Days</SelectItem>
<SelectItem value="86400">1 Day</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</Tooltip>
);
};
@@ -1,9 +1,19 @@
import { Controller, useFormContext } from "react-hook-form";
import { useEffect, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { FormControl, Input } from "@app/components/v2";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control } = useFormContext();
const [showPassword, setShowPassword] = useState(false);
const password = useWatch({ control, name: "credentials.password" });
useEffect(() => {
if (password === UNCHANGED_PASSWORD_SENTINEL) {
setShowPassword(false);
}
}, [password]);
return (
<div className="flex gap-2">
@@ -17,7 +27,7 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
isError={Boolean(error?.message)}
label="Username"
>
<Input {...field} />
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
@@ -33,18 +43,19 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
>
<Input
{...field}
type="password"
onFocus={(e) => {
if (isUpdate && field.value === "******") {
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
e.target.type = "text";
setShowPassword(true);
}}
onBlur={(e) => {
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange("******");
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
e.target.type = "password";
setShowPassword(false);
}}
/>
</FormControl>
@@ -5,6 +5,10 @@ export const BaseSqlAccountSchema = z.object({
.string()
.trim()
.min(1, "Username required")
.max(255, "Username must be 255 characters or less"),
password: z.string().trim().min(1, "Password required")
.max(63, "Username must be 63 characters or less"),
password: z
.string()
.trim()
.min(1, "Password required")
.max(256, "Password must be 256 characters or less")
});
@@ -7,9 +7,11 @@ import {
faEllipsisV,
faFolder,
faRightToBracket,
faRotate,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -100,6 +102,12 @@ export const PamAccountRow = ({
</span>
</Badge>
)}
{account.lastRotatedAt && (
<Badge className="flex h-5 w-min items-center gap-1.5 bg-orange/20 whitespace-nowrap text-orange">
<FontAwesomeIcon icon={faRotate} />
<span>Rotated {formatDistance(new Date(), account.lastRotatedAt)} ago</span>
</Badge>
)}
</div>
</div>
</Td>
@@ -5,9 +5,12 @@ import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { PamResourceType, TPostgresResource } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { BaseSqlAccountSchema } from "@app/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas";
import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas";
import { SqlResourceFields } from "./shared/SqlResourceFields";
import { SqlRotateAccountFields } from "./shared/SqlRotateAccountFields";
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
type Props = {
@@ -17,7 +20,8 @@ type Props = {
const formSchema = genericResourceFieldsSchema.extend({
resourceType: z.literal(PamResourceType.Postgres),
connectionDetails: BaseSqlResourceSchema
connectionDetails: BaseSqlResourceSchema,
rotationAccountCredentials: BaseSqlAccountSchema.nullable().optional()
});
type FormData = z.infer<typeof formSchema>;
@@ -28,17 +32,27 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource ?? {
resourceType: PamResourceType.Postgres,
connectionDetails: {
host: "",
port: 5432,
database: "default",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
defaultValues: resource
? {
...resource,
rotationAccountCredentials: resource.rotationAccountCredentials
? {
...resource.rotationAccountCredentials,
password: UNCHANGED_PASSWORD_SENTINEL
}
: resource.rotationAccountCredentials
}
: {
resourceType: PamResourceType.Postgres,
connectionDetails: {
host: "",
port: 5432,
database: "default",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
});
const {
@@ -59,6 +73,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
selectedTabIndex={selectedTabIndex}
setSelectedTabIndex={setSelectedTabIndex}
/>
<SqlRotateAccountFields isUpdate={isUpdate} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
FormControl,
Input
} from "@app/components/v2";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control } = useFormContext();
const [showPassword, setShowPassword] = useState(false);
const password = useWatch({ control, name: "credentials.password" });
useEffect(() => {
if (password === UNCHANGED_PASSWORD_SENTINEL) {
setShowPassword(false);
}
}, [password]);
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advance-settings" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Rotation Account</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">
<p className="mb-2 text-xs">
Credentials of the privileged account which will be used for rotating other accounts
under this resource
</p>
<div className="flex gap-2">
<Controller
name="rotationAccountCredentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
name="rotationAccountCredentials.password"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
>
<Input
{...field}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
setShowPassword(true);
}}
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
setShowPassword(false);
}}
/>
</FormControl>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
};