From 09a7346f32a817f81fe0ba1c12f9fab00fd8ae06 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 6 Jun 2025 21:33:19 +0530 Subject: [PATCH] feat: backend changes for assume permission in aws dynamic secret --- .../dynamic-secret-lease-queue.ts | 8 +- .../dynamic-secret-lease-service.ts | 8 +- .../dynamic-secret/dynamic-secret-service.ts | 12 +-- .../dynamic-secret/providers/aws-iam.ts | 82 ++++++++++++++++--- .../dynamic-secret/providers/models.ts | 61 ++++++++++---- .../src/ee/services/license/license-fns.ts | 2 +- .../services/permission/project-permission.ts | 22 ++++- backend/src/lib/config/env.ts | 6 ++ 8 files changed, 163 insertions(+), 38 deletions(-) diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts index fa1a80ac3..c38a8f146 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -99,7 +99,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; - await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId); + await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { + projectId: folder.projectId + }); await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); return; } @@ -133,7 +135,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); await Promise.all( dynamicSecretLeases.map(({ externalEntityId }) => - selectedProvider.revoke(decryptedStoredInput, externalEntityId) + selectedProvider.revoke(decryptedStoredInput, externalEntityId, { + projectId: folder.projectId + }) ) ); } diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index f3f3f3acd..14666cb40 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -135,7 +135,8 @@ export const dynamicSecretLeaseServiceFactory = ({ result = await selectedProvider.create({ inputs: decryptedStoredInput, expireAt: expireAt.getTime(), - usernameTemplate: dynamicSecretCfg.usernameTemplate + usernameTemplate: dynamicSecretCfg.usernameTemplate, + metadata: { projectId } }); } catch (error: unknown) { if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { @@ -237,7 +238,8 @@ export const dynamicSecretLeaseServiceFactory = ({ const { entityId } = await selectedProvider.renew( decryptedStoredInput, dynamicSecretLease.externalEntityId, - expireAt.getTime() + expireAt.getTime(), + { projectId } ); await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); @@ -313,7 +315,7 @@ export const dynamicSecretLeaseServiceFactory = ({ ) as object; const revokeResponse = await selectedProvider - .revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId) + .revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { projectId }) .catch(async (err) => { // only propogate this error if forced is false if (!isForced) return { error: err as Error }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 16ac10716..b502bf9f3 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -116,7 +116,7 @@ export const dynamicSecretServiceFactory = ({ throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); const selectedProvider = dynamicSecretProviders[provider.type]; - const inputs = await selectedProvider.validateProviderInputs(provider.inputs); + const inputs = await selectedProvider.validateProviderInputs(provider.inputs, { projectId }); let selectedGatewayId: string | null = null; if (inputs && typeof inputs === "object" && "gatewayId" in inputs && inputs.gatewayId) { @@ -146,7 +146,7 @@ export const dynamicSecretServiceFactory = ({ selectedGatewayId = gateway.id; } - const isConnected = await selectedProvider.validateConnection(provider.inputs); + const isConnected = await selectedProvider.validateConnection(provider.inputs, { projectId }); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ @@ -272,7 +272,7 @@ export const dynamicSecretServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; const newInput = { ...decryptedStoredInput, ...(inputs || {}) }; - const updatedInput = await selectedProvider.validateProviderInputs(newInput); + const updatedInput = await selectedProvider.validateProviderInputs(newInput, { projectId }); let selectedGatewayId: string | null = null; if (updatedInput && typeof updatedInput === "object" && "gatewayId" in updatedInput && updatedInput?.gatewayId) { @@ -301,7 +301,7 @@ export const dynamicSecretServiceFactory = ({ selectedGatewayId = gateway.id; } - const isConnected = await selectedProvider.validateConnection(newInput); + const isConnected = await selectedProvider.validateConnection(newInput, { projectId }); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const updatedDynamicCfg = await dynamicSecretDAL.transaction(async (tx) => { @@ -472,7 +472,9 @@ export const dynamicSecretServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; - const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object; + const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput, { + projectId + })) as object; return { ...dynamicSecretCfg, inputs: providerInputs }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts index 9d8e10f60..b7144d78f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -16,13 +16,16 @@ import { PutUserPolicyCommand, RemoveUserFromGroupCommand } from "@aws-sdk/client-iam"; +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import { randomUUID } from "crypto"; import handlebars from "handlebars"; import { z } from "zod"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; +import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; const generateUsername = (usernameTemplate?: string | null) => { const randomUsername = alphaNumericNanoId(32); @@ -40,7 +43,45 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return providerInputs; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer, projectId: string) => { + const appCfg = getConfig(); + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + console.log(appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID, appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY); + const stsClient = new STSClient({ + region: providerInputs.region, + credentials: + appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + } + : undefined // if hosting on AWS + }); + + const command = new AssumeRoleCommand({ + RoleArn: providerInputs.roleArn, + RoleSessionName: `infisical-dynamic-secret-${randomUUID()}`, + DurationSeconds: 900, // 15 mins + ExternalId: projectId + }); + + const assumeRes = await stsClient.send(command); + + if (!assumeRes.Credentials?.AccessKeyId || !assumeRes.Credentials?.SecretAccessKey) { + throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" }); + } + console.log(assumeRes.Credentials); + const client = new IAMClient({ + region: providerInputs.region, + credentials: { + accessKeyId: assumeRes.Credentials?.AccessKeyId, + secretAccessKey: assumeRes.Credentials?.SecretAccessKey, + sessionToken: assumeRes.Credentials?.SessionToken + } + }); + return client; + } + const client = new IAMClient({ region: providerInputs.region, credentials: { @@ -52,22 +93,39 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return client; }; - const validateConnection = async (inputs: unknown) => { + const validateConnection = async (inputs: unknown, { projectId }: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); - - const isConnected = await client.send(new GetUserCommand({})).then(() => true); + const client = await $getClient(providerInputs, projectId); + const isConnected = await client + .send(new GetUserCommand({})) + .then(() => true) + .catch((err) => { + const message = (err as Error)?.message; + if ( + providerInputs.method === AwsIamAuthType.AssumeRole && + message.includes("Must specify userName when calling with non-User credentials") + ) { + return true; + } + throw err; + }); return isConnected; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + metadata: { projectId: string }; + }) => { + const { inputs, usernameTemplate, metadata } = data; const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); + const client = await $getClient(providerInputs, metadata.projectId); const username = generateUsername(usernameTemplate); const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; + console.log("Hit"); const createUserRes = await client.send( new CreateUserCommand({ Path: awsPath, @@ -76,6 +134,8 @@ export const AwsIamProvider = (): TDynamicProviderFns => { UserName: username }) ); + + console.log("Hit fail"); if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); if (userGroups) { await Promise.all( @@ -125,9 +185,9 @@ export const AwsIamProvider = (): TDynamicProviderFns => { }; }; - const revoke = async (inputs: unknown, entityId: string) => { + const revoke = async (inputs: unknown, entityId: string, metadata: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); + const client = await $getClient(providerInputs, metadata.projectId); const username = entityId; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 91d26da32..e6fa27492 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -20,6 +20,11 @@ export enum SqlProviders { Vertica = "vertica" } +export enum AwsIamAuthType { + AssumeRole = "assume-role", + AccessKey = "access-key" +} + export enum ElasticSearchAuthTypes { User = "user", ApiKey = "api-key" @@ -168,16 +173,38 @@ export const DynamicSecretSapAseSchema = z.object({ revocationStatement: z.string().trim() }); -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 const DynamicSecretAwsIamSchema = z.preprocess( + (val) => { + if (typeof val === "object" && val !== null && !Object.hasOwn(val, "method")) { + // eslint-disable-next-line no-param-reassign + (val as { method: string }).method = AwsIamAuthType.AccessKey; + } + return val; + }, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsIamAuthType.AccessKey), + 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() + }), + z.object({ + method: z.literal(AwsIamAuthType.AssumeRole), + roleArn: z.string().trim().min(1, "Role ARN required"), + 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 const DynamicSecretMongoAtlasSchema = z.object({ adminPublicKey: z.string().trim().min(1).describe("Admin user public api key"), @@ -400,9 +427,15 @@ export type TDynamicProviderFns = { inputs: unknown; expireAt: number; usernameTemplate?: string | null; + metadata: { projectId: string }; }) => Promise<{ entityId: string; data: unknown }>; - validateConnection: (inputs: unknown) => Promise; - validateProviderInputs: (inputs: object) => Promise; - revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>; - renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>; + validateConnection: (inputs: unknown, metadata: { projectId: string }) => Promise; + validateProviderInputs: (inputs: object, metadata: { projectId: string }) => Promise; + revoke: (inputs: unknown, entityId: string, metadata: { projectId: string }) => Promise<{ entityId: string }>; + renew: ( + inputs: unknown, + entityId: string, + expireAt: number, + metadata: { projectId: string } + ) => Promise<{ entityId: string }>; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index c2db3e6e7..15596ecf2 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -18,7 +18,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ environmentsUsed: 0, identityLimit: null, identitiesUsed: 0, - dynamicSecret: false, + dynamicSecret: true, secretVersioning: true, pitRecovery: false, ipAllowlisting: false, diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 34cffcd98..d1ae69574 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -376,7 +376,8 @@ const DynamicSecretConditionV2Schema = z .object({ [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], - [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] }) .partial() ]), @@ -404,6 +405,23 @@ const DynamicSecretConditionV2Schema = z }) .partial(); +const SecretImportConditionSchema = z + .object({ + environment: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const SecretConditionV2Schema = z .object({ environment: z.union([ @@ -741,7 +759,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( "Describe what action an entity can take." ), - conditions: SecretConditionV1Schema.describe( + conditions: SecretImportConditionSchema.describe( "When specified, only matching conditions will be allowed to access given resource." ).optional() }), diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index cb53a71a6..e2fc73d8a 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -213,6 +213,12 @@ const envSchema = z GATEWAY_RELAY_AUTH_SECRET: zpStr(z.string().optional()), DYNAMIC_SECRET_ALLOW_INTERNAL_IP: zodStrBool.default("false"), + DYNAMIC_SECRET_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()).default( + process.env.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID + ), + DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()).default( + process.env.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + ), /* ----------------------------------------------------------------------------- */ /* App Connections ----------------------------------------------------------------------------- */