diff --git a/backend/src/db/migrations/20251203224427_pam-aws-console.ts b/backend/src/db/migrations/20251203224427_pam-aws-console.ts new file mode 100644 index 000000000..adadb9e99 --- /dev/null +++ b/backend/src/db/migrations/20251203224427_pam-aws-console.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGatewayId = await knex.schema.hasColumn(TableName.PamResource, "gatewayId"); + if (hasGatewayId) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.uuid("gatewayId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasGatewayId = await knex.schema.hasColumn(TableName.PamResource, "gatewayId"); + if (hasGatewayId) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.uuid("gatewayId").notNullable().alter(); + }); + } +} diff --git a/backend/src/db/schemas/pam-resources.ts b/backend/src/db/schemas/pam-resources.ts index 325f6eddc..f59aae5d8 100644 --- a/backend/src/db/schemas/pam-resources.ts +++ b/backend/src/db/schemas/pam-resources.ts @@ -13,7 +13,7 @@ export const PamResourcesSchema = z.object({ id: z.string().uuid(), projectId: z.string(), name: z.string(), - gatewayId: z.string().uuid(), + gatewayId: z.string().uuid().nullable().optional(), resourceType: z.string(), encryptedConnectionDetails: zodBuffer, createdAt: z.date(), diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts index d3aadd5a4..6b6a4cbed 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -1,3 +1,8 @@ +import { + CreateAwsIamAccountSchema, + SanitizedAwsIamAccountWithResourceSchema, + UpdateAwsIamAccountSchema +} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; import { CreateMySQLAccountSchema, SanitizedMySQLAccountWithResourceSchema, @@ -44,5 +49,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.AwsIam, + accountResponseSchema: SanitizedAwsIamAccountWithResourceSchema, + createAccountSchema: CreateAwsIamAccountSchema, + updateAccountSchema: UpdateAwsIamAccountSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 0b7f89b6c..d201ea0e2 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { PamFoldersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums"; +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 { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; @@ -18,7 +19,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedAccountSchema = z.union([ SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS SanitizedPostgresAccountWithResourceSchema, - SanitizedMySQLAccountWithResourceSchema + SanitizedMySQLAccountWithResourceSchema, + SanitizedAwsIamAccountWithResourceSchema ]); export const registerPamAccountRouter = async (server: FastifyZodProvider) => { @@ -124,18 +126,29 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { }) }), response: { - 200: z.object({ - sessionId: z.string(), - resourceType: z.nativeEnum(PamResource), - 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() - }) + 200: z.union([ + // Gateway-based resources (Postgres, MySQL, SSH) + z.object({ + sessionId: z.string(), + resourceType: z.nativeEnum(PamResource), + 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() + }), + // AWS IAM (no gateway, returns console URL) + z.object({ + sessionId: z.string(), + resourceType: z.literal(PamResource.AwsIam), + consoleUrl: z.string().url(), + projectId: z.string(), + metadata: z.record(z.string(), z.string().optional()).optional() + }) + ]) } }, onRequest: verifyAuth([AuthMode.JWT]), diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index 5dae317da..fcd9840b4 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,3 +1,8 @@ +import { + CreateAwsIamResourceSchema, + SanitizedAwsIamResourceSchema, + UpdateAwsIamResourceSchema +} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; import { CreateMySQLResourceSchema, MySQLResourceSchema, @@ -44,5 +49,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.AwsIam, + resourceResponseSchema: SanitizedAwsIamResourceSchema, + createResourceSchema: CreateAwsIamResourceSchema, + updateResourceSchema: UpdateAwsIamResourceSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts index ffbeae5c0..e3803316e 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts @@ -19,7 +19,7 @@ export const registerPamResourceEndpoints = ({ createResourceSchema: z.ZodType<{ projectId: T["projectId"]; connectionDetails: T["connectionDetails"]; - gatewayId: T["gatewayId"]; + gatewayId?: T["gatewayId"]; name: T["name"]; rotationAccountCredentials?: T["rotationAccountCredentials"]; }>; @@ -103,7 +103,7 @@ export const registerPamResourceEndpoints = ({ type: EventType.PAM_RESOURCE_CREATE, metadata: { resourceType, - gatewayId: req.body.gatewayId, + ...(req.body.gatewayId && { gatewayId: req.body.gatewayId }), name: req.body.name } } @@ -150,8 +150,8 @@ export const registerPamResourceEndpoints = ({ metadata: { resourceId: req.params.resourceId, resourceType, - gatewayId: req.body.gatewayId, - name: req.body.name + ...(req.body.gatewayId && { gatewayId: req.body.gatewayId }), + ...(req.body.name && { name: req.body.name }) } } }); diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index 3536e7a99..b6a7532ed 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + AwsIamResourceListItemSchema, + SanitizedAwsIamResourceSchema +} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; import { MySQLResourceListItemSchema, SanitizedMySQLResourceSchema @@ -22,13 +26,15 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedResourceSchema = z.union([ SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema, - SanitizedSSHResourceSchema + SanitizedSSHResourceSchema, + SanitizedAwsIamResourceSchema ]); const ResourceOptionsSchema = z.discriminatedUnion("resource", [ PostgresResourceListItemSchema, MySQLResourceListItemSchema, - SSHResourceListItemSchema + SSHResourceListItemSchema, + AwsIamResourceListItemSchema ]); export const registerPamResourceRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 36b49a37c..ff8bcc4bf 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -4158,7 +4158,7 @@ interface PamResourceCreateEvent { type: EventType.PAM_RESOURCE_CREATE; metadata: { resourceType: string; - gatewayId: string; + gatewayId?: string; name: string; }; } diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 1eae8df15..fe2f558aa 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -1,6 +1,11 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamFolders, TPamResources } from "@app/db/schemas"; +import { + extractAwsAccountIdFromArn, + generateConsoleFederationUrl, + TAwsIamAccountCredentials +} from "@app/ee/services/pam-resource/aws-iam"; import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory"; import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -16,6 +21,7 @@ import { OrgServiceActor } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TPamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -27,7 +33,8 @@ import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns"; import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal"; import { PamResource } from "../pam-resource/pam-resource-enums"; import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; -import { TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types"; +import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types"; +import { TSSHAccountCredentials } from "../pam-resource/ssh/ssh-resource-types"; import { TPamSessionDALFactory } from "../pam-session/pam-session-dal"; import { PamSessionStatus } from "../pam-session/pam-session-enums"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -51,6 +58,7 @@ type TPamAccountServiceFactoryDep = { >; userDAL: TUserDALFactory; auditLogService: Pick; + pamSessionExpirationService: Pick; }; export type TPamAccountServiceFactory = ReturnType; @@ -67,7 +75,8 @@ export const pamAccountServiceFactory = ({ licenseService, kmsService, gatewayV2Service, - auditLogService + auditLogService, + pamSessionExpirationService }: TPamAccountServiceFactoryDep) => { const create = async ( { @@ -135,7 +144,8 @@ export const pamAccountServiceFactory = ({ resource.resourceType as PamResource, connectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + resource.projectId ); const validatedCredentials = await factory.validateAccountCredentials(credentials); @@ -250,7 +260,8 @@ export const pamAccountServiceFactory = ({ resource.resourceType as PamResource, connectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + account.projectId ); const decryptedCredentials = await decryptAccountCredentials({ @@ -527,6 +538,65 @@ export const pamAccountServiceFactory = ({ }) ); + const { connectionDetails, gatewayId, resourceType } = await decryptResource( + resource, + account.projectId, + kmsService + ); + + const user = await userDAL.findById(actor.id); + if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); + + if (resourceType === PamResource.AwsIam) { + const awsCredentials = (await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + kmsService, + projectId: account.projectId + })) as TAwsIamAccountCredentials; + + const { consoleUrl, expiresAt } = await generateConsoleFederationUrl({ + connectionDetails, + targetRoleArn: awsCredentials.targetRoleArn, + roleSessionName: actorEmail, + projectId: account.projectId, // Use project ID as External ID for security + sessionDuration: awsCredentials.maxSessionDuration + }); + + const session = await pamSessionDAL.create({ + accountName: account.name, + actorEmail, + actorIp, + actorName, + actorUserAgent, + projectId: account.projectId, + resourceName: resource.name, + resourceType: resource.resourceType, + status: PamSessionStatus.Active, // AWS IAM sessions are immediately active + accountId: account.id, + userId: actor.id, + expiresAt, + startedAt: new Date() + }); + + // Schedule session expiration job to run at expiresAt + await pamSessionExpirationService.scheduleSessionExpiration(session.id, expiresAt); + + return { + sessionId: session.id, + resourceType, + projectId: account.projectId, + account, + consoleUrl, + metadata: { + awsAccountId: extractAwsAccountIdFromArn(connectionDetails.roleArn), + targetRoleArn: awsCredentials.targetRoleArn, + federatedUsername: actorEmail, + expiresAt: expiresAt.toISOString() + } + }; + } + + // For gateway-based resources (Postgres, MySQL, SSH), create session first const session = await pamSessionDAL.create({ accountName: account.name, actorEmail, @@ -541,23 +611,17 @@ export const pamAccountServiceFactory = ({ userId: actor.id, expiresAt: new Date(Date.now() + duration) }); - - const { connectionDetails, gatewayId, resourceType } = await decryptResource( - resource, - account.projectId, - kmsService - ); - - const user = await userDAL.findById(actor.id); - if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required for this resource type" }); + } const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ gatewayId, duration, sessionId: session.id, resourceType: resource.resourceType as PamResource, - host: connectionDetails.host, - port: connectionDetails.port, + host: (connectionDetails as TSqlResourceConnectionDetails).host, + port: (connectionDetails as TSqlResourceConnectionDetails).port, actorMetadata: { id: actor.id, type: actor.type, @@ -581,11 +645,11 @@ export const pamAccountServiceFactory = ({ projectId: account.projectId })) as TSqlResourceConnectionDetails; - const credentials = await decryptAccountCredentials({ + const credentials = (await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, kmsService, projectId: account.projectId - }); + })) as TSqlAccountCredentials; metadata = { username: credentials.username, @@ -597,11 +661,11 @@ export const pamAccountServiceFactory = ({ break; case PamResource.SSH: { - const credentials = await decryptAccountCredentials({ + const credentials = (await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, kmsService, projectId: account.projectId - }); + })) as TSSHAccountCredentials; metadata = { username: credentials.username @@ -674,7 +738,7 @@ export const pamAccountServiceFactory = ({ const resource = await pamResourceDAL.findById(account.resourceId); if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); - if (resource.gatewayIdentityId !== actor.id) { + if (resource.gatewayId && resource.gatewayIdentityId !== actor.id) { throw new ForbiddenRequestError({ message: "Identity does not have access to fetch the PAM session credentials" }); @@ -738,7 +802,8 @@ export const pamAccountServiceFactory = ({ resourceType as PamResource, connectionDetails, gatewayId, - gatewayV2Service + gatewayV2Service, + account.projectId ); const newCredentials = await factory.rotateAccountCredentials( diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-federation.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-federation.ts new file mode 100644 index 000000000..367e908d3 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-federation.ts @@ -0,0 +1,209 @@ +import { AssumeRoleCommand, STSClient, STSClientConfig } from "@aws-sdk/client-sts"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto/cryptography"; + +import { TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types"; + +const AWS_STS_MIN_DURATION_SECONDS = 900; + +const createStsClient = (region: string): STSClient => { + const appCfg = getConfig(); + + const config: STSClientConfig = { + 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 + }; + + return new STSClient(config); +}; + +export const validatePamRoleConnection = async ( + connectionDetails: TAwsIamResourceConnectionDetails, + projectId: string +): Promise => { + const stsClient = createStsClient(connectionDetails.region); + + 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; + } catch { + return false; + } +}; + +export const validateTargetRoleAssumption = async ({ + connectionDetails, + targetRoleArn, + projectId +}: { + connectionDetails: TAwsIamResourceConnectionDetails; + targetRoleArn: string; + projectId: string; +}): Promise => { + const stsClient = createStsClient(connectionDetails.region); + + 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 + }) + ); + + if (!pamRoleCredentials.Credentials) { + return false; + } + + // Then use the PAM role credentials to assume the target role + const pamStsClient = new STSClient({ + region: connectionDetails.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: { + accessKeyId: pamRoleCredentials.Credentials.AccessKeyId!, + secretAccessKey: pamRoleCredentials.Credentials.SecretAccessKey!, + sessionToken: pamRoleCredentials.Credentials.SessionToken + } + }); + + await pamStsClient.send( + new AssumeRoleCommand({ + RoleArn: targetRoleArn, + RoleSessionName: `infisical-pam-target-validation-${Date.now()}`, + DurationSeconds: AWS_STS_MIN_DURATION_SECONDS, + ExternalId: projectId + }) + ); + + return true; + } catch { + return false; + } +}; + +/** + * Assumes the target role and generates a federated console sign-in URL. + */ +export const generateConsoleFederationUrl = async ({ + connectionDetails, + targetRoleArn, + roleSessionName, + projectId, + sessionDuration +}: { + connectionDetails: TAwsIamResourceConnectionDetails; + targetRoleArn: string; + roleSessionName: string; + projectId: string; + sessionDuration: number; +}): Promise<{ consoleUrl: string; expiresAt: Date }> => { + const stsClient = createStsClient(connectionDetails.region); + + // 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: connectionDetails.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: { + accessKeyId: pamRoleCredentials.Credentials.AccessKeyId!, + secretAccessKey: pamRoleCredentials.Credentials.SecretAccessKey!, + sessionToken: pamRoleCredentials.Credentials.SessionToken + } + }); + + const targetRoleCredentials = await pamStsClient.send( + new AssumeRoleCommand({ + RoleArn: targetRoleArn, + RoleSessionName: roleSessionName, + DurationSeconds: sessionDuration, + ExternalId: projectId + }) + ); + + if (!targetRoleCredentials.Credentials) { + throw new Error("Failed to assume target role"); + } + + const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = targetRoleCredentials.Credentials; + + // Generate federation URL + const sessionJson = JSON.stringify({ + sessionId: AccessKeyId, + sessionKey: SecretAccessKey, + sessionToken: SessionToken + }); + + const federationEndpoint = "https://signin.aws.amazon.com/federation"; + + // Console destination can be regional + const getConsoleHost = () => + connectionDetails.region === "us-east-1" + ? "console.aws.amazon.com" + : `${connectionDetails.region}.console.aws.amazon.com`; + + const signinTokenUrl = `${federationEndpoint}?Action=getSigninToken&Session=${encodeURIComponent(sessionJson)}`; + + const tokenResponse = await fetch(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)}`); + } + + 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)}`); + } + + if (!tokenData.SigninToken) { + throw new Error(`AWS federation endpoint did not return a SigninToken: ${responseText.substring(0, 200)}`); + } + + const consoleDestination = `https://${getConsoleHost()}/`; + const consoleUrl = `${federationEndpoint}?Action=login&SigninToken=${encodeURIComponent(tokenData.SigninToken)}&Destination=${encodeURIComponent(consoleDestination)}`; + + return { + consoleUrl, + expiresAt: Expiration + }; +}; diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-factory.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-factory.ts new file mode 100644 index 000000000..6fe7bfafe --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-factory.ts @@ -0,0 +1,111 @@ +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { PamResource } from "../pam-resource-enums"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../pam-resource-types"; +import { validatePamRoleConnection, validateTargetRoleAssumption } from "./aws-iam-federation"; +import { TAwsIamAccountCredentials, TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types"; + +export const awsIamResourceFactory: TPamResourceFactory = ( + resourceType: PamResource, + connectionDetails: TAwsIamResourceConnectionDetails, + // AWS IAM doesn't use gateway + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _gatewayId, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _gatewayV2Service, + projectId +) => { + const validateConnection = async () => { + try { + const isValid = await validatePamRoleConnection(connectionDetails, projectId ?? ""); + + if (!isValid) { + throw new BadRequestError({ + message: + "Unable to assume the PAM role. Verify the role ARN and ensure the trust policy allows Infisical to assume the role." + }); + } + + logger.info( + { roleArn: connectionDetails.roleArn, region: connectionDetails.region }, + "[AWS IAM Resource Factory] PAM role connection validated successfully" + ); + + return connectionDetails; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + logger.error(error, "[AWS IAM Resource Factory] Failed to validate PAM role connection"); + + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( + credentials + ) => { + try { + const isValid = await validateTargetRoleAssumption({ + connectionDetails, + targetRoleArn: credentials.targetRoleArn, + projectId: projectId ?? "" + }); + + if (!isValid) { + throw new BadRequestError({ + message: + "Unable to assume the target role. Verify the target role ARN and ensure the PAM role has permission to assume it." + }); + } + + logger.info( + { targetRoleArn: credentials.targetRoleArn }, + "[AWS IAM Resource Factory] Target role credentials validated successfully" + ); + + return credentials; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + logger.error(error, "[AWS IAM Resource Factory] Failed to validate target role credentials"); + + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials = async ( + _rotationAccountCredentials, + currentCredentials + ) => { + return currentCredentials; + }; + + const handleOverwritePreventionForCensoredValues = async ( + updatedAccountCredentials: TAwsIamAccountCredentials, + // AWS IAM has no censored credential values - role ARNs are not secrets + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _currentCredentials: TAwsIamAccountCredentials + ) => { + return updatedAccountCredentials; + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues + }; +}; diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-fns.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-fns.ts new file mode 100644 index 000000000..c421e29e6 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-fns.ts @@ -0,0 +1,22 @@ +import { BadRequestError } from "@app/lib/errors"; + +import { AwsIamResourceListItemSchema } from "./aws-iam-resource-schemas"; + +export const getAwsIamResourceListItem = () => { + return { + name: AwsIamResourceListItemSchema.shape.name.value, + resource: AwsIamResourceListItemSchema.shape.resource.value + }; +}; + +/** + * Extract the AWS Account ID from an IAM Role ARN + * ARN format: arn:aws:iam::123456789012:role/RoleName + */ +export const extractAwsAccountIdFromArn = (roleArn: string): string => { + const match = roleArn.match(/^arn:aws:iam::(\d{12}):role\//); + if (!match) { + throw new BadRequestError({ message: "Invalid IAM Role ARN format" }); + } + return match[1]; +}; diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas.ts new file mode 100644 index 000000000..e45807ae4 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; + +// AWS STS session duration limits (in seconds) +// Role chaining (Infisical → PAM role → target role) limits max session to 1 hour +// @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html +const AWS_STS_MIN_SESSION_DURATION = 900; // 15 minutes +const AWS_STS_MAX_SESSION_DURATION_ROLE_CHAINING = 3600; // 1 hour + +export const AwsIamResourceConnectionDetailsSchema = z.object({ + region: z.string().trim().min(1), + roleArn: z.string().trim().min(1) +}); + +export const AwsIamAccountCredentialsSchema = z.object({ + targetRoleArn: z.string().trim().min(1).max(2048), + maxSessionDuration: z.coerce + .number() + .min(AWS_STS_MIN_SESSION_DURATION) + .max(AWS_STS_MAX_SESSION_DURATION_ROLE_CHAINING) +}); + +const BaseAwsIamResourceSchema = BasePamResourceSchema.extend({ + resourceType: z.literal(PamResource.AwsIam), + gatewayId: z.string().uuid().nullable().optional() +}); + +export const AwsIamResourceSchema = BaseAwsIamResourceSchema.extend({ + connectionDetails: AwsIamResourceConnectionDetailsSchema, + rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedAwsIamResourceSchema = BaseAwsIamResourceSchema.extend({ + connectionDetails: AwsIamResourceConnectionDetailsSchema, + rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional() +}); + +export const AwsIamResourceListItemSchema = z.object({ + name: z.literal("AWS IAM"), + resource: z.literal(PamResource.AwsIam) +}); + +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() +}); + +export const AwsIamAccountSchema = BasePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema +}); + +export const CreateAwsIamAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema, + // AWS IAM doesn't support credential rotation - credentials are generated on-the-fly via STS + rotationEnabled: z.boolean().optional().default(false) +}); + +export const UpdateAwsIamAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema.optional() +}); + +export const SanitizedAwsIamAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: AwsIamAccountCredentialsSchema.pick({ + targetRoleArn: true, + maxSessionDuration: true + }) +}); diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-types.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-types.ts new file mode 100644 index 000000000..4e288df4e --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + AwsIamAccountCredentialsSchema, + AwsIamAccountSchema, + AwsIamResourceConnectionDetailsSchema, + AwsIamResourceSchema +} from "./aws-iam-resource-schemas"; + +// Resources +export type TAwsIamResource = z.infer; +export type TAwsIamResourceConnectionDetails = z.infer; + +// Accounts +export type TAwsIamAccount = z.infer; +export type TAwsIamAccountCredentials = z.infer; \ No newline at end of file diff --git a/backend/src/ee/services/pam-resource/aws-iam/index.ts b/backend/src/ee/services/pam-resource/aws-iam/index.ts new file mode 100644 index 000000000..8e41fa48a --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/index.ts @@ -0,0 +1,5 @@ +export * from "./aws-iam-federation"; +export * from "./aws-iam-resource-factory"; +export * from "./aws-iam-resource-fns"; +export * from "./aws-iam-resource-schemas"; +export * from "./aws-iam-resource-types"; diff --git a/backend/src/ee/services/pam-resource/pam-resource-dal.ts b/backend/src/ee/services/pam-resource/pam-resource-dal.ts index 9e5cbc985..e5b76a882 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-dal.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-dal.ts @@ -14,7 +14,7 @@ export const pamResourceDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex) => { const doc = await (tx || db.replicaNode())(TableName.PamResource) - .join(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`) + .leftJoin(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`) .select(selectAllTableCols(TableName.PamResource)) .select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName")) .select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId")) diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts index e4ec043e1..bea1667fb 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -1,7 +1,8 @@ export enum PamResource { Postgres = "postgres", MySQL = "mysql", - SSH = "ssh" + SSH = "ssh", + AwsIam = "aws-iam" } export enum PamResourceOrderBy { diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts index e2d0a50f8..1d1a84f33 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -1,3 +1,4 @@ +import { awsIamResourceFactory } from "./aws-iam/aws-iam-resource-factory"; import { PamResource } from "./pam-resource-enums"; import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; @@ -8,5 +9,6 @@ type TPamResourceFactoryImplementation = TPamResourceFactory = { [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation, - [PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation + [PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation, + [PamResource.AwsIam]: awsIamResourceFactory as TPamResourceFactoryImplementation }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts index cad087d2f..a90743a12 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-fns.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -3,12 +3,15 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; +import { getAwsIamResourceListItem } from "./aws-iam/aws-iam-resource-fns"; import { getMySQLResourceListItem } from "./mysql/mysql-resource-fns"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; export const listResourceOptions = () => { - return [getPostgresResourceListItem(), getMySQLResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); + return [getPostgresResourceListItem(), getMySQLResourceListItem(), getAwsIamResourceListItem()].sort((a, b) => + a.name.localeCompare(b.name) + ); }; // Resource diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index 0ebca02b5..abbbf651b 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -92,7 +92,8 @@ export const pamResourceServiceFactory = ({ resourceType, connectionDetails, gatewayId, - gatewayV2Service + gatewayV2Service, + projectId ); const validatedConnectionDetails = await factory.validateConnection(); @@ -162,7 +163,8 @@ export const pamResourceServiceFactory = ({ resource.resourceType as PamResource, connectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + resource.projectId ); const validatedConnectionDetails = await factory.validateConnection(); const encryptedConnectionDetails = await encryptResourceConnectionDetails({ @@ -189,7 +191,8 @@ export const pamResourceServiceFactory = ({ resource.resourceType as PamResource, decryptedConnectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + resource.projectId ); let finalCredentials = { ...rotationAccountCredentials }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index 9da094801..2a27fb76e 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -1,6 +1,12 @@ import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { + TAwsIamAccount, + TAwsIamAccountCredentials, + TAwsIamResource, + TAwsIamResourceConnectionDetails +} from "./aws-iam/aws-iam-resource-types"; import { TMySQLAccount, TMySQLAccountCredentials, @@ -22,22 +28,28 @@ import { } from "./ssh/ssh-resource-types"; // Resource types -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource; export type TPamResourceConnectionDetails = | TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails - | TSSHResourceConnectionDetails; + | TSSHResourceConnectionDetails + | TAwsIamResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; -// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount; + +export type TPamAccountCredentials = + | TPostgresAccountCredentials + // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents + | TMySQLAccountCredentials + | TSSHAccountCredentials + | TAwsIamAccountCredentials; // Resource DTOs -export type TCreateResourceDTO = Pick< - TPamResource, - "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials" ->; +export type TCreateResourceDTO = Pick & { + gatewayId?: string | null; + rotationAccountCredentials?: TPamAccountCredentials | null; +}; export type TUpdateResourceDTO = Partial> & { resourceId: string; @@ -65,8 +77,9 @@ export type TPamResourceFactoryRotateAccountCredentials = ( resourceType: PamResource, connectionDetails: T, - gatewayId: string, - gatewayV2Service: Pick + gatewayId: string | null | undefined, + gatewayV2Service: Pick, + projectId: string | null | undefined ) => { validateConnection: TPamResourceFactoryValidateConnection; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts index b90aa00c6..dfbb071e2 100644 --- a/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts @@ -60,6 +60,10 @@ export const sshResourceFactory: TPamResourceFactory { const validateConnection = async () => { try { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { return new Promise((resolve, reject) => { const client = new Client(); @@ -131,6 +135,10 @@ export const sshResourceFactory: TPamResourceFactory { try { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { return new Promise((resolve, reject) => { const client = new Client(); diff --git a/backend/src/ee/services/pam-session/pam-session-dal.ts b/backend/src/ee/services/pam-session/pam-session-dal.ts index f8b3a3393..ad14f4976 100644 --- a/backend/src/ee/services/pam-session/pam-session-dal.ts +++ b/backend/src/ee/services/pam-session/pam-session-dal.ts @@ -4,6 +4,8 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { PamSessionStatus } from "./pam-session-enums"; + export type TPamSessionDALFactory = ReturnType; export const pamSessionDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.PamSession); @@ -22,5 +24,19 @@ export const pamSessionDALFactory = (db: TDbClient) => { return session; }; - return { ...orm, findById }; + const expireSessionById = async (sessionId: string, tx?: Knex) => { + const now = new Date(); + + const updatedCount = await (tx || db)(TableName.PamSession) + .where("id", sessionId) + .whereIn("status", [PamSessionStatus.Active, PamSessionStatus.Starting]) + .update({ + status: PamSessionStatus.Expired, + endedAt: now + }); + + return updatedCount; + }; + + return { ...orm, findById, expireSessionById }; }; diff --git a/backend/src/ee/services/pam-session/pam-session-enums.ts b/backend/src/ee/services/pam-session/pam-session-enums.ts index 87731f577..299f2c29a 100644 --- a/backend/src/ee/services/pam-session/pam-session-enums.ts +++ b/backend/src/ee/services/pam-session/pam-session-enums.ts @@ -2,5 +2,6 @@ export enum PamSessionStatus { Starting = "starting", // Starting, user connecting to resource Active = "active", // Active, user is connected to resource Ended = "ended", // Ended by user - Terminated = "terminated" // Terminated by an admin + Terminated = "terminated", // Terminated by an admin + Expired = "expired" // Automatically expired after expiresAt timestamp } diff --git a/backend/src/ee/services/pam-session/pam-session-service.ts b/backend/src/ee/services/pam-session/pam-session-service.ts index 18c185cac..d984517e4 100644 --- a/backend/src/ee/services/pam-session/pam-session-service.ts +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -34,9 +34,40 @@ export const pamSessionServiceFactory = ({ licenseService, kmsService }: TPamSessionServiceFactoryDep) => { + // Helper to check and update expired sessions when viewing session details (redundancy for scheduled job) + // Only applies to non-gateway sessions (e.g., AWS IAM) - gateway sessions are managed by the gateway + // This is intentionally only called in getById (session details view), not in list + const checkAndExpireSessionIfNeeded = async < + T extends { id: string; status: string; expiresAt: Date | null; gatewayIdentityId?: string | null } + >( + session: T + ): Promise => { + // Skip gateway-based sessions - they have their own lifecycle managed by the gateway + if (session.gatewayIdentityId) { + return session; + } + + const isActive = session.status === PamSessionStatus.Active || session.status === PamSessionStatus.Starting; + const isExpired = session.expiresAt && new Date(session.expiresAt) <= new Date(); + + if (isActive && isExpired) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const updatedSession = await pamSessionDAL.updateById(session.id, { + status: PamSessionStatus.Expired, + endedAt: new Date() + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return { ...session, ...updatedSession }; + } + + return session; + }; + const getById = async (sessionId: string, actor: OrgServiceActor) => { - const session = await pamSessionDAL.findById(sessionId); - if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + const sessionFromDb = await pamSessionDAL.findById(sessionId); + if (!sessionFromDb) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const session = await checkAndExpireSessionIfNeeded(sessionFromDb); const { permission } = await permissionService.getProjectPermission({ actor: actor.type, @@ -116,7 +147,7 @@ export const pamSessionServiceFactory = ({ OrgPermissionSubjects.Gateway ); - if (session.gatewayIdentityId !== actor.id) { + if (session.gatewayIdentityId && session.gatewayIdentityId !== actor.id) { throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" }); } @@ -158,7 +189,7 @@ export const pamSessionServiceFactory = ({ OrgPermissionSubjects.Gateway ); - if (session.gatewayIdentityId !== actor.id) { + if (session.gatewayIdentityId && session.gatewayIdentityId !== actor.id) { throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" }); } } else if (actor.type === ActorType.USER) { @@ -169,7 +200,7 @@ export const pamSessionServiceFactory = ({ throw new ForbiddenRequestError({ message: "Only identities and users can perform this action" }); } - if (session.status === PamSessionStatus.Ended) { + if (session.status === PamSessionStatus.Ended || session.status === PamSessionStatus.Expired) { return { session, projectId: project.id diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index c46e9c023..0243c81f2 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -83,6 +83,7 @@ export enum QueueName { HealthAlert = "health-alert", CertificateV3AutoRenewal = "certificate-v3-auto-renewal", PamAccountRotation = "pam-account-rotation", + PamSessionExpiration = "pam-session-expiration", PkiAcmeChallengeValidation = "pki-acme-challenge-validation" } @@ -138,6 +139,7 @@ export enum QueueJobs { HealthAlert = "health-alert", CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal", PamAccountRotation = "pam-account-rotation", + PamSessionExpiration = "pam-session-expiration", PkiAcmeChallengeValidation = "pki-acme-challenge-validation" } @@ -404,6 +406,10 @@ export type TQueueJobTypes = { name: QueueJobs.PamAccountRotation; payload: undefined; }; + [QueueName.PamSessionExpiration]: { + name: QueueJobs.PamSessionExpiration; + payload: { sessionId: string }; + }; [QueueName.PkiAcmeChallengeValidation]: { name: QueueJobs.PkiAcmeChallengeValidation; payload: { challengeId: string }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 914491d3c..cee4f58ae 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -279,6 +279,7 @@ import { orgServiceFactory } from "@app/services/org/org-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue"; +import { pamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue"; import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; @@ -2412,6 +2413,11 @@ export const registerRoutes = async ( gatewayV2Service }); + const pamSessionExpirationService = pamSessionExpirationServiceFactory({ + queueService, + pamSessionDAL + }); + const pamAccountService = pamAccountServiceFactory({ pamAccountDAL, gatewayV2Service, @@ -2423,7 +2429,8 @@ export const registerRoutes = async ( permissionService, projectDAL, userDAL, - auditLogService + auditLogService, + pamSessionExpirationService }); const pamAccountRotation = pamAccountRotationServiceFactory({ @@ -2490,6 +2497,7 @@ export const registerRoutes = async ( await healthAlert.init(); await pkiSyncCleanup.init(); await pamAccountRotation.init(); + await pamSessionExpirationService.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); diff --git a/backend/src/services/pam-session-expiration/pam-session-expiration-queue.ts b/backend/src/services/pam-session-expiration/pam-session-expiration-queue.ts new file mode 100644 index 000000000..f14da58f0 --- /dev/null +++ b/backend/src/services/pam-session-expiration/pam-session-expiration-queue.ts @@ -0,0 +1,81 @@ +import { TPamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +type TPamSessionExpirationServiceFactoryDep = { + queueService: TQueueServiceFactory; + pamSessionDAL: Pick; +}; + +export type TPamSessionExpirationServiceFactory = ReturnType; + +export const pamSessionExpirationServiceFactory = ({ + queueService, + pamSessionDAL +}: TPamSessionExpirationServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.startPg( + QueueJobs.PamSessionExpiration, + async (jobs) => { + await Promise.all( + jobs.map(async (job) => { + const { sessionId } = job.data; + try { + logger.info({ sessionId }, `${QueueName.PamSessionExpiration}: expiring session`); + const updated = await pamSessionDAL.expireSessionById(sessionId); + if (updated > 0) { + logger.info({ sessionId }, `${QueueName.PamSessionExpiration}: session expired successfully`); + } else { + logger.info( + { sessionId }, + `${QueueName.PamSessionExpiration}: session not expired (already ended or not found)` + ); + } + } catch (error) { + logger.error(error, `${QueueName.PamSessionExpiration}: failed to expire session ${sessionId}`); + throw error; + } + }) + ); + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 30 + } + ); + }; + + // Schedule a session expiration job to run at the session's expiresAt time + const scheduleSessionExpiration = async (sessionId: string, expiresAt: Date) => { + const now = new Date(); + const delayMs = Math.max(0, expiresAt.getTime() - now.getTime()); + const startAfter = new Date(now.getTime() + delayMs); + + await queueService.queuePg( + QueueJobs.PamSessionExpiration, + { sessionId }, + { + startAfter, + singletonKey: `pam-session-expiration-${sessionId}` + } + ); + + logger.info( + { sessionId, expiresAt: expiresAt.toISOString(), scheduledFor: startAfter.toISOString() }, + `${QueueName.PamSessionExpiration}: scheduled session expiration` + ); + }; + + return { + init, + scheduleSessionExpiration + }; +}; diff --git a/frontend/src/hooks/api/pam/enums.ts b/frontend/src/hooks/api/pam/enums.ts index 2c86d9921..9da4e74b8 100644 --- a/frontend/src/hooks/api/pam/enums.ts +++ b/frontend/src/hooks/api/pam/enums.ts @@ -16,7 +16,8 @@ export enum PamResourceType { CockroachDB = "cockroachdb", Elasticsearch = "elasticsearch", Snowflake = "snowflake", - DynamoDB = "dynamodb" + DynamoDB = "dynamodb", + AwsIam = "aws-iam" } export enum PamResourceOrderBy { @@ -28,7 +29,8 @@ export enum PamSessionStatus { Starting = "starting", Active = "active", Ended = "ended", - Terminated = "terminated" + Terminated = "terminated", + Expired = "expired" } // Accounts diff --git a/frontend/src/hooks/api/pam/maps.ts b/frontend/src/hooks/api/pam/maps.ts index 90286a05d..e42eb7748 100644 --- a/frontend/src/hooks/api/pam/maps.ts +++ b/frontend/src/hooks/api/pam/maps.ts @@ -20,5 +20,6 @@ export const PAM_RESOURCE_TYPE_MAP: Record< [PamResourceType.CockroachDB]: { name: "CockroachDB", image: "CockroachDB.png" }, [PamResourceType.Elasticsearch]: { name: "Elasticsearch", image: "Elastic.png" }, [PamResourceType.Snowflake]: { name: "Snowflake", image: "Snowflake.png" }, - [PamResourceType.DynamoDB]: { name: "DynamoDB", image: "DynamoDB.png", size: 55 } + [PamResourceType.DynamoDB]: { name: "DynamoDB", image: "DynamoDB.png", size: 55 }, + [PamResourceType.AwsIam]: { name: "AWS IAM", image: "Amazon Web Services.png" } }; diff --git a/frontend/src/hooks/api/pam/mutations.tsx b/frontend/src/hooks/api/pam/mutations.tsx index c5d6ff05b..4cf8b1d9c 100644 --- a/frontend/src/hooks/api/pam/mutations.tsx +++ b/frontend/src/hooks/api/pam/mutations.tsx @@ -120,6 +120,41 @@ export const useDeletePamAccount = () => { }); }; +export type TAccessPamAccountDTO = { + accountId: string; + duration: string; +}; + +export type TAccessPamAccountResponse = { + sessionId: string; + resourceType: string; + consoleUrl?: string; + metadata?: Record; + relayClientCertificate?: string; + relayClientPrivateKey?: string; + relayServerCertificateChain?: string; + gatewayClientCertificate?: string; + gatewayClientPrivateKey?: string; + gatewayServerCertificateChain?: string; + relayHost?: string; +}; + +export const useAccessPamAccount = () => { + return useMutation({ + mutationFn: async ({ accountId, duration }: TAccessPamAccountDTO) => { + const { data } = await apiRequest.post( + "/api/v1/pam/accounts/access", + { + accountId, + duration + } + ); + + return data; + } + }); +}; + // Folders export const useCreatePamFolder = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/pam/types/aws-iam-resource.ts b/frontend/src/hooks/api/pam/types/aws-iam-resource.ts new file mode 100644 index 000000000..ced875f30 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/aws-iam-resource.ts @@ -0,0 +1,26 @@ +import { PamResourceType } from "../enums"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +export type TAwsIamConnectionDetails = { + region: string; + roleArn: string; +}; + +export type TAwsIamCredentials = { + targetRoleArn: string; + maxSessionDuration: number; +}; + +export type TAwsIamResource = Omit & { + resourceType: PamResourceType.AwsIam; + gatewayId?: string | null; + connectionDetails: TAwsIamConnectionDetails; +}; + +export type TAwsIamAccount = Omit< + TBasePamAccount, + "rotationEnabled" | "rotationIntervalSeconds" | "lastRotatedAt" +> & { + credentials: TAwsIamCredentials; +}; diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 01b87c282..878a35aa1 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -6,17 +6,19 @@ import { PamResourceType, PamSessionStatus } from "../enums"; +import { TAwsIamAccount, TAwsIamResource } from "./aws-iam-resource"; import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; import { TSSHAccount, TSSHResource } from "./ssh-resource"; +export * from "./aws-iam-resource"; export * from "./mysql-resource"; export * from "./postgres-resource"; export * from "./ssh-resource"; -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource; -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount; export type TPamFolder = { id: string; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx index 8e9a0f8da..5d8a2c9ef 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -1,12 +1,20 @@ import { useMemo, useState } from "react"; import { faCopy } from "@fortawesome/free-regular-svg-icons"; -import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { faExternalLink, faUpRightFromSquare, faWarning } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import ms from "ms"; import { createNotification } from "@app/components/notifications"; -import { FormLabel, IconButton, Input, Modal, ModalContent } from "@app/components/v2"; -import { PamResourceType, TPamAccount } from "@app/hooks/api/pam"; +import { + Button, + FormControl, + FormLabel, + IconButton, + Input, + Modal, + ModalContent +} from "@app/components/v2"; +import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam"; type Props = { account?: TPamAccount; @@ -14,7 +22,108 @@ type Props = { onOpenChange: (isOpen: boolean) => void; }; -export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => { +const AwsIamAccessContent = ({ + account, + onOpenChange +}: { + account: TPamAccount; + onOpenChange: (isOpen: boolean) => void; +}) => { + const [durationInput, setDurationInput] = useState("1h"); + const accessPamAccount = useAccessPamAccount(); + + const parsedDuration = useMemo(() => { + try { + const milliseconds = ms(durationInput); + if (!milliseconds) return null; + const seconds = Math.floor(milliseconds / 1000); + // Min 15 minutes (900s), max 1 hour (3600s) due to AWS role chaining limitation + if (seconds < 900 || seconds > 3600) return null; + return seconds; + } catch { + return null; + } + }, [durationInput]); + + const handleAccessConsole = async () => { + if (!parsedDuration) return; + + try { + const response = await accessPamAccount.mutateAsync({ + accountId: account.id, + duration: `${parsedDuration}s` + }); + + if (response.consoleUrl) { + // Open the AWS Console URL in a new tab + window.open(response.consoleUrl, "_blank", "noopener,noreferrer"); + + createNotification({ + text: "AWS Console opened in new tab", + type: "success" + }); + + onOpenChange(false); + } else { + createNotification({ + text: "Failed to generate AWS Console URL", + type: "error" + }); + } + } catch { + createNotification({ + text: "Failed to access AWS Console", + type: "error" + }); + } + }; + + return ( + <> + 0 && !parsedDuration} + errorText="Invalid duration. Use format like 15m, 30m, 1h" + > + setDurationInput(e.target.value)} + placeholder="1h" + /> + + +
+
+ +
+ Important: AWS Console sessions cannot be terminated early. The session + remains active until the STS token expires. All activity is logged in AWS CloudTrail. +
+
+
+ + + + ); +}; + +const CliAccessContent = ({ + account, + onOpenChange +}: { + account: TPamAccount; + onOpenChange: (isOpen: boolean) => void; +}) => { const { protocol, hostname, port } = window.location; const portSuffix = port && port !== "80" && port !== "443" ? `:${port}` : ""; const siteURL = `${protocol}//${hostname}${portSuffix}`; @@ -76,56 +185,74 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) } }, [account, cliDuration]); + return ( + <> + + setDuration(e.target.value)} + placeholder="permanent" + isError={!isDurationValid} + /> + +
+ + { + navigator.clipboard.writeText(command); + + createNotification({ + text: "Command copied to clipboard", + type: "info" + }); + + onOpenChange(false); + }} + className="w-10" + > + + +
+ + Install the Infisical CLI + + + + ); +}; + +export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => { if (!account) return null; + const isAwsIam = account.resource.resourceType === PamResourceType.AwsIam; + return ( - - setDuration(e.target.value)} - placeholder="permanent" - isError={!isDurationValid} - /> - -
- - { - navigator.clipboard.writeText(command); - - createNotification({ - text: "Command copied to clipboard", - type: "info" - }); - - onOpenChange(false); - }} - className="w-10" - > - - -
- - Install the Infisical CLI - - + {isAwsIam ? ( + + ) : ( + + )}
); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx new file mode 100644 index 000000000..21ab2a41f --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx @@ -0,0 +1,206 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + ModalClose +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { PamResourceType, TAwsIamAccount } from "@app/hooks/api/pam"; + +import { GenericAccountFields } from "./GenericAccountFields"; + +type Props = { + account?: TAwsIamAccount; + resourceId?: string; + resourceType?: PamResourceType; + onSubmit: (formData: FormData) => Promise; +}; + +const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/; + +const AwsIamCredentialsSchema = z.object({ + targetRoleArn: z + .string() + .trim() + .min(1, "Target Role ARN is required") + .refine((val) => arnRoleRegex.test(val), { + message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'" + }), + // Max 1 hour (3600s) due to AWS role chaining limitation, min 15 min (900s) + maxSessionDuration: z.coerce + .number() + .min(900, "Minimum session duration is 900 seconds (15 minutes)") + .max(3600, "Maximum session duration is 3600 seconds (1 hour)") + .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({ + credentials: AwsIamCredentialsSchema +}); + +type FormData = z.infer; + +export const AwsIamAccountForm = ({ account, onSubmit }: Props) => { + const isUpdate = Boolean(account); + const { projectId } = useProject(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: account ?? { + name: "", + description: "", + credentials: { + targetRoleArn: "", + maxSessionDuration: 3600 + } + } + }); + + const { + control, + handleSubmit, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ + +
+

AWS IAM Configuration

+ + ( + + + + )} + /> + + ( + + + + )} + /> +
+ + + + Target Role Setup + +

+ The target role must have a trust policy that allows the Infisical PAM role to + assume it. If you used the{" "} + infisical-pam-*{" "} + naming convention, no additional changes are needed to the PAM role. +

+ +

+ Target role trust policy: +

+
+                {`{
+  "Version": "2012-10-17",
+  "Statement": [{
+    "Effect": "Allow",
+    "Principal": {
+      "AWS": "arn:aws:iam:::role/"
+    },
+    "Action": "sts:AssumeRole",
+    "Condition": {
+      "StringEquals": {
+        "sts:ExternalId": "${projectId}"
+      }
+    }
+  }]
+}`}
+              
+

+ Note: Replace{" "} + <YOUR_ACCOUNT_ID> with + your AWS account ID and{" "} + <YOUR_PAM_ROLE_NAME>{" "} + with the name of the PAM role you created (e.g.,{" "} + InfisicalPAMRole). The + External ID {projectId} is + your current project ID. If your target role name doesn't follow the{" "} + infisical-pam-* pattern, you + must update the PAM role's permissions policy to include the target role ARN. +

+
+
+
+ +
+

+ Note: While users cannot terminate AWS Console sessions directly, + administrators can revoke active sessions by using the{" "} + + Revoke Sessions + {" "} + feature in the IAM console. All activity is logged in AWS CloudTrail. +

+
+ +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index fc1662349..b5d2d4c4c 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -8,6 +8,7 @@ import { import { DiscriminativePick } from "@app/types"; import { PamAccountHeader } from "../PamAccountHeader"; +import { AwsIamAccountForm } from "./AwsIamAccountForm"; import { MySQLAccountForm } from "./MySQLAccountForm"; import { PostgresAccountForm } from "./PostgresAccountForm"; import { SshAccountForm } from "./SshAccountForm"; @@ -70,6 +71,10 @@ const CreateForm = ({ return ( ); + case PamResourceType.AwsIam: + return ( + + ); default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -100,6 +105,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => { return ; case PamResourceType.SSH: return ; + case PamResourceType.AwsIam: + return ; default: throw new Error(`Unhandled resource: ${account.resource.resourceType}`); } diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx index f9ce8f33b..9ea842bc3 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx @@ -41,6 +41,7 @@ type Props = { search: string; isFlatView: boolean; accountPath?: string; + isAccessLoading?: boolean; }; export const PamAccountRow = ({ @@ -50,7 +51,8 @@ export const PamAccountRow = ({ onUpdate, onDelete, isFlatView, - accountPath + accountPath, + isAccessLoading }: Props) => { const { id, name } = account; @@ -127,6 +129,8 @@ export const PamAccountRow = ({ leftIcon={} onClick={() => onAccess(account)} size="xs" + isLoading={isAccessLoading} + isDisabled={isAccessLoading} > Access diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx index d45f89a90..fc352c59a 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountsTable.tsx @@ -52,6 +52,8 @@ import { PAM_RESOURCE_TYPE_MAP, PamAccountOrderBy, PamAccountView, + PamResourceType, + TPamAccount, TPamFolder } from "@app/hooks/api/pam"; import { useListPamAccounts, useListPamResources } from "@app/hooks/api/pam/queries"; @@ -67,6 +69,7 @@ import { PamDeleteFolderModal } from "./PamDeleteFolderModal"; import { PamFolderRow } from "./PamFolderRow"; import { PamUpdateAccountModal } from "./PamUpdateAccountModal"; import { PamUpdateFolderModal } from "./PamUpdateFolderModal"; +import { useAccessAwsIamAccount } from "./useAccessAwsIamAccount"; type PamAccountFilter = { resourceIds: string[]; @@ -78,6 +81,7 @@ type Props = { export const PamAccountsTable = ({ projectId }: Props) => { const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path }); + const { accessAwsIam, loadingAccountId } = useAccessAwsIamAccount(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "misc", @@ -419,8 +423,14 @@ export const PamAccountsTable = ({ projectId }: Props) => { search={search} isFlatView={accountView === PamAccountView.Flat} accountPath={account.folderId ? folderPaths[account.folderId] : undefined} - onAccess={(e) => { - handlePopUpOpen("accessAccount", e); + isAccessLoading={loadingAccountId === account.id} + onAccess={(e: TPamAccount) => { + // For AWS IAM, directly open console without modal + if (e.resource.resourceType === PamResourceType.AwsIam) { + accessAwsIam(e); + } else { + handlePopUpOpen("accessAccount", e); + } }} onUpdate={(e) => handlePopUpOpen("updateAccount", e)} onDelete={(e) => handlePopUpOpen("deleteAccount", e)} diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAddAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAddAccountModal.tsx index ade26f06f..6074e572a 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAddAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAddAccountModal.tsx @@ -14,36 +14,6 @@ type Props = { currentFolderId: string | null; }; -type ContentProps = { - onComplete: (account: TPamAccount) => void; - projectId: string; - currentFolderId: string | null; -}; - -const Content = ({ onComplete, projectId, currentFolderId }: ContentProps) => { - const [selectedResource, setSelectedResource] = useState<{ - id: string; - name: string; - resourceType: PamResourceType; - } | null>(null); - - if (selectedResource) { - return ( - setSelectedResource(null)} - resourceId={selectedResource.id} - resourceName={selectedResource.name} - resourceType={selectedResource.resourceType} - projectId={projectId} - folderId={currentFolderId ?? undefined} - /> - ); - } - - return setSelectedResource(e.resource)} />; -}; - export const PamAddAccountModal = ({ isOpen, onOpenChange, @@ -51,22 +21,44 @@ export const PamAddAccountModal = ({ onComplete, currentFolderId }: Props) => { + const [selectedResource, setSelectedResource] = useState<{ + id: string; + name: string; + resourceType: PamResourceType; + } | null>(null); + + const handleOpenChange = (open: boolean) => { + if (!open) { + // Reset state when modal closes + setSelectedResource(null); + } + onOpenChange(open); + }; + return ( - + - { - if (onComplete) onComplete(account); - onOpenChange(false); - }} - currentFolderId={currentFolderId} - /> + {selectedResource ? ( + { + if (onComplete) onComplete(account); + onOpenChange(false); + }} + onBack={() => setSelectedResource(null)} + resourceId={selectedResource.id} + resourceName={selectedResource.name} + resourceType={selectedResource.resourceType} + projectId={projectId} + folderId={currentFolderId ?? undefined} + /> + ) : ( + setSelectedResource(e.resource)} /> + )} ); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/useAccessAwsIamAccount.tsx b/frontend/src/pages/pam/PamAccountsPage/components/useAccessAwsIamAccount.tsx new file mode 100644 index 000000000..fc3eb96d0 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/useAccessAwsIamAccount.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam"; +import { TAwsIamCredentials } from "@app/hooks/api/pam/types"; + +export const useAccessAwsIamAccount = () => { + const accessPamAccount = useAccessPamAccount(); + const [loadingAccountId, setLoadingAccountId] = useState(null); + + const accessAwsIam = async (account: TPamAccount) => { + if (account.resource.resourceType !== PamResourceType.AwsIam) { + return false; + } + + setLoadingAccountId(account.id); + + try { + const response = await accessPamAccount.mutateAsync({ + accountId: account.id, + duration: `${(account.credentials as TAwsIamCredentials).maxSessionDuration}s` + }); + + if (response.consoleUrl) { + // Open the AWS Console URL in a new tab + window.open(response.consoleUrl, "_blank", "noopener,noreferrer"); + + createNotification({ + text: "AWS Console opened in new tab", + type: "success" + }); + + return true; + } + + createNotification({ + text: "Failed to generate AWS Console URL", + type: "error" + }); + + return false; + } finally { + setLoadingAccountId(null); + } + }; + + return { + accessAwsIam, + isPending: accessPamAccount.isPending, + loadingAccountId + }; +}; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx new file mode 100644 index 000000000..0c8ef0808 --- /dev/null +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx @@ -0,0 +1,204 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + ModalClose +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { PamResourceType, TAwsIamResource } from "@app/hooks/api/pam"; +import { slugSchema } from "@app/lib/schemas"; + +type Props = { + resource?: TAwsIamResource; + onSubmit: (formData: FormData) => Promise; +}; + +const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/; + +const AwsIamConnectionDetailsSchema = z.object({ + region: z.string().trim().min(1, "Region is required"), + roleArn: z + .string() + .trim() + .min(1, "PAM Role ARN is required") + .refine((val) => arnRoleRegex.test(val), { + message: "ARN must be in the format 'arn:aws:iam::123456789012:role/RoleName'" + }) +}); + +const formSchema = z.object({ + name: slugSchema({ min: 1, max: 64, field: "Name" }), + resourceType: z.literal(PamResourceType.AwsIam), + connectionDetails: AwsIamConnectionDetailsSchema +}); + +type FormData = z.infer; + +// Infisical AWS account IDs for trust policy +const INFISICAL_AWS_ACCOUNT_US = "381492033652"; +const INFISICAL_AWS_ACCOUNT_EU = "345594589636"; + +export const AwsIamResourceForm = ({ resource, onSubmit }: Props) => { + const isUpdate = Boolean(resource); + const { projectId } = useProject(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: resource ?? { + resourceType: PamResourceType.AwsIam, + connectionDetails: { + region: "", + roleArn: "" + } + } + }); + + const { + control, + handleSubmit, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + + + AWS IAM Role Setup + +

+ Before creating this resource, you need to set up an IAM role in your AWS account + that Infisical can assume. Follow these steps: +

+ +

+ Step 1: Create a permissions policy for assuming target roles +

+

+ This policy allows the PAM role to assume target roles. We recommend using the{" "} + infisical-pam-*{" "} + naming convention for target roles. +

+
+                {`{
+  "Version": "2012-10-17",
+  "Statement": [{
+    "Effect": "Allow",
+    "Action": "sts:AssumeRole",
+    "Resource": "arn:aws:iam:::role/infisical-pam-*"
+  }]
+}`}
+              
+ +

+ Step 2: Create the PAM role with a trust policy +

+

+ Create an IAM role (e.g.,{" "} + InfisicalPAMRole) + with the permissions policy above and the following trust policy: +

+
+                {`{
+  "Version": "2012-10-17",
+  "Statement": [{
+    "Effect": "Allow",
+    "Principal": {
+      "AWS": "arn:aws:iam::${INFISICAL_AWS_ACCOUNT_US}:root"
+    },
+    "Action": "sts:AssumeRole",
+    "Condition": {
+      "StringEquals": {
+        "sts:ExternalId": "${projectId}"
+      }
+    }
+  }]
+}`}
+              
+

+ Note: Use{" "} + {INFISICAL_AWS_ACCOUNT_US}{" "} + for US region or{" "} + {INFISICAL_AWS_ACCOUNT_EU}{" "} + for EU region. The External ID{" "} + {projectId} is your current + project ID. +

+
+
+
+ +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx index a1cc7cb1b..f15e28f85 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx @@ -9,6 +9,7 @@ import { import { DiscriminativePick } from "@app/types"; import { PamResourceHeader } from "../PamResourceHeader"; +import { AwsIamResourceForm } from "./AwsIamResourceForm"; import { MySQLResourceForm } from "./MySQLResourceForm"; import { PostgresResourceForm } from "./PostgresResourceForm"; import { SSHResourceForm } from "./SSHResourceForm"; @@ -54,6 +55,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) => return ; case PamResourceType.SSH: return ; + case PamResourceType.AwsIam: + return ; default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -84,6 +87,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => { return ; case PamResourceType.SSH: return ; + case PamResourceType.AwsIam: + return ; default: throw new Error(`Unhandled resource: ${(resource as any).resourceType}`); } diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx index 86fd7c1dc..f65537743 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx @@ -1,3 +1,6 @@ +import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam"; import { CommandLogView } from "./CommandLogView"; @@ -13,6 +16,7 @@ export const PamSessionLogsSection = ({ session }: Props) => { const isDatabaseSession = session.resourceType === PamResourceType.Postgres || session.resourceType === PamResourceType.MySQL; + const isAwsIamSession = session.resourceType === PamResourceType.AwsIam; const hasLogs = session.logs.length > 0; return ( @@ -23,7 +27,27 @@ export const PamSessionLogsSection = ({ session }: Props) => { {isDatabaseSession && hasLogs && } {isSSHSession && hasLogs && } - {!hasLogs && ( + {isAwsIamSession && ( +
+
+
AWS Console session activity is logged in AWS CloudTrail
+
+ View detailed activity logs for this session in your AWS CloudTrail console. +
+ + Open AWS CloudTrail + + +
+
+
+ )} + {!hasLogs && !isAwsIamSession && (
Session logs are not yet available
diff --git a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionStatusBadge.tsx b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionStatusBadge.tsx index 1b347210e..d701d3e23 100644 --- a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionStatusBadge.tsx +++ b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionStatusBadge.tsx @@ -2,6 +2,7 @@ import { ActivityIcon, BanIcon, ChevronsLeftRightEllipsisIcon, + ClockIcon, GavelIcon, LucideIcon } from "lucide-react"; @@ -33,6 +34,10 @@ const PAM_SESSION_STATUS_CONFIG: Record = { [PamSessionStatus.Ended]: { variant: "neutral", icon: BanIcon + }, + [PamSessionStatus.Expired]: { + variant: "warning", + icon: ClockIcon } };