Dynamic Secrets: add Temporary Credentials for AWS IAM Roles

This commit is contained in:
Carlos Monastyrski
2025-09-03 22:19:18 -03:00
parent 38a7cb896b
commit 233f003e0c
6 changed files with 745 additions and 301 deletions
@@ -16,7 +16,7 @@ import {
PutUserPolicyCommand,
RemoveUserFromGroupCommand
} from "@aws-sdk/client-iam";
import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
import { AssumeRoleCommand, GetSessionTokenCommand, STSClient } from "@aws-sdk/client-sts";
import { z } from "zod";
import { CustomAWSHasher } from "@app/lib/aws/hashing";
@@ -26,9 +26,14 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models";
import { AwsIamAuthType, AwsIamCredentialType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models";
import { compileUsernameTemplate } from "./templateUtils";
// AWS STS duration constants (in seconds)
const AWS_STS_MIN_DURATION = 900; // 15 minutes
const AWS_STS_MAX_DURATION_SESSION_TOKEN = 43200; // 12 hours for GetSessionToken
const AWS_STS_MAX_DURATION_ASSUME_ROLE = 43200; // 12 hours for AssumeRole
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(32);
if (!usernameTemplate) return randomUsername;
@@ -120,6 +125,58 @@ export const AwsIamProvider = (): TDynamicProviderFns => {
const validateConnection = async (inputs: unknown, { projectId }: { projectId: string }) => {
const providerInputs = await validateProviderInputs(inputs);
try {
if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) {
if (providerInputs.method === AwsIamAuthType.AccessKey) {
const stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
credentials: {
accessKeyId: providerInputs.accessKey,
secretAccessKey: providerInputs.secretAccessKey
}
});
await stsClient.send(new GetSessionTokenCommand({ DurationSeconds: AWS_STS_MIN_DURATION }));
return true;
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
const appCfg = getConfig();
const stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
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
});
await stsClient.send(
new AssumeRoleCommand({
RoleArn: providerInputs.roleArn,
RoleSessionName: `infisical-validation-${crypto.nativeCrypto.randomUUID()}`,
DurationSeconds: AWS_STS_MIN_DURATION,
ExternalId: projectId
})
);
return true;
}
if (providerInputs.method === AwsIamAuthType.IRSA) {
const stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher
});
await stsClient.send(new GetSessionTokenCommand({ DurationSeconds: AWS_STS_MIN_DURATION }));
return true;
}
}
const client = await $getClient(providerInputs, projectId);
const isConnected = await client
.send(new GetUserCommand({}))
@@ -137,13 +194,21 @@ export const AwsIamProvider = (): TDynamicProviderFns => {
});
return isConnected;
} catch (err) {
const sensitiveTokens = [];
const sensitiveTokens: string[] = [];
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
sensitiveTokens.push(providerInputs.roleArn);
}
if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) {
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
sensitiveTokens.push(providerInputs.roleArn);
}
}
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: sensitiveTokens
@@ -163,102 +228,258 @@ export const AwsIamProvider = (): TDynamicProviderFns => {
};
metadata: { projectId: string };
}) => {
const { inputs, usernameTemplate, metadata, identity } = data;
const { inputs, usernameTemplate, metadata, identity, expireAt } = data;
const providerInputs = await validateProviderInputs(inputs);
const client = await $getClient(providerInputs, metadata.projectId);
const username = generateUsername(usernameTemplate, identity);
const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs;
const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }];
if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) {
try {
let stsClient: STSClient;
let entityId: string;
if (providerInputs.tags && Array.isArray(providerInputs.tags)) {
const additionalTags = providerInputs.tags.map((tag) => ({
Key: tag.key,
Value: tag.value
}));
awsTags.push(...additionalTags);
const currentTime = Math.floor(Date.now() / 1000);
const requestedDuration = expireAt - currentTime;
if (requestedDuration <= 0) {
throw new BadRequestError({ message: "Expiration time must be in the future" });
}
let durationSeconds = Math.min(requestedDuration, AWS_STS_MAX_DURATION_SESSION_TOKEN);
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
const appCfg = getConfig();
stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
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
});
durationSeconds = Math.min(durationSeconds, AWS_STS_MAX_DURATION_ASSUME_ROLE);
const assumeRoleRes = await stsClient.send(
new AssumeRoleCommand({
RoleArn: providerInputs.roleArn,
RoleSessionName: `infisical-temp-cred-${crypto.nativeCrypto.randomUUID()}`,
DurationSeconds: Math.max(durationSeconds, AWS_STS_MIN_DURATION),
ExternalId: metadata.projectId
})
);
if (
!assumeRoleRes.Credentials?.AccessKeyId ||
!assumeRoleRes.Credentials?.SecretAccessKey ||
!assumeRoleRes.Credentials?.SessionToken
) {
throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" });
}
entityId = `assume-role-${alphaNumericNanoId(8)}`;
return {
entityId,
data: {
ACCESS_KEY: assumeRoleRes.Credentials.AccessKeyId,
SECRET_ACCESS_KEY: assumeRoleRes.Credentials.SecretAccessKey,
SESSION_TOKEN: assumeRoleRes.Credentials.SessionToken
}
};
}
if (providerInputs.method === AwsIamAuthType.AccessKey) {
stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
credentials: {
accessKeyId: providerInputs.accessKey,
secretAccessKey: providerInputs.secretAccessKey
}
});
const sessionTokenRes = await stsClient.send(
new GetSessionTokenCommand({
DurationSeconds: Math.max(durationSeconds, AWS_STS_MIN_DURATION)
})
);
if (
!sessionTokenRes.Credentials?.AccessKeyId ||
!sessionTokenRes.Credentials?.SecretAccessKey ||
!sessionTokenRes.Credentials?.SessionToken
) {
throw new BadRequestError({ message: "Failed to get session token - verify credentials and permissions" });
}
entityId = `session-token-${alphaNumericNanoId(8)}`;
return {
entityId,
data: {
ACCESS_KEY: sessionTokenRes.Credentials.AccessKeyId,
SECRET_ACCESS_KEY: sessionTokenRes.Credentials.SecretAccessKey,
SESSION_TOKEN: sessionTokenRes.Credentials.SessionToken
}
};
}
if (providerInputs.method === AwsIamAuthType.IRSA) {
stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher
});
const sessionTokenRes = await stsClient.send(
new GetSessionTokenCommand({
DurationSeconds: Math.max(durationSeconds, AWS_STS_MIN_DURATION)
})
);
if (
!sessionTokenRes.Credentials?.AccessKeyId ||
!sessionTokenRes.Credentials?.SecretAccessKey ||
!sessionTokenRes.Credentials?.SessionToken
) {
throw new BadRequestError({
message: "Failed to get session token - verify IRSA credentials and permissions"
});
}
entityId = `irsa-session-${alphaNumericNanoId(8)}`;
return {
entityId,
data: {
ACCESS_KEY: sessionTokenRes.Credentials.AccessKeyId,
SECRET_ACCESS_KEY: sessionTokenRes.Credentials.SecretAccessKey,
SESSION_TOKEN: sessionTokenRes.Credentials.SessionToken
}
};
}
throw new BadRequestError({ message: "Unsupported authentication method for temporary credentials" });
} catch (err) {
const sensitiveTokens: string[] = [];
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
sensitiveTokens.push(providerInputs.roleArn);
}
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: sensitiveTokens
});
throw new BadRequestError({
message: `Failed to create temporary credentials: ${sanitizedErrorMessage}`
});
}
}
try {
const createUserRes = await client.send(
new CreateUserCommand({
Path: awsPath,
PermissionsBoundary: permissionBoundaryPolicyArn || undefined,
Tags: awsTags,
UserName: username
})
);
if (providerInputs.credentialType === AwsIamCredentialType.IamUser) {
const client = await $getClient(providerInputs, metadata.projectId);
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 }))
)
);
const username = generateUsername(usernameTemplate, identity);
const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs;
const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }];
if (providerInputs.tags && Array.isArray(providerInputs.tags)) {
const additionalTags = providerInputs.tags.map((tag) => ({
Key: tag.key,
Value: tag.value
}));
awsTags.push(...additionalTags);
}
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
try {
const createUserRes = await client.send(
new CreateUserCommand({
Path: awsPath,
PermissionsBoundary: permissionBoundaryPolicyArn || undefined,
Tags: awsTags,
UserName: username
})
);
}
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
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 }))
)
);
}
};
} catch (err) {
const sensitiveTokens = [username];
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
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
}
};
} catch (err) {
const sensitiveTokens = [username];
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
sensitiveTokens.push(providerInputs.roleArn);
}
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: sensitiveTokens
});
throw new BadRequestError({
message: `Failed to create lease from provider: ${sanitizedErrorMessage}`
});
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
sensitiveTokens.push(providerInputs.roleArn);
}
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: sensitiveTokens
});
throw new BadRequestError({
message: `Failed to create lease from provider: ${sanitizedErrorMessage}`
});
}
throw new BadRequestError({ message: "Invalid credential type specified" });
};
const revoke = async (inputs: unknown, entityId: string, metadata: { projectId: string }) => {
const providerInputs = await validateProviderInputs(inputs);
if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) {
return { entityId };
}
const client = await $getClient(providerInputs, metadata.projectId);
const username = entityId;
@@ -32,6 +32,11 @@ export enum AwsIamAuthType {
IRSA = "irsa"
}
export enum AwsIamCredentialType {
IamUser = "iam-user",
TemporaryCredentials = "temporary-credentials"
}
export enum ElasticSearchAuthTypes {
User = "user",
ApiKey = "api-key"
@@ -202,6 +207,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess(
z.discriminatedUnion("method", [
z.object({
method: z.literal(AwsIamAuthType.AccessKey),
credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser),
accessKey: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim().min(1),
@@ -214,6 +220,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess(
}),
z.object({
method: z.literal(AwsIamAuthType.AssumeRole),
credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser),
roleArn: z.string().trim().min(1, "Role ARN required"),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
@@ -225,6 +232,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess(
}),
z.object({
method: z.literal(AwsIamAuthType.IRSA),
credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
permissionBoundaryPolicyArn: z.string().trim().optional(),