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-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/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 74bd5eeb11..0c78b64491 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,8 +3,10 @@ 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 { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas"; import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { BadRequestError } from "@app/lib/errors"; @@ -18,9 +20,12 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedAccountSchema = z.union([ SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS SanitizedPostgresAccountWithResourceSchema, - SanitizedMySQLAccountWithResourceSchema + SanitizedMySQLAccountWithResourceSchema, + SanitizedAwsIamAccountWithResourceSchema ]); +type TSanitizedAccount = z.infer; + export const registerPamAccountRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", @@ -93,7 +98,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { } }); - return { accounts, folders, totalCount, folderId, folderPaths }; + return { accounts: accounts as TSanitizedAccount[], folders, totalCount, folderId, folderPaths }; } }); @@ -125,18 +130,19 @@ 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.discriminatedUnion("resourceType", [ + // Gateway-based resources (Postgres, MySQL, SSH) + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Postgres) }), + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.MySQL) }), + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.SSH) }), + // AWS IAM (no gateway, returns console URL) + z.object({ + sessionId: z.string(), + resourceType: z.literal(PamResource.AwsIam), + consoleUrl: z.string().url(), + metadata: z.record(z.string(), z.string().optional()).optional() + }) + ]) } }, onRequest: verifyAuth([AuthMode.JWT]), @@ -162,7 +168,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: response.projectId, + projectId: req.body.projectId, event: { type: EventType.PAM_ACCOUNT_ACCESS, metadata: { 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/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index 47b3f5258c..23ba27b8a5 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,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: req.body.identityId, - projectMembershipId: req.body.projectId, projectId: req.body.projectId, slug: privilege.name } @@ -168,7 +167,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -222,7 +220,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -276,7 +273,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -339,7 +335,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: req.query.identityId, - projectMembershipId: privilege.projectId as string, projectId, slug: privilege.name } @@ -391,7 +386,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privileges: privileges.map((privilege) => ({ ...privilege, identityId: req.query.identityId, - projectMembershipId: privilege.projectId as string, projectId: req.query.projectId, slug: privilege.name })) 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 adfac13c13..240801ebac 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -4173,7 +4173,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-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 b473b6b99b..d3c7422d85 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -3,6 +3,11 @@ import path from "node:path"; 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"; @@ -30,6 +35,7 @@ import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/appro 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"; @@ -41,7 +47,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"; @@ -67,6 +74,7 @@ type TPamAccountServiceFactoryDep = { auditLogService: Pick; approvalPolicyDAL: TApprovalPolicyDALFactory; approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory; + pamSessionExpirationService: Pick; }; export type TPamAccountServiceFactory = ReturnType; @@ -85,7 +93,8 @@ export const pamAccountServiceFactory = ({ gatewayV2Service, auditLogService, approvalPolicyDAL, - approvalRequestGrantsDAL + approvalRequestGrantsDAL, + pamSessionExpirationService }: TPamAccountServiceFactoryDep) => { const create = async ( { @@ -153,7 +162,8 @@ export const pamAccountServiceFactory = ({ resource.resourceType as PamResource, connectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + resource.projectId ); const validatedCredentials = await factory.validateAccountCredentials(credentials); @@ -268,7 +278,8 @@ export const pamAccountServiceFactory = ({ resource.resourceType as PamResource, connectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + account.projectId ); const decryptedCredentials = await decryptAccountCredentials({ @@ -297,17 +308,27 @@ 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)), - 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({ + message: `Account with name '${name}' already exists for this path` + }); } - }; + + throw err; + } }; const deleteById = async (id: string, actor: OrgServiceActor) => { @@ -446,7 +467,7 @@ export const pamAccountServiceFactory = ({ const totalCount = totalFolderCount + totalAccountCount; const decryptedAndPermittedAccounts: Array< - TPamAccounts & { + Omit & { resource: Pick & { rotationCredentialsConfigured: boolean }; credentials: TPamAccountCredentials; lastRotationMessage: string | null; @@ -594,6 +615,64 @@ 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.defaultSessionDuration + }); + + 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, + 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, @@ -609,18 +688,17 @@ export const pamAccountServiceFactory = ({ expiresAt: new Date(Date.now() + duration) }); - const { connectionDetails, gatewayId, resourceType } = await decryptResource(resource, 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, @@ -644,11 +722,11 @@ export const pamAccountServiceFactory = ({ projectId })) as TSqlResourceConnectionDetails; - const credentials = await decryptAccountCredentials({ + const credentials = (await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, kmsService, projectId - }); + })) as TSqlAccountCredentials; metadata = { username: credentials.username, @@ -660,11 +738,11 @@ export const pamAccountServiceFactory = ({ break; case PamResource.SSH: { - const credentials = await decryptAccountCredentials({ + const credentials = (await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, kmsService, projectId - }); + })) as TSSHAccountCredentials; metadata = { username: credentials.username @@ -737,7 +815,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" }); @@ -801,7 +879,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-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index ac799d8694..a20d2f7375 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; 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..97415a0888 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-federation.ts @@ -0,0 +1,245 @@ +import { AssumeRoleCommand, Credentials, STSClient, STSClientConfig } from "@aws-sdk/client-sts"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; + +import { TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types"; + +const AWS_STS_MIN_DURATION_SECONDS = 900; + +// 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 = (credentials?: Credentials): STSClient => { + const appCfg = getConfig(); + + const config: STSClientConfig = { + region: AWS_STS_DEFAULT_REGION, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher + }; + + if (credentials) { + // Use provided credentials (for role chaining) + config.credentials = { + accessKeyId: credentials.AccessKeyId!, + secretAccessKey: credentials.SecretAccessKey!, + sessionToken: credentials.SessionToken + }; + } else if (appCfg.PAM_AWS_ACCESS_KEY_ID && appCfg.PAM_AWS_SECRET_ACCESS_KEY) { + // Use configured static credentials + config.credentials = { + accessKeyId: appCfg.PAM_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.PAM_AWS_SECRET_ACCESS_KEY + }; + } + // Otherwise uses instance profile if hosting on AWS + + return new STSClient(config); +}; + +/** + * Assumes the PAM role and returns the credentials. + * Returns null if assumption fails (for validation) or throws if throwOnError is true. + */ +const assumePamRole = async ({ + connectionDetails, + projectId, + sessionDuration = AWS_STS_MIN_DURATION_SECONDS, + sessionNameSuffix = "validation", + throwOnError = false +}: { + connectionDetails: TAwsIamResourceConnectionDetails; + projectId: string; + sessionDuration?: number; + sessionNameSuffix?: string; + throwOnError?: boolean; +}): Promise => { + const stsClient = createStsClient(); + + try { + const result = await stsClient.send( + new AssumeRoleCommand({ + RoleArn: connectionDetails.roleArn, + RoleSessionName: `infisical-pam-${sessionNameSuffix}-${Date.now()}`, + DurationSeconds: sessionDuration, + ExternalId: projectId + }) + ); + + if (!result.Credentials) { + if (throwOnError) { + throw new InternalServerError({ + message: "Failed to assume PAM role - AWS STS did not return credentials" + }); + } + return null; + } + + return result.Credentials; + } catch (error) { + if (throwOnError) { + throw new InternalServerError({ + message: `Failed to assume PAM role - AWS STS did not return credentials: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + return null; + } +}; + +/** + * Assumes a target role using PAM role credentials (role chaining). + * Returns null if assumption fails (for validation) or throws if throwOnError is true. + */ +const assumeTargetRole = async ({ + pamCredentials, + targetRoleArn, + projectId, + roleSessionName, + sessionDuration = AWS_STS_MIN_DURATION_SECONDS, + throwOnError = false +}: { + pamCredentials: Credentials; + targetRoleArn: string; + projectId: string; + roleSessionName: string; + sessionDuration?: number; + throwOnError?: boolean; +}): Promise => { + const chainedStsClient = createStsClient(pamCredentials); + + try { + const result = await chainedStsClient.send( + new AssumeRoleCommand({ + RoleArn: targetRoleArn, + RoleSessionName: roleSessionName, + DurationSeconds: sessionDuration, + ExternalId: projectId + }) + ); + + if (!result.Credentials) { + if (throwOnError) { + throw new BadRequestError({ + message: "Failed to assume target role - verify the target role trust policy allows the PAM role to assume it" + }); + } + return null; + } + + return result.Credentials; + } catch (error) { + if (throwOnError) { + throw new InternalServerError({ + message: `Failed to assume target role - AWS STS did not return credentials: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + return null; + } +}; + +export const validatePamRoleConnection = async ( + connectionDetails: TAwsIamResourceConnectionDetails, + projectId: string +): Promise => { + try { + const credentials = await assumePamRole({ connectionDetails, projectId }); + return credentials !== null; + } catch { + return false; + } +}; + +export const validateTargetRoleAssumption = async ({ + connectionDetails, + targetRoleArn, + projectId +}: { + connectionDetails: TAwsIamResourceConnectionDetails; + targetRoleArn: string; + projectId: string; +}): Promise => { + try { + const pamCredentials = await assumePamRole({ connectionDetails, projectId }); + if (!pamCredentials) return false; + + const targetCredentials = await assumeTargetRole({ + pamCredentials, + targetRoleArn, + projectId, + roleSessionName: `infisical-pam-target-validation-${Date.now()}` + }); + return targetCredentials !== null; + } 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 pamCredentials = await assumePamRole({ + connectionDetails, + projectId, + sessionDuration, + sessionNameSuffix: "session", + throwOnError: true + }); + + const targetCredentials = await assumeTargetRole({ + pamCredentials: pamCredentials!, + targetRoleArn, + projectId, + roleSessionName, + sessionDuration, + throwOnError: true + }); + + const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = targetCredentials!; + + // Generate federation URL + const sessionJson = JSON.stringify({ + sessionId: AccessKeyId, + sessionKey: SecretAccessKey, + sessionToken: SessionToken + }); + + const federationEndpoint = "https://signin.aws.amazon.com/federation"; + + const signinTokenUrl = `${federationEndpoint}?Action=getSigninToken&Session=${encodeURIComponent(sessionJson)}`; + + const tokenResponse = await request.get<{ SigninToken?: string }>(signinTokenUrl); + + if (!tokenResponse.data.SigninToken) { + throw new InternalServerError({ + message: `AWS federation endpoint did not return a SigninToken: ${JSON.stringify(tokenResponse.data).substring(0, 200)}` + }); + } + + const consoleDestination = `https://console.aws.amazon.com/`; + const consoleUrl = `${federationEndpoint}?Action=login&SigninToken=${encodeURIComponent(tokenResponse.data.SigninToken)}&Destination=${encodeURIComponent(consoleDestination)}`; + + return { + consoleUrl, + expiresAt: Expiration ?? new Date(Date.now() + sessionDuration * 1000) + }; +}; 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..8449086711 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-factory.ts @@ -0,0 +1,110 @@ +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 }, + "[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 (ARN: ${connectionDetails.roleArn}) 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..d04018d492 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-fns.ts @@ -0,0 +1,24 @@ +import RE2 from "re2"; + +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(new RE2("^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..2762977eba --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas.ts @@ -0,0 +1,81 @@ +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({ + roleArn: z.string().trim().min(1) +}); + +export const AwsIamAccountCredentialsSchema = z.object({ + targetRoleArn: z.string().trim().min(1).max(2048), + defaultSessionDuration: 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, + rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateAwsIamResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: AwsIamResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional() +}); + +export const AwsIamAccountSchema = BasePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema +}); + +export const CreateAwsIamAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema, + // AWS IAM accounts don't support credential rotation - they use role assumption + rotationEnabled: z.boolean().default(false) +}); + +export const UpdateAwsIamAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema.optional() +}); + +export const SanitizedAwsIamAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: AwsIamAccountCredentialsSchema.pick({ + targetRoleArn: true, + defaultSessionDuration: 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..732355371a --- /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; 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/mysql/mysql-resource-schemas.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts index 8d3589a8ab..cb12a4c8cc 100644 --- a/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts @@ -2,13 +2,13 @@ import { z } from "zod"; import { PamResource } from "../pam-resource-enums"; import { + BaseCreateGatewayPamResourceSchema, BaseCreatePamAccountSchema, - BaseCreatePamResourceSchema, BasePamAccountSchema, BasePamAccountSchemaWithResource, BasePamResourceSchema, - BaseUpdatePamAccountSchema, - BaseUpdatePamResourceSchema + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema } from "../pam-resource-schemas"; import { BaseSqlAccountCredentialsSchema, @@ -43,12 +43,12 @@ export const MySQLResourceListItemSchema = z.object({ resource: z.literal(PamResource.MySQL) }); -export const CreateMySQLResourceSchema = BaseCreatePamResourceSchema.extend({ +export const CreateMySQLResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ connectionDetails: MySQLResourceConnectionDetailsSchema, rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() }); -export const UpdateMySQLResourceSchema = BaseUpdatePamResourceSchema.extend({ +export const UpdateMySQLResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ connectionDetails: MySQLResourceConnectionDetailsSchema.optional(), rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() }); 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-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 17ed1ccd19..a3db6b446c 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -3,6 +3,18 @@ import { z } from "zod"; import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas"; import { slugSchema } from "@app/server/lib/schemas"; +export const GatewayAccessResponseSchema = z.object({ + sessionId: z.string(), + relayClientCertificate: z.string(), + relayClientPrivateKey: z.string(), + relayServerCertificateChain: z.string(), + gatewayClientCertificate: z.string(), + gatewayClientPrivateKey: z.string(), + gatewayServerCertificateChain: z.string(), + relayHost: z.string(), + metadata: z.record(z.string(), z.string().optional()).optional() +}); + // Resources export const BasePamResourceSchema = PamResourcesSchema.omit({ encryptedConnectionDetails: true, @@ -10,17 +22,27 @@ export const BasePamResourceSchema = PamResourcesSchema.omit({ resourceType: true }); -export const BaseCreatePamResourceSchema = z.object({ +const CoreCreatePamResourceSchema = z.object({ projectId: z.string().uuid(), - gatewayId: z.string().uuid(), name: slugSchema({ field: "name" }) }); -export const BaseUpdatePamResourceSchema = z.object({ - gatewayId: z.string().uuid().optional(), +export const BaseCreateGatewayPamResourceSchema = CoreCreatePamResourceSchema.extend({ + gatewayId: z.string().uuid() +}); + +export const BaseCreatePamResourceSchema = CoreCreatePamResourceSchema; + +const CoreUpdatePamResourceSchema = z.object({ name: slugSchema({ field: "name" }).optional() }); +export const BaseUpdateGatewayPamResourceSchema = CoreUpdatePamResourceSchema.extend({ + gatewayId: z.string().uuid().optional() +}); + +export const BaseUpdatePamResourceSchema = CoreUpdatePamResourceSchema; + // Accounts export const BasePamAccountSchema = PamAccountsSchema.omit({ encryptedCredentials: true 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/postgres/postgres-resource-schemas.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts index bbe83a3a4b..fd58484f71 100644 --- a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts @@ -2,13 +2,13 @@ import { z } from "zod"; import { PamResource } from "../pam-resource-enums"; import { + BaseCreateGatewayPamResourceSchema, BaseCreatePamAccountSchema, - BaseCreatePamResourceSchema, BasePamAccountSchema, BasePamAccountSchemaWithResource, BasePamResourceSchema, - BaseUpdatePamAccountSchema, - BaseUpdatePamResourceSchema + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema } from "../pam-resource-schemas"; import { BaseSqlAccountCredentialsSchema, @@ -40,12 +40,12 @@ export const PostgresResourceListItemSchema = z.object({ resource: z.literal(PamResource.Postgres) }); -export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ +export const CreatePostgresResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ connectionDetails: PostgresResourceConnectionDetailsSchema, rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); -export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ +export const UpdatePostgresResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ connectionDetails: PostgresResourceConnectionDetailsSchema.optional(), rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); 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/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-resource/ssh/ssh-resource-schemas.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts index 97d462369a..01b8ef2c06 100644 --- a/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts @@ -2,13 +2,13 @@ import { z } from "zod"; import { PamResource } from "../pam-resource-enums"; import { + BaseCreateGatewayPamResourceSchema, BaseCreatePamAccountSchema, - BaseCreatePamResourceSchema, BasePamAccountSchema, BasePamAccountSchemaWithResource, BasePamResourceSchema, - BaseUpdatePamAccountSchema, - BaseUpdatePamResourceSchema + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema } from "../pam-resource-schemas"; import { SSHAuthMethod } from "./ssh-resource-enums"; @@ -73,12 +73,12 @@ export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({ .optional() }); -export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({ +export const CreateSSHResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ connectionDetails: SSHResourceConnectionDetailsSchema, rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() }); -export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({ +export const UpdateSSHResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ connectionDetails: SSHResourceConnectionDetailsSchema.optional(), rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() }); 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..094614859c 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.Ended, + 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..33afe95e4a 100644 --- a/backend/src/ee/services/pam-session/pam-session-enums.ts +++ b/backend/src/ee/services/pam-session/pam-session-enums.ts @@ -1,6 +1,6 @@ export enum PamSessionStatus { Starting = "starting", // Starting, user connecting to resource Active = "active", // Active, user is connected to resource - Ended = "ended", // Ended by user + Ended = "ended", // Ended by user or automatically expired after expiresAt timestamp Terminated = "terminated" // Terminated by an admin } 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..bdb82e6502 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.Ended, + 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) { diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 21e83c2b79..14eb601925 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -286,6 +286,10 @@ const envSchema = z DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()).default( process.env.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY ), + + // PAM AWS credentials (for AWS IAM PAM resource type) + PAM_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()), + PAM_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()), /* ----------------------------------------------------------------------------- */ /* App Connections ----------------------------------------------------------------------------- */ 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 382ff30d7e..e191aa0432 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -290,6 +290,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"; @@ -2429,6 +2430,10 @@ export const registerRoutes = async ( }); const approvalPolicyDAL = approvalPolicyDALFactory(db); + const pamSessionExpirationService = pamSessionExpirationServiceFactory({ + queueService, + pamSessionDAL + }); const pamAccountService = pamAccountServiceFactory({ pamAccountDAL, @@ -2443,7 +2448,8 @@ export const registerRoutes = async ( userDAL, auditLogService, approvalRequestGrantsDAL, - approvalPolicyDAL + approvalPolicyDAL, + pamSessionExpirationService }); const pamAccountRotation = pamAccountRotationServiceFactory({ @@ -2531,6 +2537,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/server/routes/sanitizedSchema/identitiy-additional-privilege.ts b/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts index e44b9af4ec..0feb1ba55c 100644 --- a/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts +++ b/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts @@ -2,6 +2,8 @@ import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; import { UnpackedPermissionSchema } from "./permission"; -export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ +export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.omit({ + projectMembershipId: true +}).extend({ permissions: UnpackedPermissionSchema.array() }); diff --git a/backend/src/services/additional-privilege/additional-privilege-service.ts b/backend/src/services/additional-privilege/additional-privilege-service.ts index 2af9e6419a..69f103c853 100644 --- a/backend/src/services/additional-privilege/additional-privilege-service.ts +++ b/backend/src/services/additional-privilege/additional-privilege-service.ts @@ -79,7 +79,10 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; } @@ -103,7 +106,10 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; }; @@ -136,7 +142,10 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; } @@ -158,7 +167,10 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; }; @@ -179,7 +191,10 @@ export const additionalPrivilegeServiceFactory = ({ const additionalPrivilege = await additionalPrivilegeDAL.deleteById(existingPrivilege.id); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; }; @@ -199,7 +214,10 @@ 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) + } }; }; @@ -219,7 +237,10 @@ 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) + } }; }; 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/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index 1e070e2bb7..dbdbe7c3b9 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -5,7 +5,6 @@ export enum IdentityProjectAdditionalPrivilegeTemporaryMode { } export type TIdentityProjectPrivilege = { - projectMembershipId: string; slug: string; id: string; createdAt: Date; diff --git a/frontend/src/hooks/api/pam/enums.ts b/frontend/src/hooks/api/pam/enums.ts index 2c86d99218..c6dfcd70c1 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 { 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..ce92de0fe8 100644 --- a/frontend/src/hooks/api/pam/mutations.tsx +++ b/frontend/src/hooks/api/pam/mutations.tsx @@ -120,6 +120,45 @@ export const useDeletePamAccount = () => { }); }; +export type TAccessPamAccountDTO = { + accountId: string; + accountPath: string; + projectId: 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, accountPath, projectId, duration }: TAccessPamAccountDTO) => { + const { data } = await apiRequest.post( + "/api/v1/pam/accounts/access", + { + accountId, + accountPath, + projectId, + 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..8cb51a0ec0 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/aws-iam-resource.ts @@ -0,0 +1,25 @@ +import { PamResourceType } from "../enums"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +export type TAwsIamConnectionDetails = { + roleArn: string; +}; + +export type TAwsIamCredentials = { + targetRoleArn: string; + defaultSessionDuration: 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 2629001828..eafdfe5f96 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -23,18 +23,17 @@ export const PamAccessAccountModal = ({ projectId, accountPath }: Props) => { - let fullAccountPath = account?.name; - if (accountPath) { - let path = accountPath; - if (path.startsWith("/")) path = path.slice(1); - fullAccountPath = `${path}/${account?.name}`; - } + 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) { + const path = accountPath.replace(/^\/+|\/+$/g, ""); + fullAccountPath = `${path}/${account?.name ?? ""}`; + } const isDurationValid = useMemo(() => duration && ms(duration || "1s") > 0, [duration]); @@ -89,7 +88,7 @@ export const PamAccessAccountModal = ({ default: return ""; } - }, [account, cliDuration]); + }, [account, fullAccountPath, projectId, cliDuration, siteURL]); if (!account) return null; 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..677fb9e90b --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/AwsIamAccountForm.tsx @@ -0,0 +1,216 @@ +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"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + ModalClose +} from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; +import { useProject } from "@app/context"; +import { + PamResourceType, + TAwsIamAccount, + TAwsIamResource, + useGetPamResourceById +} from "@app/hooks/api/pam"; + +import { GenericAccountFields, genericAccountFieldsSchema } 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) + defaultSessionDuration: 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 formSchema = genericAccountFieldsSchema.extend({ + credentials: AwsIamCredentialsSchema +}); + +type FormData = z.infer; + +export const AwsIamAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => { + const isUpdate = Boolean(account); + const { projectId } = useProject(); + + const resourceIdToFetch = account?.resourceId || resourceId; + const resourceTypeToFetch = account?.resource?.resourceType || resourceType; + const { data: resource } = useGetPamResourceById(resourceTypeToFetch, resourceIdToFetch, { + enabled: !!resourceIdToFetch && !!resourceTypeToFetch + }); + + const pamRoleArn = + (resource?.resourceType === PamResourceType.AwsIam && + (resource as TAwsIamResource).connectionDetails?.roleArn) || + "arn:aws:iam:::role/"; + + const targetRoleTrustPolicy = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "AWS": "${pamRoleArn}" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "${projectId}" + } + } + }] +}`; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: account ?? { + name: "", + description: "", + credentials: { + targetRoleArn: "", + defaultSessionDuration: 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 PAM role (created in the + "Resources" tab) to assume it. If your target role name follows the + wildcard pattern you defined in the PAM role's permissions policy, no + additional changes are needed. +

+ +

+ Target role trust policy: +

+
+
+ +
+
+                  {targetRoleTrustPolicy}
+                
+
+

+ Note: The Principal role ARN shown above is from the PAM Resource + selected for this account. The External ID{" "} + {projectId} is your + current project ID. If your target role name doesn't match the wildcard pattern + in your PAM Resource's role's permissions policy, you'll need to + update that policy to include this role's ARN. +

+
+
+
+ +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index fc16623495..d146424836 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,14 @@ const CreateForm = ({ return ( ); + case PamResourceType.AwsIam: + return ( + + ); default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -100,6 +109,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..a2c668b279 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; @@ -101,7 +103,7 @@ export const PamAccountRow = ({ )} - {account.lastRotatedAt && ( + {"lastRotatedAt" in account && account.lastRotatedAt && ( } 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 1dd5e7f307..f19a1a308a 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,21 @@ 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) { + let fullAccountPath = e?.name; + const folderPath = e.folderId ? folderPaths[e.folderId] : undefined; + if (folderPath) { + const path = folderPath.replace(/^\/+|\/+$/g, ""); + fullAccountPath = `${path}/${e?.name}`; + } + + accessAwsIam(e, fullAccountPath); + } 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/ResourceSelect.tsx b/frontend/src/pages/pam/PamAccountsPage/components/ResourceSelect.tsx index 8791671957..d9667ddbf4 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/ResourceSelect.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/ResourceSelect.tsx @@ -80,6 +80,8 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => { return; } + // Clear search when a value is selected so the selected label is shown + setSearch(""); onChange(newValue); }} isLoading={isPending} 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..f2cce369b3 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/useAccessAwsIamAccount.tsx @@ -0,0 +1,54 @@ +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, accountPath: string) => { + if (account.resource.resourceType !== PamResourceType.AwsIam) { + return false; + } + + setLoadingAccountId(account.id); + + try { + const response = await accessPamAccount.mutateAsync({ + accountId: account.id, + accountPath, + projectId: account.projectId, + duration: `${(account.credentials as TAwsIamCredentials).defaultSessionDuration}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..ec97d0a6fd --- /dev/null +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/AwsIamResourceForm.tsx @@ -0,0 +1,224 @@ +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"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + ModalClose +} from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; +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({ + 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 permissionsPolicy = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam:::role/-*" + }] +}`; + + const trustPolicy = `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::root" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "${projectId}" + } + } + }] +}`; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: resource ?? { + resourceType: PamResourceType.AwsIam, + connectionDetails: { + 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 a + wildcard pattern (e.g.,{" "} + pam-* or{" "} + privileged-*) so you + can add new accounts without updating this policy. Choose a prefix that fits your + naming conventions. +

+
+
+ +
+
+                  {permissionsPolicy}
+                
+
+ +

+ 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: +

+
+
+ +
+
+                  {trustPolicy}
+                
+
+

+ Note: Use{" "} + + {INFISICAL_AWS_ACCOUNT_US} + {" "} + for US region or{" "} + + {INFISICAL_AWS_ACCOUNT_EU} + {" "} + for EU region. Replace{" "} + + <INFISICAL_AWS_ACCOUNT_ID> + {" "} + with the appropriate Infisical AWS account ID for your 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