review fixes

This commit is contained in:
x032205
2025-10-20 19:13:14 -04:00
parent 777b67555b
commit 179380f82b
12 changed files with 176 additions and 83 deletions
@@ -516,6 +516,7 @@ export enum EventType {
PAM_ACCOUNT_UPDATE = "pam-account-update", PAM_ACCOUNT_UPDATE = "pam-account-update",
PAM_ACCOUNT_DELETE = "pam-account-delete", PAM_ACCOUNT_DELETE = "pam-account-delete",
PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation", PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation",
PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED = "pam-account-credential-rotation-failed",
PAM_RESOURCE_LIST = "pam-resource-list", PAM_RESOURCE_LIST = "pam-resource-list",
PAM_RESOURCE_GET = "pam-resource-get", PAM_RESOURCE_GET = "pam-resource-get",
PAM_RESOURCE_CREATE = "pam-resource-create", PAM_RESOURCE_CREATE = "pam-resource-create",
@@ -3834,6 +3835,16 @@ interface PamAccountCredentialRotationEvent {
}; };
} }
interface PamAccountCredentialRotationFailedEvent {
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED;
metadata: {
accountName: string;
accountId: string;
resourceId: string;
errorMessage: string;
};
}
interface PamResourceListEvent { interface PamResourceListEvent {
type: EventType.PAM_RESOURCE_LIST; type: EventType.PAM_RESOURCE_LIST;
metadata: { metadata: {
@@ -4225,6 +4236,7 @@ export type Event =
| PamAccountUpdateEvent | PamAccountUpdateEvent
| PamAccountDeleteEvent | PamAccountDeleteEvent
| PamAccountCredentialRotationEvent | PamAccountCredentialRotationEvent
| PamAccountCredentialRotationFailedEvent
| PamResourceListEvent | PamResourceListEvent
| PamResourceGetEvent | PamResourceGetEvent
| PamResourceCreateEvent | PamResourceCreateEvent
@@ -18,7 +18,8 @@ export const pamAccountDALFactory = (db: TDbClient) => {
.select( .select(
// resource // resource
db.ref("name").withSchema(TableName.PamResource).as("resourceName"), db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
db.ref("resourceType").withSchema(TableName.PamResource) db.ref("resourceType").withSchema(TableName.PamResource),
db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource)
); );
if (filter) { if (filter) {
@@ -28,15 +29,18 @@ export const pamAccountDALFactory = (db: TDbClient) => {
const accounts = await query; const accounts = await query;
return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({ return accounts.map(
...account, ({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
resourceId, ...account,
resource: { resourceId,
id: resourceId, resource: {
name: resourceName, id: resourceId,
resourceType name: resourceName,
} resourceType,
})); encryptedRotationAccountCredentials
}
})
);
}; };
const findAccountsDueForRotation = async (tx?: Knex) => { const findAccountsDueForRotation = async (tx?: Knex) => {
@@ -330,7 +330,7 @@ export const pamAccountServiceFactory = ({
const decryptedAndPermittedAccounts: Array< const decryptedAndPermittedAccounts: Array<
TPamAccounts & { TPamAccounts & {
resource: Pick<TPamResources, "id" | "name" | "resourceType">; resource: Pick<TPamResources, "id" | "name" | "resourceType"> & { rotationCredentialsConfigured: boolean };
credentials: TPamAccountCredentials; credentials: TPamAccountCredentials;
} }
> = []; > = [];
@@ -360,7 +360,8 @@ export const pamAccountServiceFactory = ({
resource: { resource: {
id: account.resource.id, id: account.resource.id,
name: account.resource.name, name: account.resource.name,
resourceType: account.resource.resourceType resourceType: account.resource.resourceType,
rotationCredentialsConfigured: !!account.resource.encryptedRotationAccountCredentials
} }
}); });
} }
@@ -608,6 +609,25 @@ export const pamAccountServiceFactory = ({
} }
}); });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred";
await auditLogService.createAuditLog({
projectId: account.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED,
metadata: {
accountId: account.id,
accountName: account.name,
resourceId: account.resourceId,
errorMessage
}
}
});
logger.error(error, `Failed to rotate credentials for account ${account.id}`); logger.error(error, `Failed to rotate credentials for account ${account.id}`);
} }
}); });
@@ -204,13 +204,26 @@ export const pamResourceServiceFactory = ({
finalCredentials.password = decryptedCredentials.password; finalCredentials.password = decryptedCredentials.password;
} }
const validatedRotationAccountCredentials = await factory.validateAccountCredentials(finalCredentials); try {
const validatedRotationAccountCredentials = await factory.validateAccountCredentials(finalCredentials);
updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({ updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({
credentials: validatedRotationAccountCredentials, credentials: validatedRotationAccountCredentials,
projectId: resource.projectId, projectId: resource.projectId,
kmsService kmsService
}); });
} catch (err) {
if (
err instanceof BadRequestError &&
err.message === "Account credentials invalid: Username or password incorrect"
) {
throw new BadRequestError({
message: "Rotation Account credentials invalid: Username or password incorrect"
});
}
throw err;
}
} }
} }
@@ -43,11 +43,3 @@ Here’s how it works:
![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png) ![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png)
Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness. Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness.
## FAQ
<AccordionGroup>
<Accordion title="What resources does Infisical PAM currently support?">
Infisical PAM currently supports PostgreSQL, with support for more databases, RDP, Kubernetes, social media accounts, and more coming soon.
</Accordion>
</AccordionGroup>
+22
View File
@@ -12,6 +12,7 @@ export const pamKeys = {
session: () => [...pamKeys.all, "session"] as const, session: () => [...pamKeys.all, "session"] as const,
listResourceOptions: () => [...pamKeys.resource(), "options"] as const, listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId], listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
getResource: (resourceId?: string) => [...pamKeys.resource(), "get", resourceId],
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId], listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId] listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
@@ -68,6 +69,27 @@ export const useListPamResources = (
}); });
}; };
export const useGetPamResourceById = (
resourceId?: string,
options?: Omit<
UseQueryOptions<TPamResource, unknown, TPamResource, ReturnType<typeof pamKeys.getResource>>,
"queryKey" | "queryFn" | "enabled"
>
) => {
return useQuery({
queryKey: pamKeys.getResource(resourceId),
queryFn: async () => {
const { data } = await apiRequest.get<{ resource: TPamResource }>(
`/api/v1/pam/resources/${resourceId}`
);
return data.resource;
},
enabled: !!resourceId,
...options
});
};
// Accounts // Accounts
export const useListPamAccounts = ( export const useListPamAccounts = (
projectId: string, projectId: string,
@@ -9,6 +9,7 @@ export interface TBasePamAccount {
id: string; id: string;
name: string; name: string;
resourceType: PamResourceType; resourceType: PamResourceType;
rotationCredentialsConfigured: boolean;
}; };
name: string; name: string;
description?: string | null; description?: string | null;
@@ -65,7 +65,7 @@ const CreateForm = ({
switch (resourceType) { switch (resourceType) {
case PamResourceType.Postgres: case PamResourceType.Postgres:
return <PostgresAccountForm onSubmit={onSubmit} />; return <PostgresAccountForm onSubmit={onSubmit} resourceId={resourceId} />;
default: default:
throw new Error(`Unhandled resource: ${resourceType}`); throw new Error(`Unhandled resource: ${resourceType}`);
} }
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2"; import { Button, ModalClose } from "@app/components/v2";
import { TPostgresAccount } from "@app/hooks/api/pam"; import { TPostgresAccount, useGetPamResourceById } from "@app/hooks/api/pam";
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
import { SqlAccountFields } from "./shared/SqlAccountFields"; import { SqlAccountFields } from "./shared/SqlAccountFields";
@@ -12,6 +13,7 @@ import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountF
type Props = { type Props = {
account?: TPostgresAccount; account?: TPostgresAccount;
resourceId?: string;
onSubmit: (formData: FormData) => Promise<void>; onSubmit: (formData: FormData) => Promise<void>;
}; };
@@ -21,7 +23,7 @@ const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.s
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
export const PostgresAccountForm = ({ account, onSubmit }: Props) => { export const PostgresAccountForm = ({ account, resourceId, onSubmit }: Props) => {
const isUpdate = Boolean(account); const isUpdate = Boolean(account);
const form = useForm<FormData>({ const form = useForm<FormData>({
@@ -42,6 +44,18 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
formState: { isSubmitting, isDirty } formState: { isSubmitting, isDirty }
} = form; } = form;
const [rotationCredentialsConfigured, setRotationCredentialsConfigured] = useState(false);
const { data: resource } = useGetPamResourceById(resourceId);
useEffect(() => {
if (account) {
setRotationCredentialsConfigured(account.resource.rotationCredentialsConfigured);
} else {
setRotationCredentialsConfigured(!!resource?.rotationAccountCredentials);
}
}, [account]);
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<form <form
@@ -51,7 +65,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
> >
<GenericAccountFields /> <GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} /> <SqlAccountFields isUpdate={isUpdate} />
<RotateAccountFields /> <RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
<div className="mt-6 flex items-center"> <div className="mt-6 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -1,14 +1,19 @@
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { z } from "zod"; import { z } from "zod";
import { FormControl, Select, SelectItem, Switch } from "@app/components/v2"; import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/components/v2";
export const rotateAccountFieldsSchema = z.object({ export const rotateAccountFieldsSchema = z.object({
rotationEnabled: z.boolean(), rotationEnabled: z.boolean(),
rotationIntervalSeconds: z.number() rotationIntervalSeconds: z.number()
}); });
export const RotateAccountFields = () => { export const RotateAccountFields = ({
rotationCredentialsConfigured
}: {
rotationCredentialsConfigured: boolean;
}) => {
const { control, watch } = useFormContext<{ const { control, watch } = useFormContext<{
rotationEnabled: boolean; rotationEnabled: boolean;
rotationIntervalSeconds: number; rotationIntervalSeconds: number;
@@ -17,55 +22,63 @@ export const RotateAccountFields = () => {
const rotationEnabled = watch("rotationEnabled"); const rotationEnabled = watch("rotationEnabled");
return ( return (
<div className="flex h-9 items-center gap-3"> <Tooltip content="The resource which owns this account does not have rotation credentials configured.">
<Controller <div
control={control} className={twMerge(
name="rotationEnabled" "flex h-9 w-fit items-center gap-3",
defaultValue={false} !rotationCredentialsConfigured && "opacity-50"
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}
/>
</FormControl>
)} )}
/> >
<span className="text-sm">Rotate Credentials Every</span> <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 <Controller
name="rotationIntervalSeconds" name="rotationIntervalSeconds"
control={control} control={control}
defaultValue={2592000} defaultValue={2592000}
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
errorText={error?.message} errorText={error?.message}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
className="mb-0" className="mb-0"
>
<Select
value={value.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}
dropdownContainerStyle={{
width: "130px"
}}
> >
<SelectItem value="2592000">30 Days</SelectItem> <Select
<SelectItem value="604800">7 Days</SelectItem> value={value.toString()}
<SelectItem value="259200">3 Days</SelectItem> onValueChange={(val) => onChange(parseInt(val, 10))}
<SelectItem value="86400">1 Day</SelectItem> className="w-full border border-mineshaft-500 capitalize"
</Select> position="popper"
</FormControl> placeholder="Select an interval..."
)} dropdownContainerClassName="max-w-none"
/> isDisabled={!rotationEnabled || !rotationCredentialsConfigured}
</div> 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>
); );
}; };
@@ -17,7 +17,7 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
label="Username" label="Username"
> >
<Input {...field} /> <Input {...field} autoComplete="off" />
</FormControl> </FormControl>
)} )}
/> />
@@ -34,6 +34,7 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
<Input <Input
{...field} {...field}
type="password" type="password"
autoComplete="new-password"
onFocus={(e) => { onFocus={(e) => {
if (isUpdate && field.value === "******") { if (isUpdate && field.value === "******") {
field.onChange(""); field.onChange("");
@@ -16,11 +16,11 @@ export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="advance-settings" className="data-[state=open]:border-none"> <AccordionItem value="advance-settings" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm"> <AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Credential Rotation Account</div> <div className="order-1 ml-3">Rotation Account</div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0"> <AccordionContent childrenClassName="px-0 py-0">
<p className="mb-2 text-xs"> <p className="mb-2 text-xs">
Credentials to the high privilege account which will be used for rotating other accounts Credentials of the privileged account which will be used for rotating other accounts
under this resource under this resource
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -34,7 +34,7 @@ export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
label="Username" label="Username"
> >
<Input {...field} /> <Input {...field} autoComplete="off" />
</FormControl> </FormControl>
)} )}
/> />
@@ -51,6 +51,7 @@ export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
<Input <Input
{...field} {...field}
type="password" type="password"
autoComplete="new-password"
onFocus={(e) => { onFocus={(e) => {
if (isUpdate && field.value === "******") { if (isUpdate && field.value === "******") {
field.onChange(""); field.onChange("");