mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 03:27:38 +00:00
Merge branch 'main' into PLATFRM-114
This commit is contained in:
@@ -5,7 +5,6 @@ export enum IdentityProjectAdditionalPrivilegeTemporaryMode {
|
||||
}
|
||||
|
||||
export type TIdentityProjectPrivilege = {
|
||||
projectMembershipId: string;
|
||||
slug: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -16,7 +16,8 @@ export enum PamResourceType {
|
||||
CockroachDB = "cockroachdb",
|
||||
Elasticsearch = "elasticsearch",
|
||||
Snowflake = "snowflake",
|
||||
DynamoDB = "dynamodb"
|
||||
DynamoDB = "dynamodb",
|
||||
AwsIam = "aws-iam"
|
||||
}
|
||||
|
||||
export enum PamResourceOrderBy {
|
||||
|
||||
@@ -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" }
|
||||
};
|
||||
|
||||
@@ -120,6 +120,45 @@ export const useDeletePamAccount = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export type TAccessPamAccountDTO = {
|
||||
accountId: string;
|
||||
accountPath: string;
|
||||
projectId: 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, accountPath, projectId, duration }: TAccessPamAccountDTO) => {
|
||||
const { data } = await apiRequest.post<TAccessPamAccountResponse>(
|
||||
"/api/v1/pam/accounts/access",
|
||||
{
|
||||
accountId,
|
||||
accountPath,
|
||||
projectId,
|
||||
duration
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Folders
|
||||
export const useCreatePamFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PamResourceType } from "../enums";
|
||||
import { TBasePamAccount } from "./base-account";
|
||||
import { TBasePamResource } from "./base-resource";
|
||||
|
||||
export type TAwsIamConnectionDetails = {
|
||||
roleArn: string;
|
||||
};
|
||||
|
||||
export type TAwsIamCredentials = {
|
||||
targetRoleArn: string;
|
||||
defaultSessionDuration: 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -23,18 +23,17 @@ export const PamAccessAccountModal = ({
|
||||
projectId,
|
||||
accountPath
|
||||
}: Props) => {
|
||||
let fullAccountPath = account?.name;
|
||||
if (accountPath) {
|
||||
let path = accountPath;
|
||||
if (path.startsWith("/")) path = path.slice(1);
|
||||
fullAccountPath = `${path}/${account?.name}`;
|
||||
}
|
||||
const [duration, setDuration] = useState("4h");
|
||||
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const portSuffix = port && port !== "80" && port !== "443" ? `:${port}` : "";
|
||||
const siteURL = `${protocol}//${hostname}${portSuffix}`;
|
||||
|
||||
const [duration, setDuration] = useState("4h");
|
||||
let fullAccountPath = account?.name ?? "";
|
||||
if (accountPath) {
|
||||
const path = accountPath.replace(/^\/+|\/+$/g, "");
|
||||
fullAccountPath = `${path}/${account?.name ?? ""}`;
|
||||
}
|
||||
|
||||
const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]);
|
||||
|
||||
@@ -89,7 +88,7 @@ export const PamAccessAccountModal = ({
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}, [account, cliDuration]);
|
||||
}, [account, fullAccountPath, projectId, cliDuration, siteURL]);
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose
|
||||
} from "@app/components/v2";
|
||||
import { CopyButton } from "@app/components/v2/CopyButton";
|
||||
import { useProject } from "@app/context";
|
||||
import {
|
||||
PamResourceType,
|
||||
TAwsIamAccount,
|
||||
TAwsIamResource,
|
||||
useGetPamResourceById
|
||||
} from "@app/hooks/api/pam";
|
||||
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } 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)
|
||||
defaultSessionDuration: 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 formSchema = genericAccountFieldsSchema.extend({
|
||||
credentials: AwsIamCredentialsSchema
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const AwsIamAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(account);
|
||||
const { projectId } = useProject();
|
||||
|
||||
const resourceIdToFetch = account?.resourceId || resourceId;
|
||||
const resourceTypeToFetch = account?.resource?.resourceType || resourceType;
|
||||
const { data: resource } = useGetPamResourceById(resourceTypeToFetch, resourceIdToFetch, {
|
||||
enabled: !!resourceIdToFetch && !!resourceTypeToFetch
|
||||
});
|
||||
|
||||
const pamRoleArn =
|
||||
(resource?.resourceType === PamResourceType.AwsIam &&
|
||||
(resource as TAwsIamResource).connectionDetails?.roleArn) ||
|
||||
"arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_PAM_ROLE_NAME>";
|
||||
|
||||
const targetRoleTrustPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "${pamRoleArn}"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"sts:ExternalId": "${projectId}"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: account ?? {
|
||||
name: "",
|
||||
description: "",
|
||||
credentials: {
|
||||
targetRoleArn: "",
|
||||
defaultSessionDuration: 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.defaultSessionDuration"
|
||||
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="Default Session Duration (seconds)"
|
||||
>
|
||||
<Input {...field} type="number" placeholder="3600" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="mb-4 w-full rounded-r border-l-2 border-l-primary bg-mineshaft-300/5"
|
||||
>
|
||||
<AccordionItem value="target-role-setup" className="border-b-0">
|
||||
<AccordionTrigger className="px-4 py-2.5 hover:no-underline [&[data-state=open]]:pb-1">
|
||||
<div className="flex items-center text-sm transition-colors duration-150 hover:text-primary">
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
|
||||
Target Role Setup
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-2.5">
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
The target role must have a trust policy that allows the PAM role (created in the
|
||||
"Resources" tab) to assume it. If your target role name follows the
|
||||
wildcard pattern you defined in the PAM role's permissions policy, no
|
||||
additional changes are needed.
|
||||
</p>
|
||||
|
||||
<p className="mb-2 text-sm font-medium text-mineshaft-200">
|
||||
Target role trust policy:
|
||||
</p>
|
||||
<div className="relative mb-3">
|
||||
<div className="absolute top-1 right-3">
|
||||
<CopyButton value={targetRoleTrustPolicy} size="sm" variant="plain" />
|
||||
</div>
|
||||
<pre className="max-h-45 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{targetRoleTrustPolicy}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
<strong>Note:</strong> The Principal role ARN shown above is from the PAM Resource
|
||||
selected for this account. The External ID{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">{projectId}</code> is your
|
||||
current project ID. If your target role name doesn't match the wildcard pattern
|
||||
in your PAM Resource's role's permissions policy, you'll need to
|
||||
update that policy to include this role's ARN.
|
||||
</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 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,14 @@ 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 +109,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;
|
||||
|
||||
@@ -101,7 +103,7 @@ export const PamAccountRow = ({
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{account.lastRotatedAt && (
|
||||
{"lastRotatedAt" in account && account.lastRotatedAt && (
|
||||
<Tooltip
|
||||
className="max-w-sm text-center"
|
||||
isDisabled={!account.lastRotationMessage}
|
||||
@@ -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,21 @@ 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) {
|
||||
let fullAccountPath = e?.name;
|
||||
const folderPath = e.folderId ? folderPaths[e.folderId] : undefined;
|
||||
if (folderPath) {
|
||||
const path = folderPath.replace(/^\/+|\/+$/g, "");
|
||||
fullAccountPath = `${path}/${e?.name}`;
|
||||
}
|
||||
|
||||
accessAwsIam(e, fullAccountPath);
|
||||
} 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>
|
||||
);
|
||||
|
||||
@@ -80,6 +80,8 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear search when a value is selected so the selected label is shown
|
||||
setSearch("");
|
||||
onChange(newValue);
|
||||
}}
|
||||
isLoading={isPending}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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, accountPath: string) => {
|
||||
if (account.resource.resourceType !== PamResourceType.AwsIam) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setLoadingAccountId(account.id);
|
||||
|
||||
try {
|
||||
const response = await accessPamAccount.mutateAsync({
|
||||
accountId: account.id,
|
||||
accountPath,
|
||||
projectId: account.projectId,
|
||||
duration: `${(account.credentials as TAwsIamCredentials).defaultSessionDuration}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
|
||||
};
|
||||
};
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose
|
||||
} from "@app/components/v2";
|
||||
import { CopyButton } from "@app/components/v2/CopyButton";
|
||||
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({
|
||||
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 permissionsPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_PREFIX>-*"
|
||||
}]
|
||||
}`;
|
||||
|
||||
const trustPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "arn:aws:iam::<INFISICAL_AWS_ACCOUNT_ID>:root"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"sts:ExternalId": "${projectId}"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: resource ?? {
|
||||
resourceType: PamResourceType.AwsIam,
|
||||
connectionDetails: {
|
||||
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.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 rounded-r border-l-2 border-l-primary bg-mineshaft-300/5"
|
||||
>
|
||||
<AccordionItem value="aws-iam-role-setup" className="border-b-0">
|
||||
<AccordionTrigger className="px-4 py-2.5 hover:no-underline [&[data-state=open]]:pb-1">
|
||||
<div className="flex items-center text-sm transition-colors duration-150 hover:text-primary">
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
|
||||
AWS IAM Role Setup
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-2.5">
|
||||
<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 a
|
||||
wildcard pattern (e.g.,{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 text-xs">pam-*</code> or{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 text-xs">privileged-*</code>) so you
|
||||
can add new accounts without updating this policy. Choose a prefix that fits your
|
||||
naming conventions.
|
||||
</p>
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute top-1 right-1">
|
||||
<CopyButton value={permissionsPolicy} size="sm" variant="plain" />
|
||||
</div>
|
||||
<pre className="max-h-45 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{permissionsPolicy}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute top-1 right-3">
|
||||
<CopyButton value={trustPolicy} size="sm" variant="plain" />
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-y-auto rounded-sm border border-mineshaft-600 bg-mineshaft-800 p-2 pr-8 text-xs whitespace-pre-wrap text-mineshaft-300">
|
||||
{trustPolicy}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
<strong>Note:</strong> Use{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">
|
||||
{INFISICAL_AWS_ACCOUNT_US}
|
||||
</code>{" "}
|
||||
for US region or{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">
|
||||
{INFISICAL_AWS_ACCOUNT_EU}
|
||||
</code>{" "}
|
||||
for EU region. Replace{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">
|
||||
<INFISICAL_AWS_ACCOUNT_ID>
|
||||
</code>{" "}
|
||||
with the appropriate Infisical AWS account ID for your region. The External ID{" "}
|
||||
<code className="rounded bg-mineshaft-700 px-1 font-bold">{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>
|
||||
|
||||
Reference in New Issue
Block a user