feat: add AWS IAM resource support with console access functionality

- Introduced AWS IAM resource type in the system, allowing users to create and manage AWS IAM accounts.
- Implemented AWS IAM resource forms and account forms for creating and updating IAM resources and accounts.
- Added functionality to generate AWS Console URLs for IAM accounts, enabling direct access to the AWS Console.
- Updated various components and hooks to handle AWS IAM-specific logic, including session expiration and access management.
- Enhanced the UI to reflect AWS IAM integration, including new modals and forms for user interaction.
This commit is contained in:
Victor Santos
2025-12-04 23:41:36 -03:00
parent a287f1f95e
commit b589ab3be4
44 changed files with 1618 additions and 163 deletions
+4 -2
View File
@@ -16,7 +16,8 @@ export enum PamResourceType {
CockroachDB = "cockroachdb",
Elasticsearch = "elasticsearch",
Snowflake = "snowflake",
DynamoDB = "dynamodb"
DynamoDB = "dynamodb",
AwsIam = "aws-iam"
}
export enum PamResourceOrderBy {
@@ -28,7 +29,8 @@ export enum PamSessionStatus {
Starting = "starting",
Active = "active",
Ended = "ended",
Terminated = "terminated"
Terminated = "terminated",
Expired = "expired"
}
// Accounts
+2 -1
View File
@@ -20,5 +20,6 @@ export const PAM_RESOURCE_TYPE_MAP: Record<
[PamResourceType.CockroachDB]: { name: "CockroachDB", image: "CockroachDB.png" },
[PamResourceType.Elasticsearch]: { name: "Elasticsearch", image: "Elastic.png" },
[PamResourceType.Snowflake]: { name: "Snowflake", image: "Snowflake.png" },
[PamResourceType.DynamoDB]: { name: "DynamoDB", image: "DynamoDB.png", size: 55 }
[PamResourceType.DynamoDB]: { name: "DynamoDB", image: "DynamoDB.png", size: 55 },
[PamResourceType.AwsIam]: { name: "AWS IAM", image: "Amazon Web Services.png" }
};
+35
View File
@@ -120,6 +120,41 @@ export const useDeletePamAccount = () => {
});
};
export type TAccessPamAccountDTO = {
accountId: string;
duration: string;
};
export type TAccessPamAccountResponse = {
sessionId: string;
resourceType: string;
consoleUrl?: string;
metadata?: Record<string, string | undefined>;
relayClientCertificate?: string;
relayClientPrivateKey?: string;
relayServerCertificateChain?: string;
gatewayClientCertificate?: string;
gatewayClientPrivateKey?: string;
gatewayServerCertificateChain?: string;
relayHost?: string;
};
export const useAccessPamAccount = () => {
return useMutation({
mutationFn: async ({ accountId, duration }: TAccessPamAccountDTO) => {
const { data } = await apiRequest.post<TAccessPamAccountResponse>(
"/api/v1/pam/accounts/access",
{
accountId,
duration
}
);
return data;
}
});
};
// Folders
export const useCreatePamFolder = () => {
const queryClient = useQueryClient();
@@ -0,0 +1,26 @@
import { PamResourceType } from "../enums";
import { TBasePamAccount } from "./base-account";
import { TBasePamResource } from "./base-resource";
export type TAwsIamConnectionDetails = {
region: string;
roleArn: string;
};
export type TAwsIamCredentials = {
targetRoleArn: string;
maxSessionDuration: number;
};
export type TAwsIamResource = Omit<TBasePamResource, "gatewayId"> & {
resourceType: PamResourceType.AwsIam;
gatewayId?: string | null;
connectionDetails: TAwsIamConnectionDetails;
};
export type TAwsIamAccount = Omit<
TBasePamAccount,
"rotationEnabled" | "rotationIntervalSeconds" | "lastRotatedAt"
> & {
credentials: TAwsIamCredentials;
};
+4 -2
View File
@@ -6,17 +6,19 @@ import {
PamResourceType,
PamSessionStatus
} from "../enums";
import { TAwsIamAccount, TAwsIamResource } from "./aws-iam-resource";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
import { TSSHAccount, TSSHResource } from "./ssh-resource";
export * from "./aws-iam-resource";
export * from "./mysql-resource";
export * from "./postgres-resource";
export * from "./ssh-resource";
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount;
export type TPamFolder = {
id: string;
@@ -1,12 +1,20 @@
import { useMemo, useState } from "react";
import { faCopy } from "@fortawesome/free-regular-svg-icons";
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { faExternalLink, faUpRightFromSquare, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { createNotification } from "@app/components/notifications";
import { FormLabel, IconButton, Input, Modal, ModalContent } from "@app/components/v2";
import { PamResourceType, TPamAccount } from "@app/hooks/api/pam";
import {
Button,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam";
type Props = {
account?: TPamAccount;
@@ -14,7 +22,108 @@ type Props = {
onOpenChange: (isOpen: boolean) => void;
};
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
const AwsIamAccessContent = ({
account,
onOpenChange
}: {
account: TPamAccount;
onOpenChange: (isOpen: boolean) => void;
}) => {
const [durationInput, setDurationInput] = useState("1h");
const accessPamAccount = useAccessPamAccount();
const parsedDuration = useMemo(() => {
try {
const milliseconds = ms(durationInput);
if (!milliseconds) return null;
const seconds = Math.floor(milliseconds / 1000);
// Min 15 minutes (900s), max 1 hour (3600s) due to AWS role chaining limitation
if (seconds < 900 || seconds > 3600) return null;
return seconds;
} catch {
return null;
}
}, [durationInput]);
const handleAccessConsole = async () => {
if (!parsedDuration) return;
try {
const response = await accessPamAccount.mutateAsync({
accountId: account.id,
duration: `${parsedDuration}s`
});
if (response.consoleUrl) {
// Open the AWS Console URL in a new tab
window.open(response.consoleUrl, "_blank", "noopener,noreferrer");
createNotification({
text: "AWS Console opened in new tab",
type: "success"
});
onOpenChange(false);
} else {
createNotification({
text: "Failed to generate AWS Console URL",
type: "error"
});
}
} catch {
createNotification({
text: "Failed to access AWS Console",
type: "error"
});
}
};
return (
<>
<FormControl
label="Session Duration"
helperText="Min 15m, max 1h (AWS role chaining limit). Examples: 30m, 1h"
isError={durationInput.length > 0 && !parsedDuration}
errorText="Invalid duration. Use format like 15m, 30m, 1h"
>
<Input
value={durationInput}
onChange={(e) => setDurationInput(e.target.value)}
placeholder="1h"
/>
</FormControl>
<div className="mb-4 rounded-sm border border-yellow-600/30 bg-yellow-600/10 p-3">
<div className="flex items-start gap-2">
<FontAwesomeIcon icon={faWarning} className="mt-0.5 text-yellow-500" />
<div className="text-xs text-yellow-500">
<strong>Important:</strong> AWS Console sessions cannot be terminated early. The session
remains active until the STS token expires. All activity is logged in AWS CloudTrail.
</div>
</div>
</div>
<Button
onClick={handleAccessConsole}
isLoading={accessPamAccount.isPending}
isDisabled={!parsedDuration}
colorSchema="secondary"
className="w-full"
leftIcon={<FontAwesomeIcon icon={faExternalLink} />}
>
Open AWS Console
</Button>
</>
);
};
const CliAccessContent = ({
account,
onOpenChange
}: {
account: TPamAccount;
onOpenChange: (isOpen: boolean) => void;
}) => {
const { protocol, hostname, port } = window.location;
const portSuffix = port && port !== "80" && port !== "443" ? `:${port}` : "";
const siteURL = `${protocol}//${hostname}${portSuffix}`;
@@ -76,56 +185,74 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
}
}, [account, cliDuration]);
return (
<>
<FormLabel
label="Duration"
tooltipText="The maximum duration of your session. Ex: 1h, 3w, 30d"
/>
<Input
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="permanent"
isError={!isDurationValid}
/>
<FormLabel label="CLI Command" className="mt-4" />
<div className="flex gap-2">
<Input value={command} isDisabled />
<IconButton
ariaLabel="copy"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(command);
createNotification({
text: "Command copied to clipboard",
type: "info"
});
onOpenChange(false);
}}
className="w-10"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<a
href="https://infisical.com/docs/cli/overview"
target="_blank"
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
rel="noreferrer"
>
<span>Install the Infisical CLI</span>
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
</>
);
};
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
if (!account) return null;
const isAwsIam = account.resource.resourceType === PamResourceType.AwsIam;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl pb-2"
title="Access Account"
subTitle={`Access ${account.name} using a CLI command.`}
subTitle={
isAwsIam
? `Access ${account.name} via AWS Console.`
: `Access ${account.name} using a CLI command.`
}
>
<FormLabel
label="Duration"
tooltipText="The maximum duration of your session. Ex: 1h, 3w, 30d"
/>
<Input
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="permanent"
isError={!isDurationValid}
/>
<FormLabel label="CLI Command" className="mt-4" />
<div className="flex gap-2">
<Input value={command} isDisabled />
<IconButton
ariaLabel="copy"
variant="outline_bg"
colorSchema="secondary"
onClick={() => {
navigator.clipboard.writeText(command);
createNotification({
text: "Command copied to clipboard",
type: "info"
});
onOpenChange(false);
}}
className="w-10"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</div>
<a
href="https://infisical.com/docs/cli/overview"
target="_blank"
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
rel="noreferrer"
>
<span>Install the Infisical CLI</span>
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
{isAwsIam ? (
<AwsIamAccessContent account={account} onOpenChange={onOpenChange} />
) : (
<CliAccessContent account={account} onOpenChange={onOpenChange} />
)}
</ModalContent>
</Modal>
);
@@ -0,0 +1,206 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
ModalClose
} from "@app/components/v2";
import { useProject } from "@app/context";
import { PamResourceType, TAwsIamAccount } from "@app/hooks/api/pam";
import { GenericAccountFields } from "./GenericAccountFields";
type Props = {
account?: TAwsIamAccount;
resourceId?: string;
resourceType?: PamResourceType;
onSubmit: (formData: FormData) => Promise<void>;
};
const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/;
const AwsIamCredentialsSchema = z.object({
targetRoleArn: z
.string()
.trim()
.min(1, "Target Role ARN is required")
.refine((val) => arnRoleRegex.test(val), {
message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'"
}),
// Max 1 hour (3600s) due to AWS role chaining limitation, min 15 min (900s)
maxSessionDuration: z.coerce
.number()
.min(900, "Minimum session duration is 900 seconds (15 minutes)")
.max(3600, "Maximum session duration is 3600 seconds (1 hour)")
.default(3600)
});
const genericAwsIamAccountFieldsSchema = z.object({
name: z.string().min(1, "Name is required").max(64, "Name must be at most 64 characters"),
description: z.string().max(512).optional().nullable()
});
const formSchema = genericAwsIamAccountFieldsSchema.extend({
credentials: AwsIamCredentialsSchema
});
type FormData = z.infer<typeof formSchema>;
export const AwsIamAccountForm = ({ account, onSubmit }: Props) => {
const isUpdate = Boolean(account);
const { projectId } = useProject();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: account ?? {
name: "",
description: "",
credentials: {
targetRoleArn: "",
maxSessionDuration: 3600
}
}
});
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<GenericAccountFields />
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">AWS IAM Configuration</h4>
<Controller
name="credentials.targetRoleArn"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-3"
helperText="The ARN of the IAM role that users will assume to access the AWS Console"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Target Role ARN"
>
<Input
{...field}
placeholder="arn:aws:iam::123456789012:role/infisical-pam-MyTargetRole"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
name="credentials.maxSessionDuration"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
helperText="In seconds. Min 900 (15m), max 3600 (1h) due to AWS role chaining limit."
errorText={error?.message}
isError={Boolean(error?.message)}
label="Session Duration (seconds)"
>
<Input {...field} type="number" placeholder="3600" />
</FormControl>
)}
/>
</div>
<Accordion type="single" collapsible className="mb-4 w-full bg-mineshaft-700">
<AccordionItem value="target-role-setup">
<AccordionTrigger>Target Role Setup</AccordionTrigger>
<AccordionContent>
<p className="mb-3 text-sm text-mineshaft-300">
The target role must have a trust policy that allows the Infisical PAM role to
assume it. If you used the{" "}
<code className="rounded bg-mineshaft-700 px-1 text-xs">infisical-pam-*</code>{" "}
naming convention, no additional changes are needed to the PAM role.
</p>
<p className="mb-2 text-sm font-medium text-mineshaft-200">
Target role trust policy:
</p>
<pre className="mb-3 max-h-45 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 text-xs whitespace-pre-wrap text-mineshaft-300">
{`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_PAM_ROLE_NAME>"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "${projectId}"
}
}
}]
}`}
</pre>
<p className="text-xs text-mineshaft-400">
<strong>Note:</strong> Replace{" "}
<code className="rounded bg-mineshaft-700 px-1">&lt;YOUR_ACCOUNT_ID&gt;</code> with
your AWS account ID and{" "}
<code className="rounded bg-mineshaft-700 px-1">&lt;YOUR_PAM_ROLE_NAME&gt;</code>{" "}
with the name of the PAM role you created (e.g.,{" "}
<code className="rounded bg-mineshaft-700 px-1">InfisicalPAMRole</code>). The
External ID <code className="rounded bg-mineshaft-700 px-1">{projectId}</code> is
your current project ID. If your target role name doesn&apos;t follow the{" "}
<code className="rounded bg-mineshaft-700 px-1">infisical-pam-*</code> pattern, you
must update the PAM role&apos;s permissions policy to include the target role ARN.
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="rounded-sm border border-yellow-600/30 bg-yellow-600/10 p-3">
<p className="text-xs text-yellow-500">
<strong>Note:</strong> While users cannot terminate AWS Console sessions directly,
administrators can revoke active sessions by using the{" "}
<a
href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-yellow-400"
>
Revoke Sessions
</a>{" "}
feature in the IAM console. All activity is logged in AWS CloudTrail.
</p>
</div>
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Account" : "Create Account"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -8,6 +8,7 @@ import {
import { DiscriminativePick } from "@app/types";
import { PamAccountHeader } from "../PamAccountHeader";
import { AwsIamAccountForm } from "./AwsIamAccountForm";
import { MySQLAccountForm } from "./MySQLAccountForm";
import { PostgresAccountForm } from "./PostgresAccountForm";
import { SshAccountForm } from "./SshAccountForm";
@@ -70,6 +71,10 @@ const CreateForm = ({
return (
<SshAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
);
case PamResourceType.AwsIam:
return (
<AwsIamAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
);
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -100,6 +105,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SshAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.AwsIam:
return <AwsIamAccountForm account={account as any} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
}
@@ -41,6 +41,7 @@ type Props = {
search: string;
isFlatView: boolean;
accountPath?: string;
isAccessLoading?: boolean;
};
export const PamAccountRow = ({
@@ -50,7 +51,8 @@ export const PamAccountRow = ({
onUpdate,
onDelete,
isFlatView,
accountPath
accountPath,
isAccessLoading
}: Props) => {
const { id, name } = account;
@@ -127,6 +129,8 @@ export const PamAccountRow = ({
leftIcon={<FontAwesomeIcon icon={faRightToBracket} />}
onClick={() => onAccess(account)}
size="xs"
isLoading={isAccessLoading}
isDisabled={isAccessLoading}
>
Access
</Button>
@@ -52,6 +52,8 @@ import {
PAM_RESOURCE_TYPE_MAP,
PamAccountOrderBy,
PamAccountView,
PamResourceType,
TPamAccount,
TPamFolder
} from "@app/hooks/api/pam";
import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries";
@@ -67,6 +69,7 @@ import { PamDeleteFolderModal } from "./PamDeleteFolderModal";
import { PamFolderRow } from "./PamFolderRow";
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
import { useAccessAwsIamAccount } from "./useAccessAwsIamAccount";
type PamAccountFilter = {
resourceIds: string[];
@@ -78,6 +81,7 @@ type Props = {
export const PamAccountsTable = ({ projectId }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
const { accessAwsIam, loadingAccountId } = useAccessAwsIamAccount();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"misc",
@@ -419,8 +423,14 @@ export const PamAccountsTable = ({ projectId }: Props) => {
search={search}
isFlatView={accountView === PamAccountView.Flat}
accountPath={account.folderId ? folderPaths[account.folderId] : undefined}
onAccess={(e) => {
handlePopUpOpen("accessAccount", e);
isAccessLoading={loadingAccountId === account.id}
onAccess={(e: TPamAccount) => {
// For AWS IAM, directly open console without modal
if (e.resource.resourceType === PamResourceType.AwsIam) {
accessAwsIam(e);
} else {
handlePopUpOpen("accessAccount", e);
}
}}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
@@ -14,36 +14,6 @@ type Props = {
currentFolderId: string | null;
};
type ContentProps = {
onComplete: (account: TPamAccount) => void;
projectId: string;
currentFolderId: string | null;
};
const Content = ({ onComplete, projectId, currentFolderId }: ContentProps) => {
const [selectedResource, setSelectedResource] = useState<{
id: string;
name: string;
resourceType: PamResourceType;
} | null>(null);
if (selectedResource) {
return (
<PamAccountForm
onComplete={onComplete}
onBack={() => setSelectedResource(null)}
resourceId={selectedResource.id}
resourceName={selectedResource.name}
resourceType={selectedResource.resourceType}
projectId={projectId}
folderId={currentFolderId ?? undefined}
/>
);
}
return <ResourceSelect projectId={projectId} onSubmit={(e) => setSelectedResource(e.resource)} />;
};
export const PamAddAccountModal = ({
isOpen,
onOpenChange,
@@ -51,22 +21,44 @@ export const PamAddAccountModal = ({
onComplete,
currentFolderId
}: Props) => {
const [selectedResource, setSelectedResource] = useState<{
id: string;
name: string;
resourceType: PamResourceType;
} | null>(null);
const handleOpenChange = (open: boolean) => {
if (!open) {
// Reset state when modal closes
setSelectedResource(null);
}
onOpenChange(open);
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<Modal isOpen={isOpen} onOpenChange={handleOpenChange}>
<ModalContent
className="max-w-2xl"
title="Add Account"
subTitle="Select a resource to add an account under."
bodyClassName="overflow-visible"
bodyClassName={selectedResource ? undefined : "overflow-visible"}
>
<Content
projectId={projectId}
onComplete={(account) => {
if (onComplete) onComplete(account);
onOpenChange(false);
}}
currentFolderId={currentFolderId}
/>
{selectedResource ? (
<PamAccountForm
onComplete={(account) => {
if (onComplete) onComplete(account);
onOpenChange(false);
}}
onBack={() => setSelectedResource(null)}
resourceId={selectedResource.id}
resourceName={selectedResource.name}
resourceType={selectedResource.resourceType}
projectId={projectId}
folderId={currentFolderId ?? undefined}
/>
) : (
<ResourceSelect projectId={projectId} onSubmit={(e) => setSelectedResource(e.resource)} />
)}
</ModalContent>
</Modal>
);
@@ -0,0 +1,52 @@
import { useState } from "react";
import { createNotification } from "@app/components/notifications";
import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam";
import { TAwsIamCredentials } from "@app/hooks/api/pam/types";
export const useAccessAwsIamAccount = () => {
const accessPamAccount = useAccessPamAccount();
const [loadingAccountId, setLoadingAccountId] = useState<string | null>(null);
const accessAwsIam = async (account: TPamAccount) => {
if (account.resource.resourceType !== PamResourceType.AwsIam) {
return false;
}
setLoadingAccountId(account.id);
try {
const response = await accessPamAccount.mutateAsync({
accountId: account.id,
duration: `${(account.credentials as TAwsIamCredentials).maxSessionDuration}s`
});
if (response.consoleUrl) {
// Open the AWS Console URL in a new tab
window.open(response.consoleUrl, "_blank", "noopener,noreferrer");
createNotification({
text: "AWS Console opened in new tab",
type: "success"
});
return true;
}
createNotification({
text: "Failed to generate AWS Console URL",
type: "error"
});
return false;
} finally {
setLoadingAccountId(null);
}
};
return {
accessAwsIam,
isPending: accessPamAccount.isPending,
loadingAccountId
};
};
@@ -0,0 +1,204 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
ModalClose
} from "@app/components/v2";
import { useProject } from "@app/context";
import { PamResourceType, TAwsIamResource } from "@app/hooks/api/pam";
import { slugSchema } from "@app/lib/schemas";
type Props = {
resource?: TAwsIamResource;
onSubmit: (formData: FormData) => Promise<void>;
};
const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/;
const AwsIamConnectionDetailsSchema = z.object({
region: z.string().trim().min(1, "Region is required"),
roleArn: z
.string()
.trim()
.min(1, "PAM Role ARN is required")
.refine((val) => arnRoleRegex.test(val), {
message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'"
})
});
const formSchema = z.object({
name: slugSchema({ min: 1, max: 64, field: "Name" }),
resourceType: z.literal(PamResourceType.AwsIam),
connectionDetails: AwsIamConnectionDetailsSchema
});
type FormData = z.infer<typeof formSchema>;
// Infisical AWS account IDs for trust policy
const INFISICAL_AWS_ACCOUNT_US = "381492033652";
const INFISICAL_AWS_ACCOUNT_EU = "345594589636";
export const AwsIamResourceForm = ({ resource, onSubmit }: Props) => {
const isUpdate = Boolean(resource);
const { projectId } = useProject();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource ?? {
resourceType: PamResourceType.AwsIam,
connectionDetails: {
region: "",
roleArn: ""
}
}
});
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="name"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
helperText="Name must be slug-friendly"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Name"
>
<Input autoFocus placeholder="my-aws-console" {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.region"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="AWS Region"
tooltipText="This region is used for the STS endpoint and initial console URL. It does not restrict access to resources in other regions. To restrict region access, configure region conditions in the target role's IAM policy."
>
<Input placeholder="us-east-1" {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.roleArn"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
helperText="The ARN of the Infisical PAM role that can assume target roles"
errorText={error?.message}
isError={Boolean(error?.message)}
label="PAM Role ARN"
>
<Input placeholder="arn:aws:iam::123456789012:role/InfisicalPAMRole" {...field} />
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mt-4 w-full bg-mineshaft-700">
<AccordionItem value="aws-iam-role-setup">
<AccordionTrigger>AWS IAM Role Setup</AccordionTrigger>
<AccordionContent>
<p className="mb-3 text-sm text-mineshaft-300">
Before creating this resource, you need to set up an IAM role in your AWS account
that Infisical can assume. Follow these steps:
</p>
<p className="mb-2 text-sm font-medium text-mineshaft-200">
Step 1: Create a permissions policy for assuming target roles
</p>
<p className="mb-3 text-sm text-mineshaft-300">
This policy allows the PAM role to assume target roles. We recommend using the{" "}
<code className="rounded bg-mineshaft-700 px-1 text-xs">infisical-pam-*</code>{" "}
naming convention for target roles.
</p>
<pre className="mb-4 max-h-40 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 text-xs whitespace-pre-wrap text-mineshaft-300">
{`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::<YOUR_ACCOUNT_ID>:role/infisical-pam-*"
}]
}`}
</pre>
<p className="mb-2 text-sm font-medium text-mineshaft-200">
Step 2: Create the PAM role with a trust policy
</p>
<p className="mb-3 text-sm text-mineshaft-300">
Create an IAM role (e.g.,{" "}
<code className="rounded bg-mineshaft-700 px-1 text-xs">InfisicalPAMRole</code>)
with the permissions policy above and the following trust policy:
</p>
<pre className="mb-4 max-h-40 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 text-xs whitespace-pre-wrap text-mineshaft-300">
{`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::${INFISICAL_AWS_ACCOUNT_US}:root"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "${projectId}"
}
}
}]
}`}
</pre>
<p className="text-xs text-mineshaft-400">
<strong>Note:</strong> Use{" "}
<code className="rounded bg-mineshaft-700 px-1">{INFISICAL_AWS_ACCOUNT_US}</code>{" "}
for US region or{" "}
<code className="rounded bg-mineshaft-700 px-1">{INFISICAL_AWS_ACCOUNT_EU}</code>{" "}
for EU region. The External ID{" "}
<code className="rounded bg-mineshaft-700 px-1">{projectId}</code> is your current
project ID.
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Details" : "Create Resource"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -9,6 +9,7 @@ import {
import { DiscriminativePick } from "@app/types";
import { PamResourceHeader } from "../PamResourceHeader";
import { AwsIamResourceForm } from "./AwsIamResourceForm";
import { MySQLResourceForm } from "./MySQLResourceForm";
import { PostgresResourceForm } from "./PostgresResourceForm";
import { SSHResourceForm } from "./SSHResourceForm";
@@ -54,6 +55,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
return <MySQLResourceForm onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm onSubmit={onSubmit} />;
case PamResourceType.AwsIam:
return <AwsIamResourceForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -84,6 +87,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.AwsIam:
return <AwsIamResourceForm resource={resource} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
}
@@ -1,3 +1,6 @@
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
import { CommandLogView } from "./CommandLogView";
@@ -13,6 +16,7 @@ export const PamSessionLogsSection = ({ session }: Props) => {
const isDatabaseSession =
session.resourceType === PamResourceType.Postgres ||
session.resourceType === PamResourceType.MySQL;
const isAwsIamSession = session.resourceType === PamResourceType.AwsIam;
const hasLogs = session.logs.length > 0;
return (
@@ -23,7 +27,27 @@ export const PamSessionLogsSection = ({ session }: Props) => {
{isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />}
{!hasLogs && (
{isAwsIamSession && (
<div className="flex grow items-center justify-center text-bunker-300">
<div className="text-center">
<div className="mb-2">AWS Console session activity is logged in AWS CloudTrail</div>
<div className="text-xs text-bunker-400">
View detailed activity logs for this session in your AWS CloudTrail console.
<br />
<a
href="https://console.aws.amazon.com/cloudtrail"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-primary-400 hover:text-primary-300"
>
Open AWS CloudTrail
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
</div>
</div>
</div>
)}
{!hasLogs && !isAwsIamSession && (
<div className="flex grow items-center justify-center text-bunker-300">
<div className="text-center">
<div className="mb-2">Session logs are not yet available</div>
@@ -2,6 +2,7 @@ import {
ActivityIcon,
BanIcon,
ChevronsLeftRightEllipsisIcon,
ClockIcon,
GavelIcon,
LucideIcon
} from "lucide-react";
@@ -33,6 +34,10 @@ const PAM_SESSION_STATUS_CONFIG: Record<PamSessionStatus, StatusConfig> = {
[PamSessionStatus.Ended]: {
variant: "neutral",
icon: BanIcon
},
[PamSessionStatus.Expired]: {
variant: "warning",
icon: ClockIcon
}
};