Files
infisical/backend/src/ee/services/pam-account/pam-account-service.ts
Akhil Mohan 815af3a892 Merge pull request #5118 from Infisical/chore/removal-ff
feat: removed feature flag for ai and pam
2026-01-07 14:08:13 +05:30

1169 lines
40 KiB
TypeScript

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,
decryptResourceMetadata
} from "@app/ee/services/pam-resource/pam-resource-fns";
import { SSHAuthMethod } from "@app/ee/services/pam-resource/ssh/ssh-resource-enums";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionActions,
ProjectPermissionPamAccountActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { createSshCert, createSshKeyPair } from "@app/ee/services/ssh/ssh-certificate-authority-fns";
import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types";
import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import {
BadRequestError,
DatabaseError,
ForbiddenRequestError,
NotFoundError,
PolicyViolationError
} from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { TApprovalPolicyDALFactory } from "@app/services/approval-policy/approval-policy-dal";
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/approval-policy-factory";
import { TApprovalRequestGrantsDALFactory } from "@app/services/approval-policy/approval-request-dal";
import { ActorType, MfaMethod } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TMfaSessionServiceFactory } from "@app/services/mfa-session/mfa-session-service";
import { MfaSessionStatus } from "@app/services/mfa-session/mfa-session-types";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TPamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal";
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 { TRedisAccountCredentials } from "../pam-resource/redis/redis-resource-types";
import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types";
import { TSSHAccountCredentials, TSSHResourceMetadata } 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";
import { TPamAccountDALFactory } from "./pam-account-dal";
import { PamAccountView } from "./pam-account-enums";
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
import { TAccessAccountDTO, TCreateAccountDTO, TListAccountsDTO, TUpdateAccountDTO } from "./pam-account-types";
type TPamAccountServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory;
pamSessionDAL: TPamSessionDALFactory;
pamAccountDAL: TPamAccountDALFactory;
pamFolderDAL: TPamFolderDALFactory;
mfaSessionService: TMfaSessionServiceFactory;
projectDAL: TProjectDALFactory;
orgDAL: TOrgDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
gatewayV2Service: Pick<
TGatewayV2ServiceFactory,
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
>;
userDAL: TUserDALFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
smtpService: Pick<TSmtpService, "sendMail">;
approvalPolicyDAL: TApprovalPolicyDALFactory;
approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory;
pamSessionExpirationService: Pick<TPamSessionExpirationServiceFactory, "scheduleSessionExpiration">;
};
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
const ROTATION_CONCURRENCY_LIMIT = 10;
export const pamAccountServiceFactory = ({
pamResourceDAL,
pamSessionDAL,
pamAccountDAL,
mfaSessionService,
pamFolderDAL,
projectDAL,
orgDAL,
userDAL,
permissionService,
kmsService,
gatewayV2Service,
auditLogService,
approvalPolicyDAL,
approvalRequestGrantsDAL,
pamSessionExpirationService
}: TPamAccountServiceFactoryDep) => {
const create = async (
{
credentials,
resourceId,
name,
description,
folderId,
rotationEnabled,
rotationIntervalSeconds,
requireMfa
}: TCreateAccountDTO,
actor: OrgServiceActor
) => {
if (rotationEnabled && (rotationIntervalSeconds === undefined || rotationIntervalSeconds === null)) {
throw new BadRequestError({
message: "Rotation interval must be defined when rotation is enabled."
});
}
const resource = await pamResourceDAL.findById(resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
if (!resource.encryptedRotationAccountCredentials && rotationEnabled) {
throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" });
}
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId,
projectId: resource.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Create,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: name,
accountPath
})
);
const connectionDetails = await decryptResourceConnectionDetails({
projectId: resource.projectId,
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService
});
// Decrypt resource metadata if available
const resourceMetadata = resource.encryptedResourceMetadata
? await decryptResourceMetadata({
encryptedMetadata: resource.encryptedResourceMetadata,
projectId: resource.projectId,
kmsService
})
: undefined;
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service,
resource.projectId,
resourceMetadata
);
const validatedCredentials = await factory.validateAccountCredentials(credentials);
const encryptedCredentials = await encryptAccountCredentials({
credentials: validatedCredentials,
projectId: resource.projectId,
kmsService
});
try {
const account = await pamAccountDAL.create({
projectId: resource.projectId,
resourceId: resource.id,
encryptedCredentials,
name,
description,
folderId,
rotationEnabled,
rotationIntervalSeconds,
requireMfa
});
return {
...(await decryptAccount(account, resource.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 updateById = async (
{
accountId,
credentials,
description,
name,
rotationEnabled,
rotationIntervalSeconds,
requireMfa
}: TUpdateAccountDTO,
actor: OrgServiceActor
) => {
const account = await pamAccountDAL.findById(accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Edit,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const updateDoc: Partial<TPamAccounts> = {};
if (name !== undefined) {
updateDoc.name = name;
}
if (requireMfa !== undefined) {
updateDoc.requireMfa = requireMfa;
}
if (description !== undefined) {
updateDoc.description = description;
}
if (rotationEnabled !== undefined) {
if (!resource.encryptedRotationAccountCredentials && rotationEnabled) {
throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" });
}
updateDoc.rotationEnabled = rotationEnabled;
}
if (rotationIntervalSeconds !== undefined) {
updateDoc.rotationIntervalSeconds = rotationIntervalSeconds;
}
if (credentials !== undefined) {
const connectionDetails = await decryptResourceConnectionDetails({
projectId: account.projectId,
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService
});
// Decrypt resource metadata if available
const resourceMetadata = resource.encryptedResourceMetadata
? await decryptResourceMetadata({
encryptedMetadata: resource.encryptedResourceMetadata,
projectId: account.projectId,
kmsService
})
: undefined;
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service,
account.projectId,
resourceMetadata
);
const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId,
kmsService
});
// Logic to prevent overwriting unedited censored values
const finalCredentials = await factory.handleOverwritePreventionForCensoredValues(
credentials,
decryptedCredentials
);
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
const encryptedCredentials = await encryptAccountCredentials({
credentials: validatedCredentials,
projectId: account.projectId,
kmsService
});
updateDoc.encryptedCredentials = encryptedCredentials;
}
// If nothing was updated, return the fetched account
if (Object.keys(updateDoc).length === 0) {
return decryptAccount(account, account.projectId, kmsService);
}
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
}
};
} 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) => {
const account = await pamAccountDAL.findById(id);
if (!account) throw new NotFoundError({ message: `Account with ID '${id}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Delete,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const deletedAccount = await pamAccountDAL.deleteById(id);
return {
...(await decryptAccount(deletedAccount, account.projectId, kmsService)),
resource: {
id: resource.id,
name: resource.name,
resourceType: resource.resourceType,
rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials
}
};
};
const list = async ({
projectId,
accountPath,
accountView,
actor,
actorId,
actorAuthMethod,
actorOrgId,
...params
}: TListAccountsDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.PAM
});
const limit = params.limit || 20;
const offset = params.offset || 0;
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
const folder = accountPath === "/" ? null : await pamFolderDAL.findByPath(projectId, accountPath);
if (accountPath !== "/" && !folder) {
return { accounts: [], folders: [], totalCount: 0, folderPaths: {} };
}
const folderId = folder?.id;
let totalFolderCount = 0;
if (canReadFolders && accountView === PamAccountView.Nested) {
const { totalCount } = await pamFolderDAL.findByProjectId({
projectId,
parentId: folderId,
search: params.search
});
totalFolderCount = totalCount;
}
let folders: TPamFolders[] = [];
if (canReadFolders && accountView === PamAccountView.Nested && offset < totalFolderCount) {
const folderLimit = Math.min(limit, totalFolderCount - offset);
const { folders: foldersResp } = await pamFolderDAL.findByProjectId({
projectId,
parentId: folderId,
limit: folderLimit,
offset,
search: params.search,
orderBy: params.orderBy,
orderDirection: params.orderDirection
});
folders = foldersResp;
}
let accountsWithResourceDetails: Awaited<
ReturnType<typeof pamAccountDAL.findByProjectIdWithResourceDetails>
>["accounts"] = [];
let totalAccountCount = 0;
const accountsToFetch = limit - folders.length;
if (accountsToFetch > 0) {
const accountOffset = Math.max(0, offset - totalFolderCount);
const { accounts, totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({
projectId,
folderId,
accountView,
offset: accountOffset,
limit: accountsToFetch,
search: params.search,
orderBy: params.orderBy,
orderDirection: params.orderDirection,
filterResourceIds: params.filterResourceIds
});
accountsWithResourceDetails = accounts;
totalAccountCount = totalCount;
} else {
// if no accounts are to be fetched for the current page, we still need the total count for pagination
const { totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({
projectId,
folderId,
accountView,
search: params.search,
filterResourceIds: params.filterResourceIds
});
totalAccountCount = totalCount;
}
const totalCount = totalFolderCount + totalAccountCount;
const decryptedAndPermittedAccounts: Array<
Omit<TPamAccounts, "encryptedCredentials" | "encryptedLastRotationMessage"> & {
resource: Pick<TPamResources, "id" | "name" | "resourceType"> & { rotationCredentialsConfigured: boolean };
credentials: TPamAccountCredentials;
lastRotationMessage: string | null;
}
> = [];
for await (const account of accountsWithResourceDetails) {
// Check permission for each individual account
if (
permission.can(
ProjectPermissionPamAccountActions.Read,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: account.resource.name,
accountName: account.name,
accountPath
})
)
) {
// Decrypt the account only if the user has permission to read it
const decryptedAccount = await decryptAccount(account, account.projectId, kmsService);
decryptedAndPermittedAccounts.push({
...decryptedAccount,
resource: {
id: account.resource.id,
name: account.resource.name,
resourceType: account.resource.resourceType,
rotationCredentialsConfigured: !!account.resource.encryptedRotationAccountCredentials
}
});
}
}
const folderPaths: Record<string, string> = {};
const accountFolderIds = [
...new Set(decryptedAndPermittedAccounts.flatMap((a) => (a.folderId ? [a.folderId] : [])))
];
await Promise.all(
accountFolderIds.map(async (fId) => {
folderPaths[fId] = await getFullPamFolderPath({
pamFolderDAL,
folderId: fId,
projectId
});
})
);
return {
accounts: decryptedAndPermittedAccounts,
folders,
totalCount,
folderId,
folderPaths
};
};
const access = async (
{
accountPath,
projectId,
actorEmail,
actorIp,
actorName,
actorUserAgent,
duration,
mfaSessionId
}: TAccessAccountDTO,
actor: OrgServiceActor
) => {
const pathSegments: string[] = accountPath.split("/").filter(Boolean);
if (pathSegments.length === 0) {
throw new BadRequestError({ message: "Invalid accountPath. Path must contain at least the account name." });
}
const accountName: string = pathSegments[pathSegments.length - 1] ?? "";
const folderPathSegments: string[] = pathSegments.slice(0, -1);
const folderPath: string = folderPathSegments.length > 0 ? `/${folderPathSegments.join("/")}` : "/";
let folderId: string | null = null;
if (folderPath !== "/") {
const folder = await pamFolderDAL.findByPath(projectId, folderPath);
if (!folder) {
throw new NotFoundError({ message: `Folder at path '${folderPath}' not found` });
}
folderId = folder.id;
}
const account = await pamAccountDAL.findOne({
projectId,
folderId,
name: accountName
});
if (!account) {
throw new NotFoundError({
message: `Account with name '${accountName}' not found at path '${accountPath}'`
});
}
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const fac = APPROVAL_POLICY_FACTORY_MAP[ApprovalPolicyType.PamAccess](ApprovalPolicyType.PamAccess);
const inputs = {
resourceId: resource.id,
accountPath: path.join(folderPath, account.name)
};
const canAccess = await fac.canAccess(approvalRequestGrantsDAL, resource.projectId, actor.id, inputs);
// Grant does not exist, check policy and fallback to permission check
if (!canAccess) {
const policy = await fac.matchPolicy(approvalPolicyDAL, resource.projectId, inputs);
if (policy) {
throw new PolicyViolationError({
message: "A policy is in place for this resource",
details: {
policyId: policy.id,
policyName: policy.name,
policyType: policy.type
}
});
}
// If there isn't a policy in place, continue with checking permission
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Access,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath: folderPath
})
);
}
const project = await projectDAL.findById(account.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${account.projectId}' not found` });
const actorUser = await userDAL.findById(actor.id);
if (!actorUser) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
// If no mfaSessionId is provided, create a new MFA session
if (!mfaSessionId && account.requireMfa) {
// Get organization to check if MFA is enforced at org level
const org = await orgDAL.findOrgById(project.orgId);
if (!org) throw new NotFoundError({ message: `Organization with ID '${project.orgId}' not found` });
// Determine which MFA method to use
// Priority: org-enforced > user-selected > email as fallback
const orgMfaMethod = org.enforceMfa ? (org.selectedMfaMethod as MfaMethod | null) : undefined;
const userMfaMethod = actorUser.isMfaEnabled ? (actorUser.selectedMfaMethod as MfaMethod | null) : undefined;
const mfaMethod = (orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL) as MfaMethod;
// Create MFA session
const newMfaSessionId = await mfaSessionService.createMfaSession(actorUser.id, account.id, mfaMethod);
// If MFA method is email, send the code immediately
if (mfaMethod === MfaMethod.EMAIL && actorUser.email) {
await mfaSessionService.sendMfaCode(actorUser.id, actorUser.email);
}
// Throw an error with the mfaSessionId to signal that MFA is required
throw new BadRequestError({
message: "MFA verification required to access PAM account",
name: "SESSION_MFA_REQUIRED",
details: {
mfaSessionId: newMfaSessionId,
mfaMethod
}
});
}
if (mfaSessionId && account.requireMfa) {
const mfaSession = await mfaSessionService.getMfaSession(mfaSessionId);
if (!mfaSession) {
throw new BadRequestError({
message: "MFA session not found or expired"
});
}
// Verify the session belongs to the current user
if (mfaSession.userId !== actor.id) {
throw new BadRequestError({
message: "MFA session does not belong to current user"
});
}
// Verify the session is for the same account
if (mfaSession.resourceId !== account.id) {
throw new BadRequestError({
message: "MFA session is for a different account"
});
}
// Check if MFA session is active
if (mfaSession.status !== MfaSessionStatus.ACTIVE) {
throw new BadRequestError({
message: "MFA session is not active. Please complete MFA verification first."
});
}
// MFA verified successfully, delete the session and proceed with access
await mfaSessionService.deleteMfaSession(mfaSessionId);
}
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,
actorIp,
actorName,
actorUserAgent,
projectId,
resourceName: resource.name,
resourceType: resource.resourceType,
status: PamSessionStatus.Starting,
accountId: account.id,
userId: actor.id,
expiresAt: new Date(Date.now() + duration)
});
if (!gatewayId) {
throw new BadRequestError({ message: "Gateway ID is required for this resource type" });
}
const { host, port } =
resourceType !== PamResource.Kubernetes
? connectionDetails
: (() => {
const url = new URL(connectionDetails.url);
let portNumber: number | undefined;
if (url.port) {
portNumber = Number(url.port);
} else {
portNumber = url.protocol === "https:" ? 443 : 80;
}
return {
host: url.hostname,
port: portNumber
};
})();
const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({
gatewayId,
duration,
sessionId: session.id,
resourceType: resource.resourceType as PamResource,
host,
port,
actorMetadata: {
id: actor.id,
type: actor.type,
name: user.email ?? ""
}
});
if (!gatewayConnectionDetails) {
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
}
let metadata;
switch (resourceType) {
case PamResource.Postgres:
case PamResource.MySQL:
{
const connectionCredentials = (await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService,
projectId
})) as TSqlResourceConnectionDetails;
const credentials = (await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId
})) as TSqlAccountCredentials;
metadata = {
username: credentials.username,
database: connectionCredentials.database,
accountName: account.name,
accountPath: folderPath
};
}
break;
case PamResource.Redis:
{
const credentials = (await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId
})) as TRedisAccountCredentials;
metadata = {
username: credentials.username,
accountName: account.name,
accountPath: folderPath
};
}
break;
case PamResource.SSH:
{
const credentials = (await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId
})) as TSSHAccountCredentials;
metadata = {
username: credentials.username
};
}
break;
case PamResource.Kubernetes:
metadata = {
resourceName: resource.name,
accountName: account.name,
accountPath
};
break;
default:
break;
}
return {
sessionId: session.id,
resourceType,
relayClientCertificate: gatewayConnectionDetails.relay.clientCertificate,
relayClientPrivateKey: gatewayConnectionDetails.relay.clientPrivateKey,
relayServerCertificateChain: gatewayConnectionDetails.relay.serverCertificateChain,
gatewayClientCertificate: gatewayConnectionDetails.gateway.clientCertificate,
gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey,
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
relayHost: gatewayConnectionDetails.relayHost,
projectId,
account,
metadata
};
};
const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => {
// To be hit by gateways only
if (actor.type !== ActorType.IDENTITY) {
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
}
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission({
actor: actor.type,
actorId: actor.id,
orgId: project.orgId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
scope: OrganizationActionScope.Any
});
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" });
// Verify that the session has not ended
if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) {
throw new BadRequestError({ message: "Session has ended or expired" });
}
const account = await pamAccountDAL.findById(session.accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
if (resource.gatewayId && resource.gatewayIdentityId !== actor.id) {
throw new ForbiddenRequestError({
message: "Identity does not have access to fetch the PAM session credentials"
});
}
const decryptedAccount = await decryptAccount(account, session.projectId, kmsService);
const decryptedResource = await decryptResource(resource, session.projectId, kmsService);
let sessionStarted = false;
// Mark session as started
if (session.status === PamSessionStatus.Starting) {
await pamSessionDAL.updateById(sessionId, {
status: PamSessionStatus.Active,
startedAt: new Date()
});
sessionStarted = true;
}
// Handle SSH certificate-based authentication
if (decryptedResource.resourceType === PamResource.SSH) {
const accountCredentials = decryptedAccount.credentials as TSSHAccountCredentials;
if (accountCredentials.authMethod === SSHAuthMethod.Certificate) {
if (!resource.encryptedResourceMetadata) {
throw new BadRequestError({
message: "SSH resource does not have a CA configured for certificate-based authentication"
});
}
const metadata = await decryptResourceMetadata<TSSHResourceMetadata>({
encryptedMetadata: resource.encryptedResourceMetadata,
projectId: session.projectId,
kmsService
});
const { caPrivateKey, caKeyAlgorithm } = metadata;
// Generate a new key pair for the user
const keyAlgorithm = (caKeyAlgorithm as SshCertKeyAlgorithm) || SshCertKeyAlgorithm.ED25519;
const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm);
// Calculate TTL from session expiry
const ttlSeconds = Math.max(Math.floor((session.expiresAt.getTime() - Date.now()) / 1000), 60);
// Sign the public key with the CA to create a certificate
const { signedPublicKey } = await createSshCert({
caPrivateKey,
clientPublicKey: publicKey,
keyId: `pam-session-${session.id}`,
principals: [accountCredentials.username],
requestedTtl: `${ttlSeconds}s`,
certType: SshCertType.USER
});
return {
credentials: {
...decryptedResource.connectionDetails,
authMethod: SSHAuthMethod.Certificate,
username: accountCredentials.username,
privateKey,
certificate: signedPublicKey
},
projectId: project.id,
account,
sessionStarted
};
}
}
return {
credentials: {
...decryptedResource.connectionDetails,
...decryptedAccount.credentials
},
projectId: project.id,
account,
sessionStarted
};
};
const rotateAllDueAccounts = async () => {
const accounts = await pamAccountDAL.findAccountsDueForRotation();
for (let i = 0; i < accounts.length; i += ROTATION_CONCURRENCY_LIMIT) {
const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT);
const rotationPromises = batch.map(async (account) => {
let logResourceType = "unknown";
try {
await pamAccountDAL.transaction(async (tx) => {
const resource = await pamResourceDAL.findById(account.resourceId, tx);
if (!resource || !resource.encryptedRotationAccountCredentials) return;
logResourceType = resource.resourceType;
const { connectionDetails, rotationAccountCredentials, gatewayId, resourceType } = await decryptResource(
resource,
account.projectId,
kmsService
);
if (!rotationAccountCredentials) return;
const accountCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId,
kmsService
});
const factory = PAM_RESOURCE_FACTORY_MAP[resourceType as PamResource](
resourceType as PamResource,
connectionDetails,
gatewayId,
gatewayV2Service,
account.projectId
);
const newCredentials = await factory.rotateAccountCredentials(
rotationAccountCredentials,
accountCredentials
);
const encryptedCredentials = await encryptAccountCredentials({
credentials: newCredentials,
projectId: account.projectId,
kmsService
});
await pamAccountDAL.updateById(
account.id,
{
encryptedCredentials,
lastRotatedAt: new Date(),
rotationStatus: "success",
encryptedLastRotationMessage: null
},
tx
);
await auditLogService.createAuditLog({
projectId: account.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION,
metadata: {
accountId: account.id,
accountName: account.name,
resourceId: resource.id,
resourceType: logResourceType
}
}
});
});
} catch (error) {
logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`);
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred";
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: account.projectId
});
const { cipherTextBlob: encryptedMessage } = encryptor({
plainText: Buffer.from(errorMessage)
});
await pamAccountDAL.updateById(account.id, {
rotationStatus: "failed",
encryptedLastRotationMessage: encryptedMessage
});
await auditLogService.createAuditLog({
projectId: account.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED,
metadata: {
accountId: account.id,
accountName: account.name,
resourceId: account.resourceId,
resourceType: logResourceType,
errorMessage
}
}
});
}
});
// eslint-disable-next-line no-await-in-loop
await Promise.all(rotationPromises);
}
};
return {
create,
updateById,
deleteById,
list,
access,
getSessionCredentials,
rotateAllDueAccounts
};
};