diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts new file mode 100644 index 000000000..3feafa534 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -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) => { + 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 + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 34c049553..beb6c428e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -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() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index edb60d4b2..c11f6ddfb 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -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 = { diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx new file mode 100644 index 000000000..6ec5b48b9 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -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. + + + +```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::\:user/\ + +Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + + + +## Set up Dynamic Secrets with AWS IAM + + + + Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + The managing AWS IAM User Access Key + + + + The managing AWS IAM User Secret Key + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + + + + The AWS data center region. + + + + 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. + + + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas + + + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas + + + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png) + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + 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) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4. + + + + 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) + + + +## 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) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png new file mode 100644 index 000000000..3ae9155a3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png new file mode 100644 index 000000000..d412109fa Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png b/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png new file mode 100644 index 000000000..4764eceb2 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png differ diff --git a/docs/mint.json b/docs/mint.json index b38b5fa52..f74a422a4 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -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" diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 27c4c5ddf..a9aab8318 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -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 = { diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx new file mode 100644 index 000000000..d1ff003c3 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx @@ -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; + +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({ + 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 ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + +