Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AWS Secret Manager assume role based integration #2027

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,059 changes: 1,059 additions & 0 deletions backend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"dependencies": {
"@aws-sdk/client-iam": "^3.525.0",
"@aws-sdk/client-secrets-manager": "^3.504.0",
"@aws-sdk/client-sts": "^3.600.0",
"@casl/ability": "^6.5.0",
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^8.5.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
const hasAwsAssumeRoleCipherText = await knex.schema.hasColumn(
TableName.IntegrationAuth,
"awsAssumeIamRoleArnCipherText"
);
const hasAwsAssumeRoleIV = await knex.schema.hasColumn(TableName.IntegrationAuth, "awsAssumeIamRoleArnIV");
const hasAwsAssumeRoleTag = await knex.schema.hasColumn(TableName.IntegrationAuth, "awsAssumeIamRoleArnTag");
if (await knex.schema.hasTable(TableName.IntegrationAuth)) {
await knex.schema.alterTable(TableName.IntegrationAuth, (t) => {
if (!hasAwsAssumeRoleCipherText) t.text("awsAssumeIamRoleArnCipherText");
if (!hasAwsAssumeRoleIV) t.text("awsAssumeIamRoleArnIV");
if (!hasAwsAssumeRoleTag) t.text("awsAssumeIamRoleArnTag");
});
}
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
}

export async function down(knex: Knex): Promise<void> {
const hasAwsAssumeRoleCipherText = await knex.schema.hasColumn(
TableName.IntegrationAuth,
"awsAssumeIamRoleArnCipherText"
);
const hasAwsAssumeRoleIV = await knex.schema.hasColumn(TableName.IntegrationAuth, "awsAssumeIamRoleArnIV");
const hasAwsAssumeRoleTag = await knex.schema.hasColumn(TableName.IntegrationAuth, "awsAssumeIamRoleArnTag");
if (await knex.schema.hasTable(TableName.IntegrationAuth)) {
await knex.schema.alterTable(TableName.IntegrationAuth, (t) => {
if (hasAwsAssumeRoleCipherText) t.dropColumn("awsAssumeIamRoleArnCipherText");
if (hasAwsAssumeRoleIV) t.dropColumn("awsAssumeIamRoleArnIV");
if (hasAwsAssumeRoleTag) t.dropColumn("awsAssumeIamRoleArnTag");
});
}
}
5 changes: 4 additions & 1 deletion backend/src/db/schemas/integration-auths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export const IntegrationAuthsSchema = z.object({
keyEncoding: z.string(),
projectId: z.string(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
awsAssumeIamRoleArnCipherText: z.string().nullable().optional(),
awsAssumeIamRoleArnIV: z.string().nullable().optional(),
awsAssumeIamRoleArnTag: z.string().nullable().optional()
});

export type TIntegrationAuths = z.infer<typeof IntegrationAuthsSchema>;
Expand Down
1 change: 1 addition & 0 deletions backend/src/lib/api-docs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ export const INTEGRATION_AUTH = {
integration: "The slug of integration for the auth object.",
accessId: "The unique authorized access id of the external integration provider.",
accessToken: "The unique authorized access token of the external integration provider.",
awsAssumeIamRoleArn: "The AWS IAM Role to be assumed by Infisical",
url: "",
namespace: "",
refreshToken: "The refresh token for integration authorization."
Expand Down
3 changes: 3 additions & 0 deletions backend/src/lib/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ const envSchema = z
// azure
CLIENT_ID_AZURE: zpStr(z.string().optional()),
CLIENT_SECRET_AZURE: zpStr(z.string().optional()),
// aws
CLIENT_ID_AWS_INTEGRATION: zpStr(z.string().optional()),
CLIENT_SECRET_AWS_INTEGRATION: zpStr(z.string().optional()),
// gitlab
CLIENT_ID_GITLAB: zpStr(z.string().optional()),
CLIENT_SECRET_GITLAB: zpStr(z.string().optional()),
Expand Down
6 changes: 6 additions & 0 deletions backend/src/server/routes/v1/integration-auth-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
integration: z.string().trim().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration),
accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId),
accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken),
awsAssumeIamRoleArn: z
.string()
.url()
.trim()
.optional()
.describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.awsAssumeIamRoleArn),
url: z.string().url().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.url),
namespace: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.namespace),
refreshToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.refreshToken)
Expand Down
19 changes: 17 additions & 2 deletions backend/src/services/integration-auth/integration-auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ export const integrationAuthServiceFactory = ({
actorAuthMethod,
accessId,
namespace,
accessToken
accessToken,
awsAssumeIamRoleArn
}: TSaveIntegrationAccessTokenDTO) => {
if (!Object.values(Integrations).includes(integration as Integrations))
throw new BadRequestError({ message: "Invalid integration" });
Expand Down Expand Up @@ -230,7 +231,7 @@ export const integrationAuthServiceFactory = ({
updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt;
}

if (!refreshToken && (accessId || accessToken)) {
if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) {
if (accessToken) {
const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessToken, key);
updateDoc.accessIV = accessEncToken.iv;
Expand All @@ -243,6 +244,12 @@ export const integrationAuthServiceFactory = ({
updateDoc.accessIdTag = accessEncToken.tag;
updateDoc.accessIdCiphertext = accessEncToken.ciphertext;
}
if (awsAssumeIamRoleArn) {
const awsAssumeIamRoleArnEnc = encryptSymmetric128BitHexKeyUTF8(awsAssumeIamRoleArn, key);
updateDoc.awsAssumeIamRoleArnCipherText = awsAssumeIamRoleArnEnc.ciphertext;
updateDoc.awsAssumeIamRoleArnIV = awsAssumeIamRoleArnEnc.iv;
updateDoc.awsAssumeIamRoleArnTag = awsAssumeIamRoleArnEnc.tag;
}
}
return integrationAuthDAL.create(updateDoc);
};
Expand All @@ -251,6 +258,14 @@ export const integrationAuthServiceFactory = ({
const getIntegrationAccessToken = async (integrationAuth: TIntegrationAuths, botKey: string) => {
let accessToken: string | undefined;
let accessId: string | undefined;
// this means its not access token based
if (
integrationAuth.integration === Integrations.AWS_SECRET_MANAGER &&
integrationAuth.awsAssumeIamRoleArnCipherText
) {
return { accessToken: "", accessId: "" };
}

if (integrationAuth.accessTag && integrationAuth.accessIV && integrationAuth.accessCiphertext) {
accessToken = decryptSymmetric128BitHexKeyUTF8({
ciphertext: integrationAuth.accessCiphertext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type TSaveIntegrationAccessTokenDTO = {
url?: string;
namespace?: string;
refreshToken?: string;
awsAssumeIamRoleArn?: string;
} & TProjectPermission;

export type TDeleteIntegrationAuthsDTO = TProjectPermission & {
Expand Down
60 changes: 53 additions & 7 deletions backend/src/services/integration-auth/integration-sync-secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@ import {
UntagResourceCommand,
UpdateSecretCommand
} from "@aws-sdk/client-secrets-manager";
import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
import { Octokit } from "@octokit/rest";
import AWS, { AWSError } from "aws-sdk";
import { AxiosError } from "axios";
import { randomUUID } from "crypto";
import sodium from "libsodium-wrappers";
import isEqual from "lodash.isequal";
import { z } from "zod";

import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
Expand Down Expand Up @@ -695,24 +698,61 @@ const syncSecretsAWSSecretManager = async ({
integration,
secrets,
accessId,
accessToken
accessToken,
awsAssumeRoleArn,
projectId
}: {
integration: TIntegrations;
secrets: Record<string, { value: string; comment?: string }>;
accessId: string | null;
accessToken: string;
awsAssumeRoleArn: string | null;
projectId?: string;
}) => {
const appCfg = getConfig();
const metadata = z.record(z.any()).parse(integration.metadata || {});

if (!accessId) {
throw new Error("AWS access ID is required");
if (!accessId && !awsAssumeRoleArn) {
throw new Error("AWS access ID/AWS Assume Role is required");
}

let accessKeyId = "";
let secretAccessKey = "";
let sessionToken;
if (awsAssumeRoleArn) {
const client = new STSClient({
region: integration.region as string,
credentials:
appCfg.CLIENT_ID_AWS_INTEGRATION && appCfg.CLIENT_SECRET_AWS_INTEGRATION
? {
accessKeyId: appCfg.CLIENT_ID_AWS_INTEGRATION,
secretAccessKey: appCfg.CLIENT_SECRET_AWS_INTEGRATION
}
: undefined
});
const command = new AssumeRoleCommand({
RoleArn: awsAssumeRoleArn,
RoleSessionName: `infisical-sm-${randomUUID()}`,
DurationSeconds: 900, // 15mins
ExternalId: projectId
});
const response = await client.send(command);
if (!response.Credentials?.AccessKeyId || !response.Credentials?.SecretAccessKey)
throw new Error("Failed to assume role");
accessKeyId = response.Credentials?.AccessKeyId;
secretAccessKey = response.Credentials?.SecretAccessKey;
sessionToken = response.Credentials?.SessionToken;
} else {
accessKeyId = accessId as string;
secretAccessKey = accessToken;
}

const secretsManager = new SecretsManagerClient({
region: integration.region as string,
credentials: {
accessKeyId: accessId,
secretAccessKey: accessToken
accessKeyId,
secretAccessKey,
sessionToken
}
});

Expand Down Expand Up @@ -3568,7 +3608,9 @@ export const syncIntegrationSecrets = async ({
secrets,
accessId,
accessToken,
appendices
awsAssumeRoleArn,
appendices,
projectId
}: {
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
Expand All @@ -3585,8 +3627,10 @@ export const syncIntegrationSecrets = async ({
integrationAuth: TIntegrationAuths;
secrets: Record<string, { value: string; comment?: string }>;
accessId: string | null;
awsAssumeRoleArn: string | null;
accessToken: string;
appendices?: { prefix: string; suffix: string };
projectId?: string;
}) => {
let response: { isSynced: boolean; syncMessage: string } | null = null;

Expand Down Expand Up @@ -3620,7 +3664,9 @@ export const syncIntegrationSecrets = async ({
integration,
secrets,
accessId,
accessToken
accessToken,
awsAssumeRoleArn,
projectId
});
break;
case Integrations.HEROKU:
Expand Down
13 changes: 11 additions & 2 deletions backend/src/services/integration/integration-dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ export const integrationDALFactory = (db: TDbClient) => {
db.ref("accessExpiresAt").withSchema(TableName.IntegrationAuth).as("accessExpiresAtAu"),
db.ref("metadata").withSchema(TableName.IntegrationAuth).as("metadataAu"),
db.ref("algorithm").withSchema(TableName.IntegrationAuth).as("algorithmAu"),
db.ref("keyEncoding").withSchema(TableName.IntegrationAuth).as("keyEncodingAu")
db.ref("keyEncoding").withSchema(TableName.IntegrationAuth).as("keyEncodingAu"),
db.ref("awsAssumeIamRoleArnCipherText").withSchema(TableName.IntegrationAuth),
db.ref("awsAssumeIamRoleArnIV").withSchema(TableName.IntegrationAuth),
db.ref("awsAssumeIamRoleArnTag").withSchema(TableName.IntegrationAuth)
);
return docs.map(
({
Expand All @@ -146,6 +149,9 @@ export const integrationDALFactory = (db: TDbClient) => {
algorithmAu: algorithm,
keyEncodingAu: keyEncoding,
accessExpiresAtAu: accessExpiresAt,
awsAssumeIamRoleArnIV,
awsAssumeIamRoleArnCipherText,
awsAssumeIamRoleArnTag,
...el
}) => ({
...el,
Expand Down Expand Up @@ -174,7 +180,10 @@ export const integrationDALFactory = (db: TDbClient) => {
metadata,
algorithm,
keyEncoding,
accessExpiresAt
accessExpiresAt,
awsAssumeIamRoleArnIV,
awsAssumeIamRoleArnCipherText,
awsAssumeIamRoleArnTag
}
})
);
Expand Down
16 changes: 16 additions & 0 deletions backend/src/services/secret/secret-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,18 @@ export const secretQueueFactory = ({

const botKey = await projectBotService.getBotKey(projectId);
const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken(integrationAuth, botKey);
const awsAssumeRoleArn =
integrationAuth.awsAssumeIamRoleArnTag &&
integrationAuth.awsAssumeIamRoleArnIV &&
integrationAuth.awsAssumeIamRoleArnCipherText
? decryptSymmetric128BitHexKeyUTF8({
ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText,
iv: integrationAuth.awsAssumeIamRoleArnIV,
tag: integrationAuth.awsAssumeIamRoleArnTag,
key: botKey
})
: null;

const secrets = await getIntegrationSecrets({
environment,
projectId,
Expand All @@ -544,6 +556,8 @@ export const secretQueueFactory = ({
}

try {
// akhilmhdh: this needs to changed later to be more easier to use
// at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places
const response = await syncIntegrationSecrets({
createManySecretsRawFn,
updateManySecretsRawFn,
Expand All @@ -552,7 +566,9 @@ export const secretQueueFactory = ({
integrationAuth,
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets,
accessId: accessId as string,
awsAssumeRoleArn,
accessToken,
projectId,
appendices: {
prefix: metadata?.secretPrefix || "",
suffix: metadata?.secretSuffix || ""
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading