From e81a77652f98dcf1da8e7bfa95f0af9522ba6cfa Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 24 Apr 2024 18:45:40 +0530 Subject: [PATCH 01/13] feat(server): dynamic secret aws iam implemented --- .../dynamic-secret/providers/aws-iam.ts | 194 ++++++++++++++++++ .../dynamic-secret/providers/index.ts | 4 +- .../dynamic-secret/providers/models.ts | 47 +++-- .../src/ee/services/license/licence-fns.ts | 2 +- 4 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 backend/src/ee/services/dynamic-secret/providers/aws-iam.ts 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 0000000000..3feafa5344 --- /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 34c0495533..beb6c428e3 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 edb60d4b23..c11f6ddfb3 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/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 8a4de57f1e..de9a73c4ec 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -15,7 +15,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ membersUsed: 0, environmentLimit: null, environmentsUsed: 0, - dynamicSecret: false, + dynamicSecret: true, secretVersioning: true, pitRecovery: false, ipAllowlisting: false, From 1a2508d91a5b0572ed380af31b6aa9c7f433f7b3 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 24 Apr 2024 18:46:01 +0530 Subject: [PATCH 02/13] feat(ui): dynamic secret aws iam ui implemented --- frontend/src/hooks/api/dynamicSecret/types.ts | 15 +- .../AwsIamInputForm.tsx | 303 +++++++++++++++++ .../CreateDynamicSecretForm.tsx | 25 ++ .../CreateDynamicSecretLease.tsx | 21 +- .../EditDynamicSecretAwsIamForm.tsx | 313 ++++++++++++++++++ .../EditDynamicSecretForm.tsx | 18 + 6 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAwsIamForm.tsx diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 27c4c5ddf4..a9aab83183 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 0000000000..d1ff003c3c --- /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 +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + +