From b589ab3be4858c0e4fd38d8b7c105d7646c55f2b Mon Sep 17 00:00:00 2001 From: Victor Santos Date: Thu, 4 Dec 2025 23:41:36 -0300 Subject: [PATCH 01/16] feat: add AWS IAM resource support with console access functionality - Introduced AWS IAM resource type in the system, allowing users to create and manage AWS IAM accounts. - Implemented AWS IAM resource forms and account forms for creating and updating IAM resources and accounts. - Added functionality to generate AWS Console URLs for IAM accounts, enabling direct access to the AWS Console. - Updated various components and hooks to handle AWS IAM-specific logic, including session expiration and access management. - Enhanced the UI to reflect AWS IAM integration, including new modals and forms for user interaction. --- .../20251203224427_pam-aws-console.ts | 21 ++ backend/src/db/schemas/pam-resources.ts | 2 +- .../ee/routes/v1/pam-account-routers/index.ts | 14 ++ .../pam-account-routers/pam-account-router.ts | 39 ++-- .../routes/v1/pam-resource-routers/index.ts | 14 ++ .../pam-resource-endpoints.ts | 8 +- .../pam-resource-router.ts | 10 +- .../ee/services/audit-log/audit-log-types.ts | 2 +- .../pam-account/pam-account-service.ts | 107 +++++++-- .../aws-iam/aws-iam-federation.ts | 209 +++++++++++++++++ .../aws-iam/aws-iam-resource-factory.ts | 111 +++++++++ .../aws-iam/aws-iam-resource-fns.ts | 22 ++ .../aws-iam/aws-iam-resource-schemas.ts | 84 +++++++ .../aws-iam/aws-iam-resource-types.ts | 16 ++ .../ee/services/pam-resource/aws-iam/index.ts | 5 + .../services/pam-resource/pam-resource-dal.ts | 2 +- .../pam-resource/pam-resource-enums.ts | 3 +- .../pam-resource/pam-resource-factory.ts | 4 +- .../services/pam-resource/pam-resource-fns.ts | 5 +- .../pam-resource/pam-resource-service.ts | 9 +- .../pam-resource/pam-resource-types.ts | 35 ++- .../pam-resource/ssh/ssh-resource-factory.ts | 8 + .../services/pam-session/pam-session-dal.ts | 18 +- .../services/pam-session/pam-session-enums.ts | 3 +- .../pam-session/pam-session-service.ts | 41 +++- backend/src/queue/queue-service.ts | 6 + backend/src/server/routes/index.ts | 10 +- .../pam-session-expiration-queue.ts | 81 +++++++ frontend/src/hooks/api/pam/enums.ts | 6 +- frontend/src/hooks/api/pam/maps.ts | 3 +- frontend/src/hooks/api/pam/mutations.tsx | 35 +++ .../hooks/api/pam/types/aws-iam-resource.ts | 26 +++ frontend/src/hooks/api/pam/types/index.ts | 6 +- .../components/PamAccessAccountModal.tsx | 219 ++++++++++++++---- .../PamAccountForm/AwsIamAccountForm.tsx | 206 ++++++++++++++++ .../PamAccountForm/PamAccountForm.tsx | 7 + .../components/PamAccountRow.tsx | 6 +- .../components/PamAccountsTable.tsx | 14 +- .../components/PamAddAccountModal.tsx | 72 +++--- .../components/useAccessAwsIamAccount.tsx | 52 +++++ .../PamResourceForm/AwsIamResourceForm.tsx | 204 ++++++++++++++++ .../PamResourceForm/PamResourceForm.tsx | 5 + .../components/PamSessionLogsSection.tsx | 26 ++- .../components/PamSessionStatusBadge.tsx | 5 + 44 files changed, 1618 insertions(+), 163 deletions(-) create mode 100644 backend/src/db/migrations/20251203224427_pam-aws-console.ts create mode 100644 backend/src/ee/services/pam-resource/aws-iam/aws-iam-federation.ts create mode 100644 backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-factory.ts create mode 100644 backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-fns.ts create mode 100644 backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas.ts create mode 100644 backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-types.ts create mode 100644 backend/src/ee/services/pam-resource/aws-iam/index.ts create mode 100644 backend/src/services/pam-session-expiration/pam-session-expiration-queue.ts create mode 100644 frontend/src/hooks/api/pam/types/aws-iam-resource.ts create mode 100644 frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx create mode 100644 frontend/src/pages/pam/PamAccountsPage/components/useAccessAwsIamAccount.tsx create mode 100644 frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx 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 0000000000..adadb9e995 --- /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 325f6eddc6..f59aae5d87 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 d3aadd5a41..6b6a4cbed6 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 0b7f89b6ce..d201ea0e23 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 5dae317da2..fcd9840b4a 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 ffbeae5c0f..e3803316ef 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 3536e7a99d..b6a7532edd 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 36b49a37cb..ff8bcc4bf6 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 1eae8df15c..fe2f558aa1 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 0000000000..367e908d3e --- /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 0000000000..6fe7bfafe8 --- /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 0000000000..c421e29e6d --- /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 0000000000..e45807ae4e --- /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 0000000000..4e288df4e1 --- /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 0000000000..8e41fa48a7 --- /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 9e5cbc9857..e5b76a882c 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 e4ec043e14..bea1667fbe 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 e2d0a50f81..1d1a84f339 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 cad087d2fd..a90743a125 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 0ebca02b57..abbbf651b7 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 9da0948018..2a27fb76e2 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 b90aa00c6c..dfbb071e24 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 f8b3a33939..ad14f4976a 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 87731f5778..299f2c29aa 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 18c185cacf..d984517e4f 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 c46e9c0231..0243c81f21 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 914491d3c3..cee4f58ae4 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 0000000000..f14da58f0a --- /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 2c86d99218..9da4e74b8a 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 90286a05d5..e42eb77488 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 c5d6ff05b6..4cf8b1d9c8 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 0000000000..ced875f300 --- /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 01b87c2822..878a35aa10 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 8e9a0f8da8..5d8a2c9efa 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 0000000000..21ab2a41f1 --- /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 fc16623495..b5d2d4c4cd 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 f9ce8f33b0..9ea842bc3c 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 d45f89a90d..fc352c59a3 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 ade26f06f7..6074e572af 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 0000000000..fc3eb96d0f --- /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 0000000000..0c8ef08088 --- /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 a1cc7cb1b5..f15e28f857 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 86fd7c1dc6..f655377436 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 1b347210e8..d701d3e233 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 } }; From aac84e39529e091b21177a2eda4534500f32ef05 Mon Sep 17 00:00:00 2001 From: Victor Santos Date: Thu, 4 Dec 2025 23:54:24 -0300 Subject: [PATCH 02/16] feat: enhance AWS IAM resource support with refined validation and response structure - Updated AWS IAM resource response schema to include distinct object structures for Postgres, MySQL, and SSH resource types. - Improved validation for project ID to ensure it is a valid UUID. - Adjusted console URL expiration handling to default to a calculated date if not provided. - Modified regex for ARN role validation to accommodate additional characters. --- .../pam-account-routers/pam-account-router.ts | 34 ++++++++++++++++--- .../aws-iam/aws-iam-federation.ts | 2 +- .../PamAccountForm/AwsIamAccountForm.tsx | 2 +- .../PamResourceForm/AwsIamResourceForm.tsx | 2 +- 4 files changed, 33 insertions(+), 7 deletions(-) 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 d201ea0e23..df46a6753c 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 @@ -126,11 +126,37 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { }) }), response: { - 200: z.union([ - // Gateway-based resources (Postgres, MySQL, SSH) + 200: z.discriminatedUnion("resourceType", [ + // Gateway-based resources (Postgres) z.object({ sessionId: z.string(), - resourceType: z.nativeEnum(PamResource), + 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(), @@ -145,7 +171,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { sessionId: z.string(), resourceType: z.literal(PamResource.AwsIam), consoleUrl: z.string().url(), - projectId: z.string(), + projectId: z.string().uuid(), metadata: z.record(z.string(), z.string().optional()).optional() }) ]) 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 index 367e908d3e..eb28e008b6 100644 --- 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 @@ -204,6 +204,6 @@ export const generateConsoleFederationUrl = async ({ return { consoleUrl, - expiresAt: Expiration + expiresAt: Expiration ?? new Date(Date.now() + sessionDuration * 1000) }; }; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx index 21ab2a41f1..0d8ef53364 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx @@ -24,7 +24,7 @@ type Props = { onSubmit: (formData: FormData) => Promise; }; -const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/; +const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@/-]+$/; const AwsIamCredentialsSchema = z.object({ targetRoleArn: z diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx index 0c8ef08088..62149f2d64 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx @@ -21,7 +21,7 @@ type Props = { onSubmit: (formData: FormData) => Promise; }; -const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/; +const arnRoleRegex = /^arn:aws:iam::\d{12}:role\/[\w+=,.@/-]+$/; const AwsIamConnectionDetailsSchema = z.object({ region: z.string().trim().min(1, "Region is required"), From ac5c185f767968df7ab506fb47ef13ed4380af1a Mon Sep 17 00:00:00 2001 From: Victor Santos Date: Fri, 5 Dec 2025 00:56:33 -0300 Subject: [PATCH 03/16] feat: enhance PAM account handling with type safety and improved response structure - Introduced type inference for sanitized accounts to ensure consistent data handling. - Updated account response structure to explicitly cast accounts to the sanitized type. - Refined the decryption function to omit sensitive fields from the returned account object. - Improved error handling in SQL resource factory by enforcing required gateway ID validation. --- .../pam-account-routers/pam-account-router.ts | 4 +++- .../ee/services/pam-account/pam-account-fns.ts | 17 ++++++++++++----- .../services/pam-account/pam-account-service.ts | 2 +- .../pam-resource/aws-iam/aws-iam-federation.ts | 8 +------- .../aws-iam/aws-iam-resource-fns.ts | 4 +++- .../aws-iam/aws-iam-resource-schemas.ts | 4 +--- .../shared/sql/sql-resource-factory.ts | 12 ++++++++++++ .../PamAccountForm/PamAccountForm.tsx | 6 +++++- .../components/PamAccountRow.tsx | 2 +- 9 files changed, 39 insertions(+), 20 deletions(-) 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 df46a6753c..1dd08b7df7 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 @@ -23,6 +23,8 @@ const SanitizedAccountSchema = z.union([ SanitizedAwsIamAccountWithResourceSchema ]); +type TSanitizedAccount = z.infer; + export const registerPamAccountRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", @@ -95,7 +97,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { } }); - return { accounts, folders, totalCount, folderId, folderPaths }; + return { accounts: accounts as TSanitizedAccount[], folders, totalCount, folderId, folderPaths }; } }); diff --git a/backend/src/ee/services/pam-account/pam-account-fns.ts b/backend/src/ee/services/pam-account/pam-account-fns.ts index aae703eeb0..71ef0fd7bd 100644 --- a/backend/src/ee/services/pam-account/pam-account-fns.ts +++ b/backend/src/ee/services/pam-account/pam-account-fns.ts @@ -72,17 +72,24 @@ export const decryptAccount = async < account: T, projectId: string, kmsService: Pick -): Promise => { +): Promise< + Omit & { + credentials: TPamAccountCredentials; + lastRotationMessage: string | null; + } +> => { + const { encryptedCredentials, encryptedLastRotationMessage, ...rest } = account; + return { - ...account, + ...rest, credentials: await decryptAccountCredentials({ - encryptedCredentials: account.encryptedCredentials, + encryptedCredentials, projectId, kmsService }), - lastRotationMessage: account.encryptedLastRotationMessage + lastRotationMessage: encryptedLastRotationMessage ? await decryptAccountMessage({ - encryptedMessage: account.encryptedLastRotationMessage, + encryptedMessage: encryptedLastRotationMessage, projectId, kmsService }) 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 fe2f558aa1..ceced9ca23 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -439,7 +439,7 @@ export const pamAccountServiceFactory = ({ const totalCount = totalFolderCount + totalAccountCount; const decryptedAndPermittedAccounts: Array< - TPamAccounts & { + Omit & { resource: Pick & { rotationCredentialsConfigured: boolean }; credentials: TPamAccountCredentials; lastRotationMessage: string | null; 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 index eb28e008b6..2595627a13 100644 --- 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 @@ -170,12 +170,6 @@ export const generateConsoleFederationUrl = async ({ 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); @@ -199,7 +193,7 @@ export const generateConsoleFederationUrl = async ({ throw new Error(`AWS federation endpoint did not return a SigninToken: ${responseText.substring(0, 200)}`); } - const consoleDestination = `https://${getConsoleHost()}/`; + const consoleDestination = `https://console.aws.amazon.com/`; const consoleUrl = `${federationEndpoint}?Action=login&SigninToken=${encodeURIComponent(tokenData.SigninToken)}&Destination=${encodeURIComponent(consoleDestination)}`; return { 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 index c421e29e6d..d04018d492 100644 --- 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 @@ -1,3 +1,5 @@ +import RE2 from "re2"; + import { BadRequestError } from "@app/lib/errors"; import { AwsIamResourceListItemSchema } from "./aws-iam-resource-schemas"; @@ -14,7 +16,7 @@ export const getAwsIamResourceListItem = () => { * ARN format: arn:aws:iam::123456789012:role/RoleName */ export const extractAwsAccountIdFromArn = (roleArn: string): string => { - const match = roleArn.match(/^arn:aws:iam::(\d{12}):role\//); + const match = roleArn.match(new RE2("^arn:aws:iam::(\\d{12}):role/")); if (!match) { throw new BadRequestError({ message: "Invalid IAM Role ARN format" }); } 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 index e45807ae4e..9167b4e5d1 100644 --- 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 @@ -67,9 +67,7 @@ export const AwsIamAccountSchema = BasePamAccountSchema.extend({ }); 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) + credentials: AwsIamAccountCredentialsSchema }); export const UpdateAwsIamAccountSchema = BaseUpdatePamAccountSchema.extend({ diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index b3128c4228..26fa7ff39d 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -233,6 +233,10 @@ export const sqlResourceFactory: TPamResourceFactory { const validateConnection = async () => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => { await client.validate(true); @@ -255,6 +259,10 @@ export const sqlResourceFactory: TPamResourceFactory { try { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + await executeWithGateway( { connectionDetails, @@ -296,6 +304,10 @@ export const sqlResourceFactory: TPamResourceFactory { const newPassword = alphaNumericNanoId(32); + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { return await executeWithGateway( { diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index b5d2d4c4cd..d146424836 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -73,7 +73,11 @@ const CreateForm = ({ ); case PamResourceType.AwsIam: return ( - + ); default: throw new Error(`Unhandled resource: ${resourceType}`); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx index 9ea842bc3c..a2c668b279 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountRow.tsx @@ -103,7 +103,7 @@ export const PamAccountRow = ({ )} - {account.lastRotatedAt && ( + {"lastRotatedAt" in account && account.lastRotatedAt && ( Date: Fri, 5 Dec 2025 01:14:28 -0300 Subject: [PATCH 04/16] feat: improve PAM account update handling with enhanced error management - Added try-catch block to handle potential database errors during account updates. - Implemented specific error handling for unique constraint violations, providing clearer feedback for duplicate account names. - Updated AWS IAM account schema to indicate that credential rotation is not supported, defaulting to false. --- .../ee/services/pam-account/pam-account-service.ts | 12 +++++++++++- .../pam-resource/aws-iam/aws-iam-resource-schemas.ts | 4 +++- .../pam-resource/aws-iam/aws-iam-resource-types.ts | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) 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 ceced9ca23..302fd006f0 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -290,7 +290,8 @@ export const pamAccountServiceFactory = ({ return decryptAccount(account, account.projectId, kmsService); } - const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); + try { + const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); return { ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), @@ -301,6 +302,15 @@ export const pamAccountServiceFactory = ({ rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials } }; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Account with name '${name}' already exists for this path` + }); + } + + throw err; + } }; const deleteById = async (id: string, actor: OrgServiceActor) => { 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 index 9167b4e5d1..1a72ac2e73 100644 --- 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 @@ -67,7 +67,9 @@ export const AwsIamAccountSchema = BasePamAccountSchema.extend({ }); export const CreateAwsIamAccountSchema = BaseCreatePamAccountSchema.extend({ - credentials: AwsIamAccountCredentialsSchema + credentials: AwsIamAccountCredentialsSchema, + // AWS IAM accounts don't support credential rotation - they use role assumption + rotationEnabled: z.boolean().default(false) }); export const UpdateAwsIamAccountSchema = BaseUpdatePamAccountSchema.extend({ 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 index 4e288df4e1..732355371a 100644 --- 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 @@ -13,4 +13,4 @@ export type TAwsIamResourceConnectionDetails = z.infer; -export type TAwsIamAccountCredentials = z.infer; \ No newline at end of file +export type TAwsIamAccountCredentials = z.infer; From cc9cee39539e5e2ed7d5905f34033c8c979a0a1f Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 4 Dec 2025 20:43:30 -0800 Subject: [PATCH 05/16] fix(additional-privileges): return correct project membership ID --- ...ity-project-additional-privilege-router.ts | 10 +- .../additional-privilege-service.ts | 112 +++++++++++++++++- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index 47b3f5258c..70ef9990ee 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -84,7 +84,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: req.body.identityId, - projectMembershipId: req.body.projectId, + projectMembershipId: privilege.projectMembershipId || req.body.projectId, projectId: req.body.projectId, slug: privilege.name } @@ -168,7 +168,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, + projectMembershipId: privilege.projectMembershipId || (privilegeDoc.projectId as string), projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -222,7 +222,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, + projectMembershipId: privilege.projectMembershipId || (privilegeDoc.projectId as string), projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -276,7 +276,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, + projectMembershipId: privilege.projectMembershipId || (privilegeDoc.projectId as string), projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -391,7 +391,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privileges: privileges.map((privilege) => ({ ...privilege, identityId: req.query.identityId, - projectMembershipId: privilege.projectId as string, + projectMembershipId: privilege.projectMembershipId || (privilege.projectId as string), projectId: req.query.projectId, slug: privilege.name })) diff --git a/backend/src/services/additional-privilege/additional-privilege-service.ts b/backend/src/services/additional-privilege/additional-privilege-service.ts index 2af9e6419a..7fa99b47ab 100644 --- a/backend/src/services/additional-privilege/additional-privilege-service.ts +++ b/backend/src/services/additional-privilege/additional-privilege-service.ts @@ -58,6 +58,21 @@ export const additionalPrivilegeServiceFactory = ({ const scope = factory.getScopeField(dto.scopeData); const dbActorField = data.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + let projectMembershipId: string | undefined; + if (scope.key === "projectId") { + const projectMembership = await membershipDAL.findOne({ + [dbActorField]: data.actorId, + scopeProjectId: scope.value, + scope: AccessScope.Project + }); + + if (!projectMembership) { + throw new NotFoundError({ message: `Project membership for ${data.actorType} ${data.actorId} not found` }); + } + + projectMembershipId = projectMembership.id; + } + const existingSlug = await additionalPrivilegeDAL.findOne({ name: data.name, [dbActorField]: data.actorId, @@ -79,7 +94,11 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions), + projectMembershipId + } }; } @@ -103,7 +122,11 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions), + projectMembershipId + } }; }; @@ -114,6 +137,21 @@ export const additionalPrivilegeServiceFactory = ({ const scope = factory.getScopeField(dto.scopeData); const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + let projectMembershipId: string | undefined; + if (scope.key === "projectId") { + const projectMembership = await membershipDAL.findOne({ + [dbActorField]: dto.selector.actorId, + scopeProjectId: scope.value, + scope: AccessScope.Project + }); + if (!projectMembership) { + throw new NotFoundError({ + message: `Project membership for ${dto.selector.actorType} ${dto.selector.actorId} not found` + }); + } + projectMembershipId = projectMembership.id; + } + const existingPrivilege = await additionalPrivilegeDAL.findOne({ [dbActorField]: dto.selector.actorId, id: dto.selector.id, @@ -136,7 +174,11 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions), + projectMembershipId + } }; } @@ -158,7 +200,11 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions), + projectMembershipId + } }; }; @@ -169,6 +215,21 @@ export const additionalPrivilegeServiceFactory = ({ const scope = factory.getScopeField(dto.scopeData); const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + let projectMembershipId: string | undefined; + if (scope.key === "projectId") { + const projectMembership = await membershipDAL.findOne({ + [dbActorField]: dto.selector.actorId, + scopeProjectId: scope.value, + scope: AccessScope.Project + }); + if (!projectMembership) { + throw new NotFoundError({ + message: `Project membership for ${dto.selector.actorType} ${dto.selector.actorId} not found` + }); + } + projectMembershipId = projectMembership.id; + } + const existingPrivilege = await additionalPrivilegeDAL.findOne({ id: selector.id, [dbActorField]: dto.selector.actorId, @@ -179,7 +240,11 @@ export const additionalPrivilegeServiceFactory = ({ const additionalPrivilege = await additionalPrivilegeDAL.deleteById(existingPrivilege.id); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions), + projectMembershipId + } }; }; @@ -190,6 +255,21 @@ export const additionalPrivilegeServiceFactory = ({ const scope = factory.getScopeField(dto.scopeData); const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + let projectMembershipId: string | undefined; + if (scope.key === "projectId") { + const projectMembership = await membershipDAL.findOne({ + [dbActorField]: dto.selector.actorId, + scopeProjectId: scope.value, + scope: AccessScope.Project + }); + if (!projectMembership) { + throw new NotFoundError({ + message: `Project membership for ${dto.selector.actorType} ${dto.selector.actorId} not found` + }); + } + projectMembershipId = projectMembership.id; + } + const additionalPrivilege = await additionalPrivilegeDAL.findOne({ id: selector.id, [dbActorField]: dto.selector.actorId, @@ -199,7 +279,11 @@ export const additionalPrivilegeServiceFactory = ({ throw new NotFoundError({ message: `Additional privilege with id ${selector.id} doesn't exist` }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions), + projectMembershipId + } }; }; @@ -230,6 +314,21 @@ export const additionalPrivilegeServiceFactory = ({ const scope = factory.getScopeField(dto.scopeData); const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + let projectMembershipId: string | undefined; + if (scope.key === "projectId") { + const projectMembership = await membershipDAL.findOne({ + [dbActorField]: dto.selector.actorId, + scopeProjectId: scope.value, + scope: AccessScope.Project + }); + if (!projectMembership) { + throw new NotFoundError({ + message: `Project membership for ${dto.selector.actorType} ${dto.selector.actorId} not found` + }); + } + projectMembershipId = projectMembership.id; + } + const additionalPrivileges = await additionalPrivilegeDAL.find({ [dbActorField]: dto.selector.actorId, [scope.key]: scope.value @@ -238,6 +337,7 @@ export const additionalPrivilegeServiceFactory = ({ return { additionalPrivileges: additionalPrivileges.map((el) => ({ ...el, + projectMembershipId, permissions: unpackPermissions(el.permissions) })) }; From b2e4c1e6bf4ba5e443a2ccbf44018c2ed88ece70 Mon Sep 17 00:00:00 2001 From: Victor Santos Date: Fri, 5 Dec 2025 01:44:33 -0300 Subject: [PATCH 06/16] feat: update PAM account types and endpoint handling for optional rotation settings - Made the rotationEnabled field optional in the account schema to enhance flexibility. - Updated endpoint logic to default rotationEnabled to false if not provided in the request. - Adjusted account DTOs to reflect the optional nature of rotationEnabled, improving type safety. --- .../pam-account-endpoints.ts | 4 ++-- .../pam-account/pam-account-service.ts | 18 +++++++++--------- .../services/pam-account/pam-account-types.ts | 6 ++++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts index 44e2a5ea11..4043c3cc83 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -22,7 +22,7 @@ export const registerPamResourceEndpoints = ({ folderId?: C["folderId"]; name: C["name"]; description?: C["description"]; - rotationEnabled: C["rotationEnabled"]; + rotationEnabled?: C["rotationEnabled"]; rotationIntervalSeconds?: C["rotationIntervalSeconds"]; }>; updateAccountSchema: z.ZodType<{ @@ -65,7 +65,7 @@ export const registerPamResourceEndpoints = ({ folderId: req.body.folderId, name: req.body.name, description: req.body.description, - rotationEnabled: req.body.rotationEnabled, + rotationEnabled: req.body.rotationEnabled ?? false, rotationIntervalSeconds: req.body.rotationIntervalSeconds } } 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 302fd006f0..1edfb19e26 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -293,15 +293,15 @@ export const pamAccountServiceFactory = ({ try { const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); - return { - ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), - resource: { - id: resource.id, - name: resource.name, - resourceType: resource.resourceType, - rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials - } - }; + return { + ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } + }; } catch (err) { if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { throw new BadRequestError({ diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index b8498036e1..241d38a115 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -6,8 +6,10 @@ import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums"; // DTOs export type TCreateAccountDTO = Pick< TPamAccount, - "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds" ->; + "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds" +> & { + rotationEnabled?: boolean; +}; export type TUpdateAccountDTO = Partial> & { accountId: string; From b3f2fb13995ac62103e89d6d75067f5cf4787296 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 4 Dec 2025 23:02:29 -0800 Subject: [PATCH 07/16] added missed endpoint --- ...ity-project-additional-privilege-router.ts | 2 +- .../additional-privilege-service.ts | 21 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index 70ef9990ee..b13194278f 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -339,7 +339,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: req.query.identityId, - projectMembershipId: privilege.projectId as string, + projectMembershipId: privilege.projectMembershipId || (privilege.projectId as string), projectId, slug: privilege.name } diff --git a/backend/src/services/additional-privilege/additional-privilege-service.ts b/backend/src/services/additional-privilege/additional-privilege-service.ts index 7fa99b47ab..3fdc586344 100644 --- a/backend/src/services/additional-privilege/additional-privilege-service.ts +++ b/backend/src/services/additional-privilege/additional-privilege-service.ts @@ -294,6 +294,21 @@ export const additionalPrivilegeServiceFactory = ({ const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; const scope = factory.getScopeField(dto.scopeData); + let projectMembershipId: string | undefined; + if (scope.key === "projectId") { + const projectMembership = await membershipDAL.findOne({ + [dbActorField]: dto.selector.actorId, + scopeProjectId: scope.value, + scope: AccessScope.Project + }); + + if (!projectMembership) { + throw new NotFoundError({ + message: `Project membership for ${dto.selector.actorType} ${dto.selector.actorId} not found` + }); + } + projectMembershipId = projectMembership.id; + } const additionalPrivilege = await additionalPrivilegeDAL.findOne({ name: selector.name, [dbActorField]: dto.selector.actorId, @@ -303,7 +318,11 @@ export const additionalPrivilegeServiceFactory = ({ throw new NotFoundError({ message: `Additional privilege with name ${selector.name} doesn't exist` }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions), + projectMembershipId + } }; }; From 6db5188b363f707fa3fe4696aa2c0656b32811c9 Mon Sep 17 00:00:00 2001 From: Victor Santos Date: Fri, 5 Dec 2025 16:56:55 -0300 Subject: [PATCH 08/16] feat: update AWS IAM session duration handling and improve account access functionality - Changed session duration parameter from maxSessionDuration to defaultSessionDuration for consistency. - Refactored AWS STS client creation to use a hardcoded default region, simplifying the configuration. - Enhanced PAM account access modal to include account path and project ID in the access request. - Updated various components and schemas to reflect the new session duration naming and improve type safety. --- .../pam-account/pam-account-service.ts | 2 +- .../aws-iam/aws-iam-federation.ts | 20 +- .../aws-iam/aws-iam-resource-factory.ts | 2 +- .../aws-iam/aws-iam-resource-schemas.ts | 5 +- frontend/src/hooks/api/pam/mutations.tsx | 6 +- .../hooks/api/pam/types/aws-iam-resource.ts | 3 +- .../components/PamAccessAccountModal.tsx | 250 ++++-------------- .../PamAccountForm/AwsIamAccountForm.tsx | 94 +++---- .../components/PamAccountsTable.tsx | 10 +- .../components/useAccessAwsIamAccount.tsx | 6 +- .../PamResourceForm/AwsIamResourceForm.tsx | 117 ++++---- 11 files changed, 207 insertions(+), 308 deletions(-) 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 f4a5f48012..bb00f2eaf9 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -591,7 +591,7 @@ export const pamAccountServiceFactory = ({ targetRoleArn: awsCredentials.targetRoleArn, roleSessionName: actorEmail, projectId: account.projectId, // Use project ID as External ID for security - sessionDuration: awsCredentials.maxSessionDuration + sessionDuration: awsCredentials.defaultSessionDuration }); const session = await pamSessionDAL.create({ 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 index 2595627a13..f9be88b2f7 100644 --- 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 @@ -8,11 +8,17 @@ import { TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types"; const AWS_STS_MIN_DURATION_SECONDS = 900; -const createStsClient = (region: string): STSClient => { +// We hardcode us-east-1 because: +// 1. IAM is global - roles can be assumed from any STS regional endpoint +// 2. The temporary credentials returned work globally across all AWS regions +// 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 appCfg = getConfig(); const config: STSClientConfig = { - region, + region: AWS_STS_DEFAULT_REGION, useFipsEndpoint: crypto.isFipsModeEnabled(), sha256: CustomAWSHasher, credentials: @@ -31,7 +37,7 @@ export const validatePamRoleConnection = async ( connectionDetails: TAwsIamResourceConnectionDetails, projectId: string ): Promise => { - const stsClient = createStsClient(connectionDetails.region); + const stsClient = createStsClient(); try { await stsClient.send( @@ -58,7 +64,7 @@ export const validateTargetRoleAssumption = async ({ targetRoleArn: string; projectId: string; }): Promise => { - const stsClient = createStsClient(connectionDetails.region); + const stsClient = createStsClient(); try { // First assume the PAM role @@ -77,7 +83,7 @@ export const validateTargetRoleAssumption = async ({ // Then use the PAM role credentials to assume the target role const pamStsClient = new STSClient({ - region: connectionDetails.region, + region: AWS_STS_DEFAULT_REGION, useFipsEndpoint: crypto.isFipsModeEnabled(), sha256: CustomAWSHasher, credentials: { @@ -118,7 +124,7 @@ export const generateConsoleFederationUrl = async ({ projectId: string; sessionDuration: number; }): Promise<{ consoleUrl: string; expiresAt: Date }> => { - const stsClient = createStsClient(connectionDetails.region); + const stsClient = createStsClient(); // First assume the PAM role const pamRoleCredentials = await stsClient.send( @@ -136,7 +142,7 @@ export const generateConsoleFederationUrl = async ({ // Role chaining: use PAM role credentials to assume the target role const pamStsClient = new STSClient({ - region: connectionDetails.region, + region: AWS_STS_DEFAULT_REGION, useFipsEndpoint: crypto.isFipsModeEnabled(), sha256: CustomAWSHasher, credentials: { 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 index 6fe7bfafe8..01f593b4fa 100644 --- 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 @@ -32,7 +32,7 @@ export const awsIamResourceFactory: TPamResourceFactory { export type TAccessPamAccountDTO = { accountId: string; + accountPath: string; + projectId: string; duration: string; }; @@ -141,11 +143,13 @@ export type TAccessPamAccountResponse = { export const useAccessPamAccount = () => { return useMutation({ - mutationFn: async ({ accountId, duration }: TAccessPamAccountDTO) => { + mutationFn: async ({ accountId, accountPath, projectId, duration }: TAccessPamAccountDTO) => { const { data } = await apiRequest.post( "/api/v1/pam/accounts/access", { accountId, + accountPath, + projectId, duration } ); diff --git a/frontend/src/hooks/api/pam/types/aws-iam-resource.ts b/frontend/src/hooks/api/pam/types/aws-iam-resource.ts index ced875f300..8cb51a0ec0 100644 --- a/frontend/src/hooks/api/pam/types/aws-iam-resource.ts +++ b/frontend/src/hooks/api/pam/types/aws-iam-resource.ts @@ -3,13 +3,12 @@ import { TBasePamAccount } from "./base-account"; import { TBasePamResource } from "./base-resource"; export type TAwsIamConnectionDetails = { - region: string; roleArn: string; }; export type TAwsIamCredentials = { targetRoleArn: string; - maxSessionDuration: number; + defaultSessionDuration: number; }; export type TAwsIamResource = Omit & { diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx index 0b78120c60..4d70a0ae40 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -1,20 +1,12 @@ import { useMemo, useState } from "react"; import { faCopy } from "@fortawesome/free-regular-svg-icons"; -import { faExternalLink, faUpRightFromSquare, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import ms from "ms"; import { createNotification } from "@app/components/notifications"; -import { - Button, - FormControl, - FormLabel, - IconButton, - Input, - Modal, - ModalContent -} from "@app/components/v2"; -import { PamResourceType, TPamAccount, useAccessPamAccount } from "@app/hooks/api/pam"; +import { FormLabel, IconButton, Input, Modal, ModalContent } from "@app/components/v2"; +import { PamResourceType, TPamAccount } from "@app/hooks/api/pam"; type Props = { account?: TPamAccount; @@ -24,124 +16,25 @@ type Props = { projectId: string; }; -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, +export const PamAccessAccountModal = ({ + isOpen, onOpenChange, + account, projectId, accountPath -}: { - account: TPamAccount; - onOpenChange: (isOpen: boolean) => void; - projectId: string; - accountPath?: string; -}) => { - let fullAccountPath = account?.name; - if (accountPath) { - let path = accountPath; - if (path.startsWith("/")) path = path.slice(1); - fullAccountPath = `${path}/${account?.name}`; - } +}: Props) => { + const [duration, setDuration] = useState("4h"); const { protocol, hostname, port } = window.location; const portSuffix = port && port !== "80" && port !== "443" ? `:${port}` : ""; const siteURL = `${protocol}//${hostname}${portSuffix}`; - const [duration, setDuration] = useState("4h"); + let fullAccountPath = account?.name ?? ""; + if (accountPath) { + let path = accountPath; + if (path.startsWith("/")) path = path.slice(1); + fullAccountPath = `${path}/${account?.name}`; + } const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]); @@ -196,87 +89,58 @@ const CliAccessContent = ({ default: return ""; } - }, [account, cliDuration]); + }, [account, fullAccountPath, projectId, cliDuration, siteURL]); - 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, - projectId, - accountPath -}: Props) => { if (!account) return null; - const isAwsIam = account.resource.resourceType === PamResourceType.AwsIam; - return ( - {isAwsIam ? ( - - ) : ( - - )} + + 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 + +
); diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx index 0d8ef53364..9d6a882b99 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx @@ -1,4 +1,6 @@ import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -12,6 +14,7 @@ import { Input, ModalClose } from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; import { useProject } from "@app/context"; import { PamResourceType, TAwsIamAccount } from "@app/hooks/api/pam"; @@ -35,7 +38,7 @@ const AwsIamCredentialsSchema = z.object({ 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 + defaultSessionDuration: z.coerce .number() .min(900, "Minimum session duration is 900 seconds (15 minutes)") .max(3600, "Maximum session duration is 3600 seconds (1 hour)") @@ -57,6 +60,22 @@ export const AwsIamAccountForm = ({ account, onSubmit }: Props) => { const isUpdate = Boolean(account); const { projectId } = useProject(); + const targetRoleTrustPolicy = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::role/" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "${projectId}" + } + } + }] +}`; + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: account ?? { @@ -64,7 +83,7 @@ export const AwsIamAccountForm = ({ account, onSubmit }: Props) => { description: "", credentials: { targetRoleArn: "", - maxSessionDuration: 3600 + defaultSessionDuration: 3600 } } }); @@ -104,7 +123,7 @@ export const AwsIamAccountForm = ({ account, onSubmit }: Props) => { /> ( { helperText="In seconds. Min 900 (15m), max 3600 (1h) due to AWS role chaining limit." errorText={error?.message} isError={Boolean(error?.message)} - label="Session Duration (seconds)" + label="Default Session Duration (seconds)" > @@ -120,10 +139,19 @@ export const AwsIamAccountForm = ({ account, onSubmit }: Props) => { />
- - - Target Role Setup - + + + +
+ + Target Role Setup +
+
+

The target role must have a trust policy that allows the Infisical PAM role to assume it. If you used the{" "} @@ -134,32 +162,24 @@ export const AwsIamAccountForm = ({ account, onSubmit }: Props) => {

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}"
-      }
-    }
-  }]
-}`}
-              
+
+
+ +
+
+                  {targetRoleTrustPolicy}
+                
+

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{" "} + with the name of the PAM role you created and used in the "Resources" tab + (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.

@@ -167,22 +187,6 @@ export const AwsIamAccountForm = ({ account, onSubmit }: Props) => {
-
-

- 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. -

-
-