feat: enhance AWS IAM resource handling with new gateway access schema and improved project ID management

- Introduced GatewayAccessResponseSchema for consistent response structures across Postgres, MySQL, and SSH resources.
- Updated PAM account router to utilize the new schema, streamlining response validation.
- Refactored AWS IAM service to improve project ID handling during role assumption and credential management.
- Enhanced AWS IAM resource schemas to support gateway-specific configurations, improving flexibility and type safety.
This commit is contained in:
Victor Santos
2025-12-07 20:32:59 -03:00
parent 69fd05bc1e
commit 3e77c33532
11 changed files with 198 additions and 185 deletions

View File

@@ -6,6 +6,7 @@ import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/
import { SanitizedAwsIamAccountWithResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas";
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas";
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
import { BadRequestError } from "@app/lib/errors";
@@ -130,51 +131,15 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: z.discriminatedUnion("resourceType", [
// Gateway-based resources (Postgres)
z.object({
sessionId: z.string(),
resourceType: z.literal(PamResource.Postgres),
relayClientCertificate: z.string(),
relayClientPrivateKey: z.string(),
relayServerCertificateChain: z.string(),
gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string(),
metadata: z.record(z.string(), z.string().optional()).optional()
}),
// Gateway-based resources (MySQL)
z.object({
sessionId: z.string(),
resourceType: z.literal(PamResource.MySQL),
relayClientCertificate: z.string(),
relayClientPrivateKey: z.string(),
relayServerCertificateChain: z.string(),
gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string(),
metadata: z.record(z.string(), z.string().optional()).optional()
}),
// Gateway-based resources (SSH)
z.object({
sessionId: z.string(),
resourceType: z.literal(PamResource.SSH),
relayClientCertificate: z.string(),
relayClientPrivateKey: z.string(),
relayServerCertificateChain: z.string(),
gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string(),
metadata: z.record(z.string(), z.string().optional()).optional()
}),
// Gateway-based resources (Postgres, MySQL, SSH)
GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Postgres) }),
GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.MySQL) }),
GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.SSH) }),
// AWS IAM (no gateway, returns console URL)
z.object({
sessionId: z.string(),
resourceType: z.literal(PamResource.AwsIam),
consoleUrl: z.string().url(),
projectId: z.string().uuid(),
metadata: z.record(z.string(), z.string().optional()).optional()
})
])
@@ -203,7 +168,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: response.projectId,
projectId: req.body.projectId,
event: {
type: EventType.PAM_ACCOUNT_ACCESS,
metadata: {

View File

@@ -616,7 +616,6 @@ export const pamAccountServiceFactory = ({
return {
sessionId: session.id,
resourceType,
projectId: account.projectId,
account,
consoleUrl,
metadata: {

View File

@@ -1,8 +1,10 @@
import { AssumeRoleCommand, STSClient, STSClientConfig } from "@aws-sdk/client-sts";
import { AssumeRoleCommand, Credentials, STSClient, STSClientConfig } from "@aws-sdk/client-sts";
import { CustomAWSHasher } from "@app/lib/aws/hashing";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types";
@@ -14,42 +16,123 @@ const AWS_STS_MIN_DURATION_SECONDS = 900;
// 3. The target account's resources can be in any region - it doesn't affect STS calls
const AWS_STS_DEFAULT_REGION = "us-east-1";
const createStsClient = (): STSClient => {
const createStsClient = (credentials?: Credentials): STSClient => {
const appCfg = getConfig();
const config: STSClientConfig = {
region: AWS_STS_DEFAULT_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 // if hosting on AWS
sha256: CustomAWSHasher
};
if (credentials) {
// Use provided credentials (for role chaining)
config.credentials = {
accessKeyId: credentials.AccessKeyId!,
secretAccessKey: credentials.SecretAccessKey!,
sessionToken: credentials.SessionToken
};
} else if (appCfg.PAM_AWS_ACCESS_KEY_ID && appCfg.PAM_AWS_SECRET_ACCESS_KEY) {
// Use configured static credentials
config.credentials = {
accessKeyId: appCfg.PAM_AWS_ACCESS_KEY_ID,
secretAccessKey: appCfg.PAM_AWS_SECRET_ACCESS_KEY
};
}
// Otherwise uses instance profile if hosting on AWS
return new STSClient(config);
};
/**
* Assumes the PAM role and returns the credentials.
* Returns null if assumption fails (for validation) or throws if throwOnError is true.
*/
const assumePamRole = async ({
connectionDetails,
projectId,
sessionDuration = AWS_STS_MIN_DURATION_SECONDS,
sessionNameSuffix = "validation",
throwOnError = false
}: {
connectionDetails: TAwsIamResourceConnectionDetails;
projectId: string;
sessionDuration?: number;
sessionNameSuffix?: string;
throwOnError?: boolean;
}): Promise<Credentials | null> => {
const stsClient = createStsClient();
const result = await stsClient.send(
new AssumeRoleCommand({
RoleArn: connectionDetails.roleArn,
RoleSessionName: `infisical-pam-${sessionNameSuffix}-${Date.now()}`,
DurationSeconds: sessionDuration,
ExternalId: projectId
})
);
if (!result.Credentials) {
if (throwOnError) {
throw new InternalServerError({
message: "Failed to assume PAM role - AWS STS did not return credentials"
});
}
return null;
}
return result.Credentials;
};
/**
* Assumes a target role using PAM role credentials (role chaining).
* Returns null if assumption fails (for validation) or throws if throwOnError is true.
*/
const assumeTargetRole = async ({
pamCredentials,
targetRoleArn,
projectId,
roleSessionName,
sessionDuration = AWS_STS_MIN_DURATION_SECONDS,
throwOnError = false
}: {
pamCredentials: Credentials;
targetRoleArn: string;
projectId: string;
roleSessionName: string;
sessionDuration?: number;
throwOnError?: boolean;
}): Promise<Credentials | null> => {
const chainedStsClient = createStsClient(pamCredentials);
const result = await chainedStsClient.send(
new AssumeRoleCommand({
RoleArn: targetRoleArn,
RoleSessionName: roleSessionName,
DurationSeconds: sessionDuration,
ExternalId: projectId
})
);
if (!result.Credentials) {
if (throwOnError) {
throw new BadRequestError({
message: "Failed to assume target role - verify the target role trust policy allows the PAM role to assume it"
});
}
return null;
}
return result.Credentials;
};
export const validatePamRoleConnection = async (
connectionDetails: TAwsIamResourceConnectionDetails,
projectId: string
): Promise<boolean> => {
const stsClient = createStsClient();
try {
await stsClient.send(
new AssumeRoleCommand({
RoleArn: connectionDetails.roleArn,
RoleSessionName: `infisical-pam-validation-${Date.now()}`,
DurationSeconds: AWS_STS_MIN_DURATION_SECONDS,
ExternalId: projectId
})
);
return true;
const credentials = await assumePamRole({ connectionDetails, projectId });
return credentials !== null;
} catch {
return false;
}
@@ -64,45 +147,17 @@ export const validateTargetRoleAssumption = async ({
targetRoleArn: string;
projectId: string;
}): Promise<boolean> => {
const stsClient = createStsClient();
try {
// First assume the PAM role
const pamRoleCredentials = await stsClient.send(
new AssumeRoleCommand({
RoleArn: connectionDetails.roleArn,
RoleSessionName: `infisical-pam-validation-${Date.now()}`,
DurationSeconds: AWS_STS_MIN_DURATION_SECONDS,
ExternalId: projectId
})
);
const pamCredentials = await assumePamRole({ connectionDetails, projectId });
if (!pamCredentials) return false;
if (!pamRoleCredentials.Credentials) {
return false;
}
// Then use the PAM role credentials to assume the target role
const pamStsClient = new STSClient({
region: AWS_STS_DEFAULT_REGION,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
credentials: {
accessKeyId: pamRoleCredentials.Credentials.AccessKeyId!,
secretAccessKey: pamRoleCredentials.Credentials.SecretAccessKey!,
sessionToken: pamRoleCredentials.Credentials.SessionToken
}
const targetCredentials = await assumeTargetRole({
pamCredentials,
targetRoleArn,
projectId,
roleSessionName: `infisical-pam-target-validation-${Date.now()}`
});
await pamStsClient.send(
new AssumeRoleCommand({
RoleArn: targetRoleArn,
RoleSessionName: `infisical-pam-target-validation-${Date.now()}`,
DurationSeconds: AWS_STS_MIN_DURATION_SECONDS,
ExternalId: projectId
})
);
return true;
return targetCredentials !== null;
} catch {
return false;
}
@@ -124,48 +179,24 @@ export const generateConsoleFederationUrl = async ({
projectId: string;
sessionDuration: number;
}): Promise<{ consoleUrl: string; expiresAt: Date }> => {
const stsClient = createStsClient();
// First assume the PAM role
const pamRoleCredentials = await stsClient.send(
new AssumeRoleCommand({
RoleArn: connectionDetails.roleArn,
RoleSessionName: `infisical-pam-${Date.now()}`,
DurationSeconds: sessionDuration,
ExternalId: projectId
})
);
if (!pamRoleCredentials.Credentials) {
throw new Error("Failed to assume PAM role");
}
// Role chaining: use PAM role credentials to assume the target role
const pamStsClient = new STSClient({
region: AWS_STS_DEFAULT_REGION,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
credentials: {
accessKeyId: pamRoleCredentials.Credentials.AccessKeyId!,
secretAccessKey: pamRoleCredentials.Credentials.SecretAccessKey!,
sessionToken: pamRoleCredentials.Credentials.SessionToken
}
const pamCredentials = await assumePamRole({
connectionDetails,
projectId,
sessionDuration,
sessionNameSuffix: "session",
throwOnError: true
});
const targetRoleCredentials = await pamStsClient.send(
new AssumeRoleCommand({
RoleArn: targetRoleArn,
RoleSessionName: roleSessionName,
DurationSeconds: sessionDuration,
ExternalId: projectId
})
);
const targetCredentials = await assumeTargetRole({
pamCredentials: pamCredentials!,
targetRoleArn,
projectId,
roleSessionName,
sessionDuration,
throwOnError: true
});
if (!targetRoleCredentials.Credentials) {
throw new Error("Failed to assume target role");
}
const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = targetRoleCredentials.Credentials;
const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = targetCredentials!;
// Generate federation URL
const sessionJson = JSON.stringify({
@@ -178,25 +209,20 @@ export const generateConsoleFederationUrl = async ({
const signinTokenUrl = `${federationEndpoint}?Action=getSigninToken&Session=${encodeURIComponent(sessionJson)}`;
const tokenResponse = await fetch(signinTokenUrl);
const tokenResponse = await request.get<{ SigninToken?: string }>(signinTokenUrl);
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
// eslint-disable-next-line no-console
throw new Error(`AWS federation endpoint returned error (${tokenResponse.status}): ${errorText.substring(0, 200)}`);
if (!tokenResponse.data.SigninToken) {
throw new InternalServerError({
message: `AWS federation endpoint did not return a SigninToken: ${JSON.stringify(tokenResponse.data).substring(0, 200)}`
});
}
const responseText = await tokenResponse.text();
let tokenData: { SigninToken: string };
try {
tokenData = JSON.parse(responseText) as { SigninToken: string };
} catch {
throw new Error(`AWS federation endpoint returned invalid response: ${responseText.substring(0, 200)}`);
}
const tokenData = tokenResponse.data;
if (!tokenData.SigninToken) {
throw new Error(`AWS federation endpoint did not return a SigninToken: ${responseText.substring(0, 200)}`);
throw new InternalServerError({
message: `AWS federation endpoint did not return a SigninToken: ${JSON.stringify(tokenResponse.data).substring(0, 200)}`
});
}
const consoleDestination = `https://console.aws.amazon.com/`;

View File

@@ -51,13 +51,11 @@ export const AwsIamResourceListItemSchema = z.object({
export const CreateAwsIamResourceSchema = BaseCreatePamResourceSchema.extend({
connectionDetails: AwsIamResourceConnectionDetailsSchema,
gatewayId: z.string().uuid().nullable().optional(),
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
});
export const UpdateAwsIamResourceSchema = BaseUpdatePamResourceSchema.extend({
connectionDetails: AwsIamResourceConnectionDetailsSchema.optional(),
gatewayId: z.string().uuid().nullable().optional(),
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
});

View File

@@ -2,13 +2,13 @@ import { z } from "zod";
import { PamResource } from "../pam-resource-enums";
import {
BaseCreateGatewayPamResourceSchema,
BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema,
BasePamAccountSchemaWithResource,
BasePamResourceSchema,
BaseUpdatePamAccountSchema,
BaseUpdatePamResourceSchema
BaseUpdateGatewayPamResourceSchema,
BaseUpdatePamAccountSchema
} from "../pam-resource-schemas";
import {
BaseSqlAccountCredentialsSchema,
@@ -43,12 +43,12 @@ export const MySQLResourceListItemSchema = z.object({
resource: z.literal(PamResource.MySQL)
});
export const CreateMySQLResourceSchema = BaseCreatePamResourceSchema.extend({
export const CreateMySQLResourceSchema = BaseCreateGatewayPamResourceSchema.extend({
connectionDetails: MySQLResourceConnectionDetailsSchema,
rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional()
});
export const UpdateMySQLResourceSchema = BaseUpdatePamResourceSchema.extend({
export const UpdateMySQLResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({
connectionDetails: MySQLResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional()
});

View File

@@ -3,6 +3,18 @@ import { z } from "zod";
import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas";
import { slugSchema } from "@app/server/lib/schemas";
export const GatewayAccessResponseSchema = z.object({
sessionId: z.string(),
relayClientCertificate: z.string(),
relayClientPrivateKey: z.string(),
relayServerCertificateChain: z.string(),
gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string(),
metadata: z.record(z.string(), z.string().optional()).optional()
});
// Resources
export const BasePamResourceSchema = PamResourcesSchema.omit({
encryptedConnectionDetails: true,
@@ -10,17 +22,27 @@ export const BasePamResourceSchema = PamResourcesSchema.omit({
resourceType: true
});
export const BaseCreatePamResourceSchema = z.object({
const CoreCreatePamResourceSchema = z.object({
projectId: z.string().uuid(),
gatewayId: z.string().uuid(),
name: slugSchema({ field: "name" })
});
export const BaseUpdatePamResourceSchema = z.object({
gatewayId: z.string().uuid().optional(),
export const BaseCreateGatewayPamResourceSchema = CoreCreatePamResourceSchema.extend({
gatewayId: z.string().uuid()
});
export const BaseCreatePamResourceSchema = CoreCreatePamResourceSchema;
const CoreUpdatePamResourceSchema = z.object({
name: slugSchema({ field: "name" }).optional()
});
export const BaseUpdateGatewayPamResourceSchema = CoreUpdatePamResourceSchema.extend({
gatewayId: z.string().uuid().optional()
});
export const BaseUpdatePamResourceSchema = CoreUpdatePamResourceSchema;
// Accounts
export const BasePamAccountSchema = PamAccountsSchema.omit({
encryptedCredentials: true

View File

@@ -2,13 +2,13 @@ import { z } from "zod";
import { PamResource } from "../pam-resource-enums";
import {
BaseCreateGatewayPamResourceSchema,
BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema,
BasePamAccountSchemaWithResource,
BasePamResourceSchema,
BaseUpdatePamAccountSchema,
BaseUpdatePamResourceSchema
BaseUpdateGatewayPamResourceSchema,
BaseUpdatePamAccountSchema
} from "../pam-resource-schemas";
import {
BaseSqlAccountCredentialsSchema,
@@ -40,12 +40,12 @@ export const PostgresResourceListItemSchema = z.object({
resource: z.literal(PamResource.Postgres)
});
export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({
export const CreatePostgresResourceSchema = BaseCreateGatewayPamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema,
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
});
export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({
export const UpdatePostgresResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
});

View File

@@ -2,13 +2,13 @@ import { z } from "zod";
import { PamResource } from "../pam-resource-enums";
import {
BaseCreateGatewayPamResourceSchema,
BaseCreatePamAccountSchema,
BaseCreatePamResourceSchema,
BasePamAccountSchema,
BasePamAccountSchemaWithResource,
BasePamResourceSchema,
BaseUpdatePamAccountSchema,
BaseUpdatePamResourceSchema
BaseUpdateGatewayPamResourceSchema,
BaseUpdatePamAccountSchema
} from "../pam-resource-schemas";
import { SSHAuthMethod } from "./ssh-resource-enums";
@@ -73,12 +73,12 @@ export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({
.optional()
});
export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({
export const CreateSSHResourceSchema = BaseCreateGatewayPamResourceSchema.extend({
connectionDetails: SSHResourceConnectionDetailsSchema,
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
});
export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({
export const UpdateSSHResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({
connectionDetails: SSHResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional()
});

View File

@@ -286,6 +286,10 @@ const envSchema = z
DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()).default(
process.env.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY
),
// PAM AWS credentials (for AWS IAM PAM resource type)
PAM_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()),
PAM_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()),
/* ----------------------------------------------------------------------------- */
/* App Connections ----------------------------------------------------------------------------- */

View File

@@ -18,7 +18,7 @@ import { CopyButton } from "@app/components/v2/CopyButton";
import { useProject } from "@app/context";
import { PamResourceType, TAwsIamAccount } from "@app/hooks/api/pam";
import { GenericAccountFields } from "./GenericAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
type Props = {
account?: TAwsIamAccount;
@@ -45,12 +45,7 @@ const AwsIamCredentialsSchema = z.object({
.default(3600)
});
const genericAwsIamAccountFieldsSchema = z.object({
name: z.string().min(1, "Name is required").max(64, "Name must be at most 64 characters"),
description: z.string().max(512).optional().nullable()
});
const formSchema = genericAwsIamAccountFieldsSchema.extend({
const formSchema = genericAccountFieldsSchema.extend({
credentials: AwsIamCredentialsSchema
});
@@ -153,8 +148,8 @@ export const AwsIamAccountForm = ({ account, onSubmit }: Props) => {
</AccordionTrigger>
<AccordionContent className="px-4 pb-2.5">
<p className="mb-3 text-sm text-mineshaft-300">
The target role must have a trust policy that allows the Infisical PAM role to
assume it. If you used the{" "}
The target role must have a trust policy that allows the Infisical PAM role you
created and used in the &quot;Resources&quot; tab to assume it. If you used the{" "}
<code className="rounded bg-mineshaft-700 px-1 text-xs">infisical-pam-*</code>{" "}
naming convention, no additional changes are needed to the PAM role.
</p>

View File

@@ -66,7 +66,7 @@ export const AwsIamResourceForm = ({ resource, onSubmit }: Props) => {
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::${INFISICAL_AWS_ACCOUNT_US}:root"
"AWS": "arn:aws:iam::<INFISICAL_AWS_ACCOUNT_ID>:root"
},
"Action": "sts:AssumeRole",
"Condition": {
@@ -186,7 +186,11 @@ export const AwsIamResourceForm = ({ resource, onSubmit }: Props) => {
<code className="rounded bg-mineshaft-700 px-1 font-bold">
{INFISICAL_AWS_ACCOUNT_EU}
</code>{" "}
for EU region. The External ID{" "}
for EU region. Replace{" "}
<code className="rounded bg-mineshaft-700 px-1 font-bold">
&lt;INFISICAL_AWS_ACCOUNT_ID&gt;
</code>{" "}
with the appropriate Infisical AWS account ID for your region. The External ID{" "}
<code className="rounded bg-mineshaft-700 px-1 font-bold">{projectId}</code> is your
current project ID.
</p>