mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-09 01:28:13 +00:00
feat: add AWS IAM resource support with console access functionality
- Introduced AWS IAM resource type in the system, allowing users to create and manage AWS IAM accounts. - Implemented AWS IAM resource forms and account forms for creating and updating IAM resources and accounts. - Added functionality to generate AWS Console URLs for IAM accounts, enabling direct access to the AWS Console. - Updated various components and hooks to handle AWS IAM-specific logic, including session expiration and access management. - Enhanced the UI to reflect AWS IAM integration, including new modals and forms for user interaction.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
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<void> {
|
||||
const hasGatewayId = await knex.schema.hasColumn(TableName.PamResource, "gatewayId");
|
||||
if (hasGatewayId) {
|
||||
await knex.schema.alterTable(TableName.PamResource, (t) => {
|
||||
t.uuid("gatewayId").notNullable().alter();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<PamResource, (server: Fasti
|
||||
createAccountSchema: CreateSSHAccountSchema,
|
||||
updateAccountSchema: UpdateSSHAccountSchema
|
||||
});
|
||||
},
|
||||
[PamResource.AwsIam]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.AwsIam,
|
||||
accountResponseSchema: SanitizedAwsIamAccountWithResourceSchema,
|
||||
createAccountSchema: CreateAwsIamAccountSchema,
|
||||
updateAccountSchema: UpdateAwsIamAccountSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { PamFoldersSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums";
|
||||
import { SanitizedAwsIamAccountWithResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas";
|
||||
import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
@@ -18,7 +19,8 @@ import { AuthMode } from "@app/services/auth/auth-type";
|
||||
const SanitizedAccountSchema = z.union([
|
||||
SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS
|
||||
SanitizedPostgresAccountWithResourceSchema,
|
||||
SanitizedMySQLAccountWithResourceSchema
|
||||
SanitizedMySQLAccountWithResourceSchema,
|
||||
SanitizedAwsIamAccountWithResourceSchema
|
||||
]);
|
||||
|
||||
export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
@@ -124,18 +126,29 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
})
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
sessionId: z.string(),
|
||||
resourceType: z.nativeEnum(PamResource),
|
||||
relayClientCertificate: z.string(),
|
||||
relayClientPrivateKey: z.string(),
|
||||
relayServerCertificateChain: z.string(),
|
||||
gatewayClientCertificate: z.string(),
|
||||
gatewayClientPrivateKey: z.string(),
|
||||
gatewayServerCertificateChain: z.string(),
|
||||
relayHost: z.string(),
|
||||
metadata: z.record(z.string(), z.string().optional()).optional()
|
||||
})
|
||||
200: z.union([
|
||||
// Gateway-based resources (Postgres, MySQL, SSH)
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
resourceType: z.nativeEnum(PamResource),
|
||||
relayClientCertificate: z.string(),
|
||||
relayClientPrivateKey: z.string(),
|
||||
relayServerCertificateChain: z.string(),
|
||||
gatewayClientCertificate: z.string(),
|
||||
gatewayClientPrivateKey: z.string(),
|
||||
gatewayServerCertificateChain: z.string(),
|
||||
relayHost: z.string(),
|
||||
metadata: z.record(z.string(), z.string().optional()).optional()
|
||||
}),
|
||||
// AWS IAM (no gateway, returns console URL)
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
resourceType: z.literal(PamResource.AwsIam),
|
||||
consoleUrl: z.string().url(),
|
||||
projectId: z.string(),
|
||||
metadata: z.record(z.string(), z.string().optional()).optional()
|
||||
})
|
||||
])
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
|
||||
@@ -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<PamResource, (server: Fast
|
||||
createResourceSchema: CreateSSHResourceSchema,
|
||||
updateResourceSchema: UpdateSSHResourceSchema
|
||||
});
|
||||
},
|
||||
[PamResource.AwsIam]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.AwsIam,
|
||||
resourceResponseSchema: SanitizedAwsIamResourceSchema,
|
||||
createResourceSchema: CreateAwsIamResourceSchema,
|
||||
updateResourceSchema: UpdateAwsIamResourceSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ export const registerPamResourceEndpoints = <T extends TPamResource>({
|
||||
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 = <T extends TPamResource>({
|
||||
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 = <T extends TPamResource>({
|
||||
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 })
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -4158,7 +4158,7 @@ interface PamResourceCreateEvent {
|
||||
type: EventType.PAM_RESOURCE_CREATE;
|
||||
metadata: {
|
||||
resourceType: string;
|
||||
gatewayId: string;
|
||||
gatewayId?: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamFolders, TPamResources } from "@app/db/schemas";
|
||||
import {
|
||||
extractAwsAccountIdFromArn,
|
||||
generateConsoleFederationUrl,
|
||||
TAwsIamAccountCredentials
|
||||
} from "@app/ee/services/pam-resource/aws-iam";
|
||||
import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory";
|
||||
import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
@@ -16,6 +21,7 @@ import { OrgServiceActor } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TPamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
@@ -27,7 +33,8 @@ import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
|
||||
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
|
||||
import { PamResource } from "../pam-resource/pam-resource-enums";
|
||||
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
|
||||
import { TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types";
|
||||
import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types";
|
||||
import { TSSHAccountCredentials } from "../pam-resource/ssh/ssh-resource-types";
|
||||
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
|
||||
import { PamSessionStatus } from "../pam-session/pam-session-enums";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
@@ -51,6 +58,7 @@ type TPamAccountServiceFactoryDep = {
|
||||
>;
|
||||
userDAL: TUserDALFactory;
|
||||
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||
pamSessionExpirationService: Pick<TPamSessionExpirationServiceFactory, "scheduleSessionExpiration">;
|
||||
};
|
||||
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
|
||||
|
||||
@@ -67,7 +75,8 @@ export const pamAccountServiceFactory = ({
|
||||
licenseService,
|
||||
kmsService,
|
||||
gatewayV2Service,
|
||||
auditLogService
|
||||
auditLogService,
|
||||
pamSessionExpirationService
|
||||
}: TPamAccountServiceFactoryDep) => {
|
||||
const create = async (
|
||||
{
|
||||
@@ -135,7 +144,8 @@ export const pamAccountServiceFactory = ({
|
||||
resource.resourceType as PamResource,
|
||||
connectionDetails,
|
||||
resource.gatewayId,
|
||||
gatewayV2Service
|
||||
gatewayV2Service,
|
||||
resource.projectId
|
||||
);
|
||||
const validatedCredentials = await factory.validateAccountCredentials(credentials);
|
||||
|
||||
@@ -250,7 +260,8 @@ export const pamAccountServiceFactory = ({
|
||||
resource.resourceType as PamResource,
|
||||
connectionDetails,
|
||||
resource.gatewayId,
|
||||
gatewayV2Service
|
||||
gatewayV2Service,
|
||||
account.projectId
|
||||
);
|
||||
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
@@ -527,6 +538,65 @@ export const pamAccountServiceFactory = ({
|
||||
})
|
||||
);
|
||||
|
||||
const { connectionDetails, gatewayId, resourceType } = await decryptResource(
|
||||
resource,
|
||||
account.projectId,
|
||||
kmsService
|
||||
);
|
||||
|
||||
const user = await userDAL.findById(actor.id);
|
||||
if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
|
||||
|
||||
if (resourceType === PamResource.AwsIam) {
|
||||
const awsCredentials = (await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
})) as TAwsIamAccountCredentials;
|
||||
|
||||
const { consoleUrl, expiresAt } = await generateConsoleFederationUrl({
|
||||
connectionDetails,
|
||||
targetRoleArn: awsCredentials.targetRoleArn,
|
||||
roleSessionName: actorEmail,
|
||||
projectId: account.projectId, // Use project ID as External ID for security
|
||||
sessionDuration: awsCredentials.maxSessionDuration
|
||||
});
|
||||
|
||||
const session = await pamSessionDAL.create({
|
||||
accountName: account.name,
|
||||
actorEmail,
|
||||
actorIp,
|
||||
actorName,
|
||||
actorUserAgent,
|
||||
projectId: account.projectId,
|
||||
resourceName: resource.name,
|
||||
resourceType: resource.resourceType,
|
||||
status: PamSessionStatus.Active, // AWS IAM sessions are immediately active
|
||||
accountId: account.id,
|
||||
userId: actor.id,
|
||||
expiresAt,
|
||||
startedAt: new Date()
|
||||
});
|
||||
|
||||
// Schedule session expiration job to run at expiresAt
|
||||
await pamSessionExpirationService.scheduleSessionExpiration(session.id, expiresAt);
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
resourceType,
|
||||
projectId: account.projectId,
|
||||
account,
|
||||
consoleUrl,
|
||||
metadata: {
|
||||
awsAccountId: extractAwsAccountIdFromArn(connectionDetails.roleArn),
|
||||
targetRoleArn: awsCredentials.targetRoleArn,
|
||||
federatedUsername: actorEmail,
|
||||
expiresAt: expiresAt.toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// For gateway-based resources (Postgres, MySQL, SSH), create session first
|
||||
const session = await pamSessionDAL.create({
|
||||
accountName: account.name,
|
||||
actorEmail,
|
||||
@@ -541,23 +611,17 @@ export const pamAccountServiceFactory = ({
|
||||
userId: actor.id,
|
||||
expiresAt: new Date(Date.now() + duration)
|
||||
});
|
||||
|
||||
const { connectionDetails, gatewayId, resourceType } = await decryptResource(
|
||||
resource,
|
||||
account.projectId,
|
||||
kmsService
|
||||
);
|
||||
|
||||
const user = await userDAL.findById(actor.id);
|
||||
if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
|
||||
if (!gatewayId) {
|
||||
throw new BadRequestError({ message: "Gateway ID is required for this resource type" });
|
||||
}
|
||||
|
||||
const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({
|
||||
gatewayId,
|
||||
duration,
|
||||
sessionId: session.id,
|
||||
resourceType: resource.resourceType as PamResource,
|
||||
host: connectionDetails.host,
|
||||
port: connectionDetails.port,
|
||||
host: (connectionDetails as TSqlResourceConnectionDetails).host,
|
||||
port: (connectionDetails as TSqlResourceConnectionDetails).port,
|
||||
actorMetadata: {
|
||||
id: actor.id,
|
||||
type: actor.type,
|
||||
@@ -581,11 +645,11 @@ export const pamAccountServiceFactory = ({
|
||||
projectId: account.projectId
|
||||
})) as TSqlResourceConnectionDetails;
|
||||
|
||||
const credentials = await decryptAccountCredentials({
|
||||
const credentials = (await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
});
|
||||
})) as TSqlAccountCredentials;
|
||||
|
||||
metadata = {
|
||||
username: credentials.username,
|
||||
@@ -597,11 +661,11 @@ export const pamAccountServiceFactory = ({
|
||||
break;
|
||||
case PamResource.SSH:
|
||||
{
|
||||
const credentials = await decryptAccountCredentials({
|
||||
const credentials = (await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: account.projectId
|
||||
});
|
||||
})) as TSSHAccountCredentials;
|
||||
|
||||
metadata = {
|
||||
username: credentials.username
|
||||
@@ -674,7 +738,7 @@ export const pamAccountServiceFactory = ({
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
if (resource.gatewayIdentityId !== actor.id) {
|
||||
if (resource.gatewayId && resource.gatewayIdentityId !== actor.id) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Identity does not have access to fetch the PAM session credentials"
|
||||
});
|
||||
@@ -738,7 +802,8 @@ export const pamAccountServiceFactory = ({
|
||||
resourceType as PamResource,
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
gatewayV2Service
|
||||
gatewayV2Service,
|
||||
account.projectId
|
||||
);
|
||||
|
||||
const newCredentials = await factory.rotateAccountCredentials(
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { AssumeRoleCommand, STSClient, STSClientConfig } from "@aws-sdk/client-sts";
|
||||
|
||||
import { CustomAWSHasher } from "@app/lib/aws/hashing";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
|
||||
import { TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types";
|
||||
|
||||
const AWS_STS_MIN_DURATION_SECONDS = 900;
|
||||
|
||||
const createStsClient = (region: string): STSClient => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const config: STSClientConfig = {
|
||||
region,
|
||||
useFipsEndpoint: crypto.isFipsModeEnabled(),
|
||||
sha256: CustomAWSHasher,
|
||||
credentials:
|
||||
appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY
|
||||
}
|
||||
: undefined // if hosting on AWS
|
||||
};
|
||||
|
||||
return new STSClient(config);
|
||||
};
|
||||
|
||||
export const validatePamRoleConnection = async (
|
||||
connectionDetails: TAwsIamResourceConnectionDetails,
|
||||
projectId: string
|
||||
): Promise<boolean> => {
|
||||
const stsClient = createStsClient(connectionDetails.region);
|
||||
|
||||
try {
|
||||
await stsClient.send(
|
||||
new AssumeRoleCommand({
|
||||
RoleArn: connectionDetails.roleArn,
|
||||
RoleSessionName: `infisical-pam-validation-${Date.now()}`,
|
||||
DurationSeconds: AWS_STS_MIN_DURATION_SECONDS,
|
||||
ExternalId: projectId
|
||||
})
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateTargetRoleAssumption = async ({
|
||||
connectionDetails,
|
||||
targetRoleArn,
|
||||
projectId
|
||||
}: {
|
||||
connectionDetails: TAwsIamResourceConnectionDetails;
|
||||
targetRoleArn: string;
|
||||
projectId: string;
|
||||
}): Promise<boolean> => {
|
||||
const stsClient = createStsClient(connectionDetails.region);
|
||||
|
||||
try {
|
||||
// First assume the PAM role
|
||||
const pamRoleCredentials = await stsClient.send(
|
||||
new AssumeRoleCommand({
|
||||
RoleArn: connectionDetails.roleArn,
|
||||
RoleSessionName: `infisical-pam-validation-${Date.now()}`,
|
||||
DurationSeconds: AWS_STS_MIN_DURATION_SECONDS,
|
||||
ExternalId: projectId
|
||||
})
|
||||
);
|
||||
|
||||
if (!pamRoleCredentials.Credentials) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then use the PAM role credentials to assume the target role
|
||||
const pamStsClient = new STSClient({
|
||||
region: connectionDetails.region,
|
||||
useFipsEndpoint: crypto.isFipsModeEnabled(),
|
||||
sha256: CustomAWSHasher,
|
||||
credentials: {
|
||||
accessKeyId: pamRoleCredentials.Credentials.AccessKeyId!,
|
||||
secretAccessKey: pamRoleCredentials.Credentials.SecretAccessKey!,
|
||||
sessionToken: pamRoleCredentials.Credentials.SessionToken
|
||||
}
|
||||
});
|
||||
|
||||
await pamStsClient.send(
|
||||
new AssumeRoleCommand({
|
||||
RoleArn: targetRoleArn,
|
||||
RoleSessionName: `infisical-pam-target-validation-${Date.now()}`,
|
||||
DurationSeconds: AWS_STS_MIN_DURATION_SECONDS,
|
||||
ExternalId: projectId
|
||||
})
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assumes the target role and generates a federated console sign-in URL.
|
||||
*/
|
||||
export const generateConsoleFederationUrl = async ({
|
||||
connectionDetails,
|
||||
targetRoleArn,
|
||||
roleSessionName,
|
||||
projectId,
|
||||
sessionDuration
|
||||
}: {
|
||||
connectionDetails: TAwsIamResourceConnectionDetails;
|
||||
targetRoleArn: string;
|
||||
roleSessionName: string;
|
||||
projectId: string;
|
||||
sessionDuration: number;
|
||||
}): Promise<{ consoleUrl: string; expiresAt: Date }> => {
|
||||
const stsClient = createStsClient(connectionDetails.region);
|
||||
|
||||
// First assume the PAM role
|
||||
const pamRoleCredentials = await stsClient.send(
|
||||
new AssumeRoleCommand({
|
||||
RoleArn: connectionDetails.roleArn,
|
||||
RoleSessionName: `infisical-pam-${Date.now()}`,
|
||||
DurationSeconds: sessionDuration,
|
||||
ExternalId: projectId
|
||||
})
|
||||
);
|
||||
|
||||
if (!pamRoleCredentials.Credentials) {
|
||||
throw new Error("Failed to assume PAM role");
|
||||
}
|
||||
|
||||
// Role chaining: use PAM role credentials to assume the target role
|
||||
const pamStsClient = new STSClient({
|
||||
region: connectionDetails.region,
|
||||
useFipsEndpoint: crypto.isFipsModeEnabled(),
|
||||
sha256: CustomAWSHasher,
|
||||
credentials: {
|
||||
accessKeyId: pamRoleCredentials.Credentials.AccessKeyId!,
|
||||
secretAccessKey: pamRoleCredentials.Credentials.SecretAccessKey!,
|
||||
sessionToken: pamRoleCredentials.Credentials.SessionToken
|
||||
}
|
||||
});
|
||||
|
||||
const targetRoleCredentials = await pamStsClient.send(
|
||||
new AssumeRoleCommand({
|
||||
RoleArn: targetRoleArn,
|
||||
RoleSessionName: roleSessionName,
|
||||
DurationSeconds: sessionDuration,
|
||||
ExternalId: projectId
|
||||
})
|
||||
);
|
||||
|
||||
if (!targetRoleCredentials.Credentials) {
|
||||
throw new Error("Failed to assume target role");
|
||||
}
|
||||
|
||||
const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = targetRoleCredentials.Credentials;
|
||||
|
||||
// Generate federation URL
|
||||
const sessionJson = JSON.stringify({
|
||||
sessionId: AccessKeyId,
|
||||
sessionKey: SecretAccessKey,
|
||||
sessionToken: SessionToken
|
||||
});
|
||||
|
||||
const federationEndpoint = "https://signin.aws.amazon.com/federation";
|
||||
|
||||
// Console destination can be regional
|
||||
const getConsoleHost = () =>
|
||||
connectionDetails.region === "us-east-1"
|
||||
? "console.aws.amazon.com"
|
||||
: `${connectionDetails.region}.console.aws.amazon.com`;
|
||||
|
||||
const signinTokenUrl = `${federationEndpoint}?Action=getSigninToken&Session=${encodeURIComponent(sessionJson)}`;
|
||||
|
||||
const tokenResponse = await fetch(signinTokenUrl);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
// eslint-disable-next-line no-console
|
||||
throw new Error(`AWS federation endpoint returned error (${tokenResponse.status}): ${errorText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const responseText = await tokenResponse.text();
|
||||
let tokenData: { SigninToken: string };
|
||||
|
||||
try {
|
||||
tokenData = JSON.parse(responseText) as { SigninToken: string };
|
||||
} catch {
|
||||
throw new Error(`AWS federation endpoint returned invalid response: ${responseText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
if (!tokenData.SigninToken) {
|
||||
throw new Error(`AWS federation endpoint did not return a SigninToken: ${responseText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const consoleDestination = `https://${getConsoleHost()}/`;
|
||||
const consoleUrl = `${federationEndpoint}?Action=login&SigninToken=${encodeURIComponent(tokenData.SigninToken)}&Destination=${encodeURIComponent(consoleDestination)}`;
|
||||
|
||||
return {
|
||||
consoleUrl,
|
||||
expiresAt: Expiration
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { PamResource } from "../pam-resource-enums";
|
||||
import {
|
||||
TPamResourceFactory,
|
||||
TPamResourceFactoryRotateAccountCredentials,
|
||||
TPamResourceFactoryValidateAccountCredentials
|
||||
} from "../pam-resource-types";
|
||||
import { validatePamRoleConnection, validateTargetRoleAssumption } from "./aws-iam-federation";
|
||||
import { TAwsIamAccountCredentials, TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types";
|
||||
|
||||
export const awsIamResourceFactory: TPamResourceFactory<TAwsIamResourceConnectionDetails, TAwsIamAccountCredentials> = (
|
||||
resourceType: PamResource,
|
||||
connectionDetails: TAwsIamResourceConnectionDetails,
|
||||
// AWS IAM doesn't use gateway
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_gatewayId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_gatewayV2Service,
|
||||
projectId
|
||||
) => {
|
||||
const validateConnection = async () => {
|
||||
try {
|
||||
const isValid = await validatePamRoleConnection(connectionDetails, projectId ?? "");
|
||||
|
||||
if (!isValid) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Unable to assume the PAM role. Verify the role ARN and ensure the trust policy allows Infisical to assume the role."
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ roleArn: connectionDetails.roleArn, region: connectionDetails.region },
|
||||
"[AWS IAM Resource Factory] PAM role connection validated successfully"
|
||||
);
|
||||
|
||||
return connectionDetails;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error(error, "[AWS IAM Resource Factory] Failed to validate PAM role connection");
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<TAwsIamAccountCredentials> = async (
|
||||
credentials
|
||||
) => {
|
||||
try {
|
||||
const isValid = await validateTargetRoleAssumption({
|
||||
connectionDetails,
|
||||
targetRoleArn: credentials.targetRoleArn,
|
||||
projectId: projectId ?? ""
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Unable to assume the target role. Verify the target role ARN and ensure the PAM role has permission to assume it."
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ targetRoleArn: credentials.targetRoleArn },
|
||||
"[AWS IAM Resource Factory] Target role credentials validated successfully"
|
||||
);
|
||||
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error(error, "[AWS IAM Resource Factory] Failed to validate target role credentials");
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<TAwsIamAccountCredentials> = 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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { AwsIamResourceListItemSchema } from "./aws-iam-resource-schemas";
|
||||
|
||||
export const getAwsIamResourceListItem = () => {
|
||||
return {
|
||||
name: AwsIamResourceListItemSchema.shape.name.value,
|
||||
resource: AwsIamResourceListItemSchema.shape.resource.value
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the AWS Account ID from an IAM Role ARN
|
||||
* ARN format: arn:aws:iam::123456789012:role/RoleName
|
||||
*/
|
||||
export const extractAwsAccountIdFromArn = (roleArn: string): string => {
|
||||
const match = roleArn.match(/^arn:aws:iam::(\d{12}):role\//);
|
||||
if (!match) {
|
||||
throw new BadRequestError({ message: "Invalid IAM Role ARN format" });
|
||||
}
|
||||
return match[1];
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamResource } from "../pam-resource-enums";
|
||||
import {
|
||||
BaseCreatePamAccountSchema,
|
||||
BaseCreatePamResourceSchema,
|
||||
BasePamAccountSchema,
|
||||
BasePamAccountSchemaWithResource,
|
||||
BasePamResourceSchema,
|
||||
BaseUpdatePamAccountSchema,
|
||||
BaseUpdatePamResourceSchema
|
||||
} from "../pam-resource-schemas";
|
||||
|
||||
// AWS STS session duration limits (in seconds)
|
||||
// Role chaining (Infisical → PAM role → target role) limits max session to 1 hour
|
||||
// @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
|
||||
const AWS_STS_MIN_SESSION_DURATION = 900; // 15 minutes
|
||||
const AWS_STS_MAX_SESSION_DURATION_ROLE_CHAINING = 3600; // 1 hour
|
||||
|
||||
export const AwsIamResourceConnectionDetailsSchema = z.object({
|
||||
region: z.string().trim().min(1),
|
||||
roleArn: z.string().trim().min(1)
|
||||
});
|
||||
|
||||
export const AwsIamAccountCredentialsSchema = z.object({
|
||||
targetRoleArn: z.string().trim().min(1).max(2048),
|
||||
maxSessionDuration: z.coerce
|
||||
.number()
|
||||
.min(AWS_STS_MIN_SESSION_DURATION)
|
||||
.max(AWS_STS_MAX_SESSION_DURATION_ROLE_CHAINING)
|
||||
});
|
||||
|
||||
const BaseAwsIamResourceSchema = BasePamResourceSchema.extend({
|
||||
resourceType: z.literal(PamResource.AwsIam),
|
||||
gatewayId: z.string().uuid().nullable().optional()
|
||||
});
|
||||
|
||||
export const AwsIamResourceSchema = BaseAwsIamResourceSchema.extend({
|
||||
connectionDetails: AwsIamResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const SanitizedAwsIamResourceSchema = BaseAwsIamResourceSchema.extend({
|
||||
connectionDetails: AwsIamResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const AwsIamResourceListItemSchema = z.object({
|
||||
name: z.literal("AWS IAM"),
|
||||
resource: z.literal(PamResource.AwsIam)
|
||||
});
|
||||
|
||||
export const CreateAwsIamResourceSchema = BaseCreatePamResourceSchema.extend({
|
||||
connectionDetails: AwsIamResourceConnectionDetailsSchema,
|
||||
gatewayId: z.string().uuid().nullable().optional(),
|
||||
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const UpdateAwsIamResourceSchema = BaseUpdatePamResourceSchema.extend({
|
||||
connectionDetails: AwsIamResourceConnectionDetailsSchema.optional(),
|
||||
gatewayId: z.string().uuid().nullable().optional(),
|
||||
rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const AwsIamAccountSchema = BasePamAccountSchema.extend({
|
||||
credentials: AwsIamAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const CreateAwsIamAccountSchema = BaseCreatePamAccountSchema.extend({
|
||||
credentials: AwsIamAccountCredentialsSchema,
|
||||
// AWS IAM doesn't support credential rotation - credentials are generated on-the-fly via STS
|
||||
rotationEnabled: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export const UpdateAwsIamAccountSchema = BaseUpdatePamAccountSchema.extend({
|
||||
credentials: AwsIamAccountCredentialsSchema.optional()
|
||||
});
|
||||
|
||||
export const SanitizedAwsIamAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
|
||||
credentials: AwsIamAccountCredentialsSchema.pick({
|
||||
targetRoleArn: true,
|
||||
maxSessionDuration: true
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
AwsIamAccountCredentialsSchema,
|
||||
AwsIamAccountSchema,
|
||||
AwsIamResourceConnectionDetailsSchema,
|
||||
AwsIamResourceSchema
|
||||
} from "./aws-iam-resource-schemas";
|
||||
|
||||
// Resources
|
||||
export type TAwsIamResource = z.infer<typeof AwsIamResourceSchema>;
|
||||
export type TAwsIamResourceConnectionDetails = z.infer<typeof AwsIamResourceConnectionDetailsSchema>;
|
||||
|
||||
// Accounts
|
||||
export type TAwsIamAccount = z.infer<typeof AwsIamAccountSchema>;
|
||||
export type TAwsIamAccountCredentials = z.infer<typeof AwsIamAccountCredentialsSchema>;
|
||||
@@ -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";
|
||||
@@ -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"))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export enum PamResource {
|
||||
Postgres = "postgres",
|
||||
MySQL = "mysql",
|
||||
SSH = "ssh"
|
||||
SSH = "ssh",
|
||||
AwsIam = "aws-iam"
|
||||
}
|
||||
|
||||
export enum PamResourceOrderBy {
|
||||
|
||||
@@ -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<TPamResourceConnect
|
||||
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
|
||||
[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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<TPamResource, "name" | "connectionDetails" | "resourceType" | "projectId"> & {
|
||||
gatewayId?: string | null;
|
||||
rotationAccountCredentials?: TPamAccountCredentials | null;
|
||||
};
|
||||
|
||||
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
|
||||
resourceId: string;
|
||||
@@ -65,8 +77,9 @@ export type TPamResourceFactoryRotateAccountCredentials<C extends TPamAccountCre
|
||||
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
|
||||
resourceType: PamResource,
|
||||
connectionDetails: T,
|
||||
gatewayId: string,
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">
|
||||
gatewayId: string | null | undefined,
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
|
||||
projectId: string | null | undefined
|
||||
) => {
|
||||
validateConnection: TPamResourceFactoryValidateConnection<T>;
|
||||
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
|
||||
|
||||
@@ -60,6 +60,10 @@ export const sshResourceFactory: TPamResourceFactory<TSSHResourceConnectionDetai
|
||||
) => {
|
||||
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<void>((resolve, reject) => {
|
||||
const client = new Client();
|
||||
@@ -131,6 +135,10 @@ export const sshResourceFactory: TPamResourceFactory<TSSHResourceConnectionDetai
|
||||
credentials
|
||||
) => {
|
||||
try {
|
||||
if (!gatewayId) {
|
||||
throw new BadRequestError({ message: "Gateway ID is required" });
|
||||
}
|
||||
|
||||
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = new Client();
|
||||
|
||||
@@ -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<typeof pamSessionDALFactory>;
|
||||
export const pamSessionDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamSession);
|
||||
@@ -22,5 +24,19 @@ export const pamSessionDALFactory = (db: TDbClient) => {
|
||||
return session;
|
||||
};
|
||||
|
||||
return { ...orm, findById };
|
||||
const expireSessionById = async (sessionId: string, tx?: Knex) => {
|
||||
const now = new Date();
|
||||
|
||||
const updatedCount = await (tx || db)(TableName.PamSession)
|
||||
.where("id", sessionId)
|
||||
.whereIn("status", [PamSessionStatus.Active, PamSessionStatus.Starting])
|
||||
.update({
|
||||
status: PamSessionStatus.Expired,
|
||||
endedAt: now
|
||||
});
|
||||
|
||||
return updatedCount;
|
||||
};
|
||||
|
||||
return { ...orm, findById, expireSessionById };
|
||||
};
|
||||
|
||||
@@ -2,5 +2,6 @@ export enum PamSessionStatus {
|
||||
Starting = "starting", // Starting, user connecting to resource
|
||||
Active = "active", // Active, user is connected to resource
|
||||
Ended = "ended", // Ended by user
|
||||
Terminated = "terminated" // Terminated by an admin
|
||||
Terminated = "terminated", // Terminated by an admin
|
||||
Expired = "expired" // Automatically expired after expiresAt timestamp
|
||||
}
|
||||
|
||||
@@ -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<T> => {
|
||||
// Skip gateway-based sessions - they have their own lifecycle managed by the gateway
|
||||
if (session.gatewayIdentityId) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const isActive = session.status === PamSessionStatus.Active || session.status === PamSessionStatus.Starting;
|
||||
const isExpired = session.expiresAt && new Date(session.expiresAt) <= new Date();
|
||||
|
||||
if (isActive && isExpired) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const updatedSession = await pamSessionDAL.updateById(session.id, {
|
||||
status: PamSessionStatus.Expired,
|
||||
endedAt: new Date()
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
return { ...session, ...updatedSession };
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
const getById = async (sessionId: string, actor: OrgServiceActor) => {
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
const sessionFromDb = await pamSessionDAL.findById(sessionId);
|
||||
if (!sessionFromDb) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
const session = await checkAndExpireSessionIfNeeded(sessionFromDb);
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
@@ -116,7 +147,7 @@ export const pamSessionServiceFactory = ({
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
if (session.gatewayIdentityId !== actor.id) {
|
||||
if (session.gatewayIdentityId && session.gatewayIdentityId !== actor.id) {
|
||||
throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" });
|
||||
}
|
||||
|
||||
@@ -158,7 +189,7 @@ export const pamSessionServiceFactory = ({
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
if (session.gatewayIdentityId !== actor.id) {
|
||||
if (session.gatewayIdentityId && session.gatewayIdentityId !== actor.id) {
|
||||
throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" });
|
||||
}
|
||||
} else if (actor.type === ActorType.USER) {
|
||||
@@ -169,7 +200,7 @@ export const pamSessionServiceFactory = ({
|
||||
throw new ForbiddenRequestError({ message: "Only identities and users can perform this action" });
|
||||
}
|
||||
|
||||
if (session.status === PamSessionStatus.Ended) {
|
||||
if (session.status === PamSessionStatus.Ended || session.status === PamSessionStatus.Expired) {
|
||||
return {
|
||||
session,
|
||||
projectId: project.id
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -279,6 +279,7 @@ import { orgServiceFactory } from "@app/services/org/org-service";
|
||||
import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
|
||||
import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||
import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue";
|
||||
import { pamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue";
|
||||
import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue";
|
||||
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
|
||||
import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
|
||||
@@ -2412,6 +2413,11 @@ export const registerRoutes = async (
|
||||
gatewayV2Service
|
||||
});
|
||||
|
||||
const pamSessionExpirationService = pamSessionExpirationServiceFactory({
|
||||
queueService,
|
||||
pamSessionDAL
|
||||
});
|
||||
|
||||
const pamAccountService = pamAccountServiceFactory({
|
||||
pamAccountDAL,
|
||||
gatewayV2Service,
|
||||
@@ -2423,7 +2429,8 @@ export const registerRoutes = async (
|
||||
permissionService,
|
||||
projectDAL,
|
||||
userDAL,
|
||||
auditLogService
|
||||
auditLogService,
|
||||
pamSessionExpirationService
|
||||
});
|
||||
|
||||
const pamAccountRotation = pamAccountRotationServiceFactory({
|
||||
@@ -2490,6 +2497,7 @@ export const registerRoutes = async (
|
||||
await healthAlert.init();
|
||||
await pkiSyncCleanup.init();
|
||||
await pamAccountRotation.init();
|
||||
await pamSessionExpirationService.init();
|
||||
await dailyReminderQueueService.startDailyRemindersJob();
|
||||
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
||||
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
||||
|
||||
@@ -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<TPamSessionDALFactory, "expireSessionById">;
|
||||
};
|
||||
|
||||
export type TPamSessionExpirationServiceFactory = ReturnType<typeof pamSessionExpirationServiceFactory>;
|
||||
|
||||
export const pamSessionExpirationServiceFactory = ({
|
||||
queueService,
|
||||
pamSessionDAL
|
||||
}: TPamSessionExpirationServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const init = async () => {
|
||||
if (appCfg.isSecondaryInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queueService.startPg<QueueName.PamSessionExpiration>(
|
||||
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<QueueName.PamSessionExpiration>(
|
||||
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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user