Merge pull request #1731 from akhilmhdh/dynamic-secret/aws-iam

Dynamic secret AWS IAM
This commit is contained in:
Maidul Islam
2024-04-29 20:39:38 -04:00
committed by GitHub
14 changed files with 1073 additions and 21 deletions

View File

@@ -0,0 +1,194 @@
import {
AddUserToGroupCommand,
AttachUserPolicyCommand,
CreateAccessKeyCommand,
CreateUserCommand,
DeleteAccessKeyCommand,
DeleteUserCommand,
DeleteUserPolicyCommand,
DetachUserPolicyCommand,
GetUserCommand,
IAMClient,
ListAccessKeysCommand,
ListAttachedUserPoliciesCommand,
ListGroupsForUserCommand,
ListUserPoliciesCommand,
PutUserPolicyCommand,
RemoveUserFromGroupCommand
} from "@aws-sdk/client-iam";
import { z } from "zod";
import { BadRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models";
const generateUsername = () => {
return alphaNumericNanoId(32);
};
export const AwsIamProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretAwsIamSchema.parseAsync(inputs);
return providerInputs;
};
const getClient = async (providerInputs: z.infer<typeof DynamicSecretAwsIamSchema>) => {
const client = new IAMClient({
region: providerInputs.region,
credentials: {
accessKeyId: providerInputs.accessKey,
secretAccessKey: providerInputs.secretAccessKey
}
});
return client;
};
const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const client = await getClient(providerInputs);
const isConnected = await client.send(new GetUserCommand({})).then(() => true);
return isConnected;
};
const create = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const client = await getClient(providerInputs);
const username = generateUsername();
const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs;
const createUserRes = await client.send(
new CreateUserCommand({
Path: awsPath,
PermissionsBoundary: permissionBoundaryPolicyArn || undefined,
Tags: [{ Key: "createdBy", Value: "infisical-dynamic-secret" }],
UserName: username
})
);
if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" });
if (userGroups) {
await Promise.all(
userGroups
.split(",")
.filter(Boolean)
.map((group) =>
client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group }))
)
);
}
if (policyArns) {
await Promise.all(
policyArns
.split(",")
.filter(Boolean)
.map((policyArn) =>
client.send(new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn }))
)
);
}
if (policyDocument) {
await client.send(
new PutUserPolicyCommand({
UserName: createUserRes.User.UserName,
PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`,
PolicyDocument: policyDocument
})
);
}
const createAccessKeyRes = await client.send(
new CreateAccessKeyCommand({
UserName: createUserRes.User.UserName
})
);
if (!createAccessKeyRes.AccessKey)
throw new BadRequestError({ message: "Failed to create AWS IAM User access key" });
return {
entityId: username,
data: {
ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId,
SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey,
USERNAME: username
}
};
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs);
const client = await getClient(providerInputs);
const username = entityId;
// remove user from groups
const userGroups = await client.send(new ListGroupsForUserCommand({ UserName: username }));
await Promise.all(
(userGroups.Groups || []).map(({ GroupName }) =>
client.send(
new RemoveUserFromGroupCommand({
GroupName,
UserName: username
})
)
)
);
// remove user access keys
const userAccessKeys = await client.send(new ListAccessKeysCommand({ UserName: username }));
await Promise.all(
(userAccessKeys.AccessKeyMetadata || []).map(({ AccessKeyId }) =>
client.send(
new DeleteAccessKeyCommand({
AccessKeyId,
UserName: username
})
)
)
);
// remove user inline policies
const userInlinePolicies = await client.send(new ListUserPoliciesCommand({ UserName: username }));
await Promise.all(
(userInlinePolicies.PolicyNames || []).map((policyName) =>
client.send(
new DeleteUserPolicyCommand({
PolicyName: policyName,
UserName: username
})
)
)
);
// remove user attached policies
const userAttachedPolicies = await client.send(new ListAttachedUserPoliciesCommand({ UserName: username }));
await Promise.all(
(userAttachedPolicies.AttachedPolicies || []).map((policy) =>
client.send(
new DetachUserPolicyCommand({
PolicyArn: policy.PolicyArn,
UserName: username
})
)
)
);
await client.send(new DeleteUserCommand({ UserName: username }));
return { entityId: username };
};
const renew = async (_inputs: unknown, entityId: string) => {
// do nothing
const username = entityId;
return { entityId: username };
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};

View File

@@ -1,8 +1,10 @@
import { AwsIamProvider } from "./aws-iam";
import { CassandraProvider } from "./cassandra";
import { DynamicSecretProviders } from "./models";
import { SqlDatabaseProvider } from "./sql-database";
export const buildDynamicSecretProviders = () => ({
[DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(),
[DynamicSecretProviders.Cassandra]: CassandraProvider()
[DynamicSecretProviders.Cassandra]: CassandraProvider(),
[DynamicSecretProviders.AwsIam]: AwsIamProvider()
});

View File

@@ -8,38 +8,51 @@ export enum SqlProviders {
export const DynamicSecretSqlDBSchema = z.object({
client: z.nativeEnum(SqlProviders),
host: z.string().toLowerCase(),
host: z.string().trim().toLowerCase(),
port: z.number(),
database: z.string(),
username: z.string(),
password: z.string(),
creationStatement: z.string(),
revocationStatement: z.string(),
renewStatement: z.string().optional(),
database: z.string().trim(),
username: z.string().trim(),
password: z.string().trim(),
creationStatement: z.string().trim(),
revocationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(),
ca: z.string().optional()
});
export const DynamicSecretCassandraSchema = z.object({
host: z.string().toLowerCase(),
host: z.string().trim().toLowerCase(),
port: z.number(),
localDataCenter: z.string().min(1),
keyspace: z.string().optional(),
username: z.string(),
password: z.string(),
creationStatement: z.string(),
revocationStatement: z.string(),
renewStatement: z.string().optional(),
localDataCenter: z.string().trim().min(1),
keyspace: z.string().trim().optional(),
username: z.string().trim(),
password: z.string().trim(),
creationStatement: z.string().trim(),
revocationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(),
ca: z.string().optional()
});
export const DynamicSecretAwsIamSchema = z.object({
accessKey: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
permissionBoundaryPolicyArn: z.string().trim().optional(),
policyDocument: z.string().trim().optional(),
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional()
});
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra"
Cassandra = "cassandra",
AwsIam = "aws-iam"
}
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema })
z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema })
]);
export type TDynamicProviderFns = {

View File

@@ -0,0 +1,151 @@
---
title: "AWS IAM"
description: "How to dynamically generate AWS IAM Users."
---
The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users on demand based on configured AWS policy.
## Prerequisite
Infisical needs an initial AWS IAM user with the required permissions to create sub IAM users. This IAM user will be responsible for managing the lifecycle of new IAM users.
<Accordion title="Managing AWS IAM User minimum permission policy">
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iam:AttachUserPolicy",
"iam:CreateAccessKey",
"iam:CreateUser",
"iam:DeleteAccessKey",
"iam:DeleteUser",
"iam:DeleteUserPolicy",
"iam:DetachUserPolicy",
"iam:GetUser",
"iam:ListAccessKeys",
"iam:ListAttachedUserPolicies",
"iam:ListGroupsForUser",
"iam:ListUserPolicies",
"iam:PutUserPolicy",
"iam:AddUserToGroup",
"iam:RemoveUserFromGroup"
],
"Resource": ["*"]
}
]
}
```
To minimize managing user access you can attach a resource in format
> arn:aws:iam::\<account-id\>:user/\<aws-scope-path\>
Replace **\<account id\>** with your AWS account id and **\<aws-scope-path\>** with a path to minimize managing user access.
</Accordion>
## Set up Dynamic Secrets with AWS IAM
<Steps>
<Step title="Secret Overview Dashboard">
Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to.
</Step>
<Step title="Click on the 'Add Dynamic Secret' button">
![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png)
</Step>
<Step title="Select AWS IAM">
![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png)
</Step>
<Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Name" type="string" required>
Name by which you want the secret to be referenced
</ParamField>
<ParamField path="Default TTL" type="string" required>
Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate)
</ParamField>
<ParamField path="Max TTL" type="string" required>
Maximum time-to-live for a generated secret
</ParamField>
<ParamField path="AWS Access Key" type="string" required>
The managing AWS IAM User Access Key
</ParamField>
<ParamField path="AWS Secret Key" type="string" required>
The managing AWS IAM User Secret Key
</ParamField>
<ParamField path="AWS IAM Path" type="string">
[IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access.
</ParamField>
<ParamField path="AWS Region" type="string" required>
The AWS data center region.
</ParamField>
<ParamField path="IAM User Permission Boundary" type="string" required>
The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role.
</ParamField>
<ParamField path="AWS IAM Groups" type="string">
The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas
</ParamField>
<ParamField path="AWS Policy ARNs" type="string">
The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas
</ParamField>
<ParamField path="AWS IAM Policy Document" type="string">
The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas
</ParamField>
![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png)
</Step>
<Step title="Click 'Submit'">
After submitting the form, you will see a dynamic secret created in the dashboard.
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png)
</Step>
<Step title="Generate dynamic secrets">
Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials.
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png)
![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png)
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png)
<Tip>
Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4.
</Tip>
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you.
![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png)
</Step>
</Steps>
## Audit or Revoke Leases
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
This will allow you see the lease details and delete the lease ahead of its expiration time.
![Provision Lease](/images/platform/dynamic-secrets/lease-data.png)
## Renew Leases
To extend the life of the generated dynamic secret lease past its initial time to live, simply click on the **Renew** as illustrated below.
![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png)
<Warning>
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
</Warning>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -146,7 +146,8 @@
"documentation/platform/dynamic-secrets/postgresql",
"documentation/platform/dynamic-secrets/mysql",
"documentation/platform/dynamic-secrets/oracle",
"documentation/platform/dynamic-secrets/cassandra"
"documentation/platform/dynamic-secrets/cassandra",
"documentation/platform/dynamic-secrets/aws-iam"
]
},
"documentation/platform/groups"

View File

@@ -17,7 +17,8 @@ export type TDynamicSecret = {
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra"
Cassandra = "cassandra",
AwsIam = "aws-iam"
}
export enum SqlProviders {
@@ -56,6 +57,18 @@ export type TDynamicSecretProvider =
renewStatement?: string;
ca?: string | undefined;
};
}
| {
type: DynamicSecretProviders.AwsIam;
inputs: {
accessKey: string;
secretAccessKey: string;
region: string;
awsPath?: string;
policyDocument?: string;
userGroups?: string;
policyArns?: string;
};
};
export type TCreateDynamicSecretDTO = {

View File

@@ -0,0 +1,303 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
accessKey: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
permissionBoundaryPolicyArn: z.string().trim().optional(),
policyDocument: z.string().trim().optional(),
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const AwsIamInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isLoading) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.AwsIam, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch (err) {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-postgres" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.accessKey"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Access Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.secretAccessKey"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Secret Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.awsPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Path"
className="flex-grow"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.region"
defaultValue="us-east-1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Region"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="provider.permissionBoundaryPolicyArn"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="IAM User Permission Boundary ARN"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="ARN to be attached to the generated user for AWS Permission Boundary."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.userGroups"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Groups"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given groups."
>
<Input {...field} placeholder="group1,group2" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.policyArns"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Policy ARNs"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given policy arns."
>
<Input
{...field}
placeholder="arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.policyDocument"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Policy Document"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Generated users will have the inline policy."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { faAws } from "@fortawesome/free-brands-svg-icons";
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
@@ -6,6 +7,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { AwsIamInputForm } from "./AwsIamInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
@@ -32,6 +34,11 @@ const DYNAMIC_SECRET_LIST = [
icon: faDatabase,
provider: DynamicSecretProviders.Cassandra,
title: "Cassandra"
},
{
icon: faAws,
provider: DynamicSecretProviders.AwsIam,
title: "AWS IAM"
}
];
@@ -129,6 +136,24 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.AwsIam && (
<motion.div
key="dynamic-aws-iam-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AwsIamInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>

View File

@@ -54,11 +54,11 @@ const OutputDisplay = ({
};
const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
if (
provider === DynamicSecretProviders.SqlDatabase ||
provider === DynamicSecretProviders.Cassandra
) {
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
return (
<div>
<OutputDisplay label="Database User" value={DB_USERNAME} />
@@ -70,6 +70,25 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
</div>
);
}
if (provider === DynamicSecretProviders.AwsIam) {
const { USERNAME, ACCESS_KEY, SECRET_ACCESS_KEY } = data as {
ACCESS_KEY: string;
SECRET_ACCESS_KEY: string;
USERNAME: string;
};
return (
<div>
<OutputDisplay label="AWS Username" value={USERNAME} />
<OutputDisplay label="AWS IAM Access Key" value={ACCESS_KEY} />
<OutputDisplay
label="AWS IAM Secret Key"
value={SECRET_ACCESS_KEY}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
</div>
);
}
return null;
};

View File

@@ -0,0 +1,313 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: z
.object({
accessKey: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
permissionBoundaryPolicyArn: z.string().trim().optional(),
policyDocument: z.string().trim().optional(),
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional()
})
.partial(),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
})
.nullable(),
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
export const EditDynamicSecretAwsIamForm = ({
onClose,
dynamicSecret,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isLoading) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
inputs,
newName: newName === dynamicSecret.name ? undefined : newName
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="DYN-1" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="inputs.accessKey"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Access Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.secretAccessKey"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Secret Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="inputs.awsPath"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Path"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.region"
defaultValue="us-east-1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Region"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="inputs.userGroups"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Groups"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given groups."
>
<Input {...field} placeholder="group1,group2" />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.permissionBoundaryPolicyArn"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="IAM User Permission Boundary ARN"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="ARN to be attached to the generated user for AWS Permission Boundary."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.policyArns"
defaultValue="datacenter1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Policy ARNs"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given policy arns."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.policyDocument"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Policy Document"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Generated users will have the inline policy."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Save
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -4,6 +4,7 @@ import { Spinner } from "@app/components/v2";
import { useGetDynamicSecretDetails } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm";
@@ -74,6 +75,23 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.AwsIam && (
<motion.div
key="aws-iam-provider-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretAwsIamForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};